/*
Theme Name: Technical Notes
Theme URI: https://blog.sergejkeller.xyz
Author: Sergej Keller
Description: A dark technical-journal block theme. Serif headlines on slate gray, a blue/cyan/turquoise accent system, dot-leader contents lists, path-style breadcrumbs and print-inspired rules.
Version: 1.53.1
Requires at least: 6.5
Tested up to: 6.9
Requires PHP: 7.4
License: GNU General Public License v2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: technical-notes
Tags: blog, dark, one-column, block-patterns, full-site-editing
*/

/* Instrument Serif — self-hosted (SIL OFL), no external font requests */
@font-face {
  font-family: "Instrument Serif";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("assets/fonts/instrument-serif-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Instrument Serif";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("assets/fonts/instrument-serif-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Instrument Serif";
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url("assets/fonts/instrument-serif-italic-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Instrument Serif";
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url("assets/fonts/instrument-serif-italic-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  --tn-bg: #17191B;
  --tn-deep: #131517;
  --tn-page: #1E2326;
  --tn-surface: #262C30;
  --tn-line: #2E3438;
  --tn-line-soft: #272C30;
  --tn-text: #C9CFD2;
  --tn-heading: #F4F6F7;
  --tn-muted: #8B9499;
  /* --tn-faint is decoration only: rules, dot leaders, field edges. It is
     2.70:1 on the page sheet, so it must never carry text. --tn-dim is the
     dimmest tone that still clears WCAG AA 4.5:1 for small copy (4.62:1 on
     the page sheet, 5.33:1 on the header/footer) and replaced --tn-faint
     everywhere text was set in it. */
  --tn-faint: #5C666B;
  --tn-dim: #828C92;
  --tn-cyan: #5FD4E0;
  --tn-blue: #6FA8DC;
  --tn-turquoise: #4EC9A8;
  --tn-cyan-dim: #2E6E7A;
  /* resting underline under prose links — --tn-cyan-dim is 2.74:1 and the
     underline is a link affordance, so it needs the 3:1 non-text minimum */
  --tn-link-rest: #357E8B;
  /* Syntax colours for the C samples (tokenised by theme.js). The theme's
     own three accents carry the structural tokens — blue for keywords,
     turquoise for types, cyan for call sites — and two warm tones are added
     for literals, because a literal has to separate from all three of them
     and every remaining cool hue would have collided. Measured against the
     code panel (--tn-deep): 7.24, 8.93, 10.44, 9.53, 7.03, 7.62 and 5.33:1,
     so the dimmest token still clears AA for body text. */
  --tn-code-kw: #6FA8DC;
  --tn-code-type: #4EC9A8;
  --tn-code-fn: #5FD4E0;
  --tn-code-num: #D7B778;
  --tn-code-str: #D98E73;
  --tn-code-pre: #B99BD8;
  --tn-code-com: #828C92;
  --tn-code-punc: #8B9499;
  --tn-serif: "Instrument Serif", "Iowan Old Style", Georgia, "Times New Roman", serif;
  --tn-sans: system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
  --tn-mono: ui-monospace, "Cascadia Code", "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
  color-scheme: dark;
}

/* Neither element otherwise gets a background of its own — only the
   inner containers (.tn-page, .tn-header, .tn-footer…) paint dark, on
   the assumption the html/body canvas is never actually visible. On iOS
   Safari it is: the rubber-band bounce past the top (or bottom) of the
   page reveals the <html> element's own background, which without this
   defaults to white — and the sticky header's backdrop-filter blur,
   sitting right at that boundary, was sampling/blending with that
   exposed white strip during a bounce, showing as a white gradient at
   the top of the header. Desktop browsers (and Chromium's simulated
   mobile viewports) don't rubber-band, which is why it never showed up
   there. */
html {
  background: var(--tn-bg);
}

body {
  -webkit-font-smoothing: antialiased;
  /* theme.json paints an opaque background straight onto <body>. Without
     a stacking context of its own, body isn't a positioning ancestor for
     z-index purposes, so the matrix canvas's z-index:-1 (a child of body)
     escapes to the root stacking context and paints *before* body's own
     background there — hiding it completely instead of sitting behind
     the page content as intended. isolation (not position: relative,
     which would also change the containing block for any absolutely
     positioned descendant) scopes that z-index back to body, letting the
     canvas paint on top of body's background as intended. */
  isolation: isolate;
}

/* ── themed scrollbar ───────────────────────────────── */

::-webkit-scrollbar {
  width: 12px;
  height: 12px;
}

::-webkit-scrollbar-track {
  background: var(--tn-deep);
}

::-webkit-scrollbar-thumb {
  background: var(--tn-line);
  border: 3px solid var(--tn-deep);
  border-radius: 6px;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--tn-cyan-dim);
}

::-webkit-scrollbar-corner {
  background: var(--tn-deep);
}

/* Firefox only — in Chromium, scrollbar-color would disable the
   ::-webkit-scrollbar styling above */
@supports (-moz-appearance: none) {
  html {
    scrollbar-color: var(--tn-line) var(--tn-deep);
  }
}

/* pin the footer to the viewport bottom on short pages */
.wp-site-blocks {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100svh;
}

/* main sets both cross-axis margins to auto (via .tn-shell), which under
   flexbox exempts it from stretching — it would otherwise shrink to fit
   its content, so template types with only short rows (search, archive)
   rendered narrower than ones containing long paragraphs. */
.wp-site-blocks > main {
  flex-grow: 1;
  width: 100%;
  /* present unconditionally (not just alongside the :has() blur rule
     further down) so the transition still runs when the blur is
     removed, not just when it's applied */
  transition: filter .3s ease;
}

a:focus-visible {
  outline: 2px solid var(--tn-cyan);
  outline-offset: 3px;
  border-radius: 2px;
}

/* ── shells and page sheets ─────────────────────────── */

.tn-shell {
  max-width: 960px;
  margin-left: auto;
  margin-right: auto;
  padding-left: 24px;
  padding-right: 24px;
}

.tn-page {
  background: var(--tn-page);
  border: 1px solid var(--tn-line);
  border-radius: 3px;
  padding: 44px 52px 40px;
  margin: 48px 0;
  /* The ambient shadow used to be 0 16px 40px / .28. Against a #17191B page
     that ramp only spans about four luma levels, so 8-bit quantisation cut
     it into ten flat steps — measured 41 device px wide at the widest, which
     reads as visible banding rather than a gradient. Nothing can add levels
     that 8-bit does not have; the fix is to spend them over a shorter
     distance so the steps sit at too high a spatial frequency to see. At
     16px of blur the widest step drops to 16px, and the deeper .38 both
     restores the weight lost with the tighter blur and buys three more
     gradation steps (13 rather than 10). */
  box-shadow: 0 1px 0 rgba(0, 0, 0, .45), 0 7px 16px rgba(0, 0, 0, .38);
}

@media (max-width: 700px) {
  .tn-page { padding: 28px 22px 26px; }
}

/* thick-thin print rule under section labels */
.tn-rule-head {
  position: relative;
  padding-bottom: 9px;
  margin-bottom: 22px;
  border-bottom: 2px solid var(--tn-line);
}

.tn-rule-head::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -5px;
  height: 1px;
  background: var(--tn-line);
}

.tn-smallcaps {
  font-family: var(--tn-sans);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: .22em;
  text-transform: uppercase;
}

/* ── masthead ───────────────────────────────────────── */

.tn-header {
  display: block;
  position: sticky;
  top: -1px;
  z-index: 100;
  background: var(--tn-deep);
  border-bottom: 1px solid var(--tn-line);
  transition: background-color .35s ease;
}

/* The frosted glass lives on an overlay that overshoots the top edge,
   so the backdrop-filter's sampling boundary sits off-screen — its
   on-screen edge is what flickers in Chromium while scrolling. Painted
   above the header's own background (negative z-index child of a
   stacking context) but under the content; blurring the uniform tint
   is a no-op, so the effect only shows once the tint goes translucent.
   saturate(180%) + blur(20px) is the Apple frosted-glass recipe —
   the saturation keeps colors behind the glass vivid. */
.tn-header::before {
  content: "";
  position: absolute;
  top: -12px;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: -1;
  transform: translateZ(0);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  backdrop-filter: saturate(180%) blur(20px);
  transition: -webkit-backdrop-filter .35s ease, backdrop-filter .35s ease;
}

/* compact mode — html.tn-stuck is set by theme.js past the page top */

html.tn-stuck .tn-header {
  background: rgba(19, 21, 23, .68);
}

/* While the mobile menu is up the header goes fully opaque and drops
   the glass entirely, so it and the dropdown hanging off it read as one
   flat, continuous surface with nothing showing through either of them.
   The dropdown (further down) sets the same var(--tn-deep) fill for the
   same reason; matching them on a *translucent* tint instead can't work
   — they are separate elements, so each backdrop-filter samples
   whatever happens to be behind itself and the two drift out of sync as
   the page scrolls.

   Keyed off .is-menu-open directly rather than a lingering helper
   class: core clears it the instant a close starts, which is exactly
   when the header should begin fading back to its resting (or stuck)
   tint, in step with the panel rolling up. Outranks the html.tn-stuck
   rule above on specificity, so the compact header gives up its
   translucency for the duration too. */
html:has(.tn-nav .wp-block-navigation__responsive-container.is-menu-open) .tn-header {
  background: var(--tn-deep);
}

html:has(.tn-nav .wp-block-navigation__responsive-container.is-menu-open) .tn-header::before {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

.tn-mast {
  /* anchors the mobile nav dropdown (below) to "right after the brand/nav
     row" — its padding box is exactly where .tn-crumb-bar begins, so
     top:100% there needs no JS measurement and holds regardless of
     resting/stuck sizing. */
  position: relative;
  padding-top: 27px;
  padding-bottom: 6px;
  transition: padding .35s ease;
}

html.tn-stuck .tn-mast {
  padding-top: 12px;
  padding-bottom: 0;
}

/* centred, not baseline: the search field has no text baseline to share
   with the site title, and baseline alignment dropped it ~19px low */
.tn-mast-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  flex-wrap: wrap;
}

.tn-brand {
  display: flex;
  align-items: baseline;
  gap: 12px;
  flex-wrap: wrap;
}

.tn-brand .wp-block-site-title {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: clamp(1.8rem, 3.4vw, 2.4rem);
  line-height: 1.1;
  margin: 0;
  transition: font-size .35s ease;
}

html.tn-stuck .tn-brand .wp-block-site-title {
  font-size: 1.4rem;
}

.tn-brand .wp-block-site-title a {
  color: inherit;
  text-decoration: none;
}

.tn-brand-em {
  font-family: var(--tn-serif);
  font-style: italic;
  color: var(--tn-cyan);
  font-size: clamp(1.5rem, 2.8vw, 1.95rem);
  margin: 0;
  transition: font-size .35s ease;
}

html.tn-stuck .tn-brand-em {
  font-size: 1.15rem;
}

/* ── header search ──────────────────────────────────── */

.tn-mast-aside {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 10px;
  /* core's flow layout gives group children margin-block-start: 24px,
     which pushed the field below the title's centre line */
  margin: 0;
  align-self: center;
  transition: gap .35s ease;
}

.tn-search {
  margin: 0;
  max-height: 40px;
  overflow: hidden;
  visibility: visible;
  transition: opacity .3s ease, max-height .35s ease, transform .35s ease,
              visibility 0s linear 0s;
}

/* The compact masthead used to collapse the search field away at every
   width. Above 700px it does not have to: the field shares the brand row
   there rather than taking one of its own, so keeping it costs 4px of
   header height (measured: 88 -> 92px at 1440, 1024 and 760px) and the row
   still does not wrap.

   Between 600 and 700px .tn-mast-aside is full-width and would wrap the
   field onto a row of its own, so while stuck it is shrunk back to its
   content and joins the brand row instead — there is 385px of free space
   next to the brand at 700px and the field needs 245px.

   Below 600px the hamburger exists, and the field lives inside it as the
   menu's first item (moved there by theme.js) rather than in the header
   at all. */
@media (min-width: 600px) and (max-width: 700px) {
  html.tn-stuck .tn-mast-aside {
    gap: 0;
    width: auto;
  }
}

/* Once theme.js has moved the field into the menu this group holds
   nothing, and an empty full-width flex item still takes a wrapped row of
   its own — measured 24px of .tn-mast-row gap spent on a zero-height line.
   The class is set by theme.js rather than matching :empty, which never
   fired: the block template leaves two whitespace text nodes inside the
   group, and :empty counts those as content. */
.tn-mast-aside.is-vacated {
  display: none;
}

/* ── search inside the mobile menu ──────────────────────
   The field is the menu's first item below 600px. It is a real child of
   core's panel content, so it inherits the panel's 24px inset and its
   open/close clip-path for free — nothing here has to animate. */
.tn-search.tn-search-in-menu {
  width: 100%;
  align-self: stretch;
  max-height: none;
  /* No rule under the field, and no margin either. It read as a stray mark
     against the first menu link rather than as a separator, and the margin
     was doubling the panel's own 24px row-gap to 48px — the field now sits
     on the same rhythm as the links it precedes. */
  margin: 0;
}

.tn-search.tn-search-in-menu .wp-block-search__input,
.tn-search.tn-search-in-menu .wp-block-search__input:focus {
  width: 100%;
}

/* Same four-layer glass as the buttons. The masthead background is
   --tn-deep, so the resting fill stays --tn-surface to keep the field
   from disappearing into the header, and the resting edge is --tn-faint
   rather than the buttons' dimmer --tn-line. */
/* the glass itself is shared with the text inputs, further down */
.tn-search .wp-block-search__inside-wrapper {
  display: flex;
  align-items: center;
  padding: 0;
  position: relative;                 /* anchors the console prompt ghost */
}

.tn-search .wp-block-search__input {
  width: 190px;
  background: transparent;
  border: 0;
  border-radius: 0;
  padding: 6px 10px;
  font-family: var(--tn-mono);
  font-size: 12.5px;
  color: var(--tn-text);
  transition: width .3s ease;
}

.tn-search .wp-block-search__input:focus {
  outline: none;
  width: 230px;
}

.tn-search .wp-block-search__input::placeholder {
  color: var(--tn-muted);
}

/* the icon button drops the block button's border and sweep */
.tn-search .wp-block-search__button {
  background: transparent;
  background-image: none;
  border: 0;
  border-left: 1px solid var(--tn-faint);
  border-radius: 0;
  padding: 5px 9px;
  min-width: 0;
  color: var(--tn-text);
  box-shadow: none;
  transition: color .2s ease;
}

/* stays transparent on hover: an opaque fill here would mask the field's
   own glass and read as a dark patch under the cursor */
.tn-search .wp-block-search__button:hover {
  background: transparent;
  background-image: none;
  color: var(--tn-cyan);
  box-shadow: none;
}

.tn-search .wp-block-search__button:active {
  transform: none;
}

.tn-search .wp-block-search__button svg {
  width: 19px;
  height: 19px;
  fill: currentColor;
  display: block;
}

/* ── masthead console ───────────────────────────────── */

/* Typing ">" into the search field arms it as a command prompt: theme.js
   (module 8) adds .is-console and the magnifier gives way to a "run"
   label.

   The label is laid over the icon rather than replacing it, and the icon
   only loses `visibility` — it keeps its 19px box. So the button is
   dimensionally identical armed or not, whatever the label says, and no
   amount of typing and deleting the ">" can reflow the masthead. The
   button is already `position: relative` via .wp-element-button. */
.tn-search .tn-run {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: none;
  align-items: center;
  justify-content: center;
  font-family: var(--tn-mono);
  font-size: 11.5px;
  letter-spacing: .06em;
  line-height: 1;
  pointer-events: none;
}

.tn-search.is-console .wp-block-search__button svg { visibility: hidden; }
.tn-search.is-console .tn-run { display: flex; }

/* The prompt the field's own placeholder cannot show, because a browser
   stops painting a placeholder as soon as the value is non-empty and an
   armed field always holds the ">". Drawn just after the caret instead:
   the input's own 10px padding, plus one mono advance for the ">" — and
   because .tn-hint repeats the input's family and size, 1ch here is
   exactly the character it is clearing. */
.tn-search .tn-hint {
  position: absolute;
  left: calc(10px + 1ch);
  top: 50%;
  transform: translateY(-50%);
  display: none;
  padding-left: .7ch;
  font-family: var(--tn-mono);
  font-size: 12.5px;
  line-height: 1;
  color: var(--tn-muted);
  white-space: nowrap;
  pointer-events: none;
}

.tn-search.is-prompt .tn-hint { display: block; }

/* The flash is an overlay, not an animation on `color` or `background`.
   An animated property beats any normal declaration for as long as it
   runs, so animating either would have locked out the button's own
   :hover and :active rules the whole time it was armed — the button
   would have stopped responding to the cursor. A pseudo-element wash
   leaves every existing state rule intact and simply tints over it.

   A slow breath, not a blink: this is a 43px button in a masthead, and
   anything faster reads as an error rather than an invitation. */
.tn-search.is-console .wp-block-search__button::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: var(--tn-cyan);
  animation: tn-console-breathe 2.6s ease-in-out infinite;
}

@keyframes tn-console-breathe {
  0%, 100% { opacity: 0; }
  50%      { opacity: .12; }
}

/* Command accepted / not recognised. Same specificity as the breathing
   rule, so they sit after it to win; both fade to nothing and the class
   is dropped after 600ms. */
.tn-search.is-ok .wp-block-search__button::after,
.tn-search.is-err .wp-block-search__button::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  animation: tn-console-flash .6s ease;
}

.tn-search.is-ok .wp-block-search__button::after  { background: var(--tn-turquoise); }
.tn-search.is-err .wp-block-search__button::after { background: #E05F5F; }

@keyframes tn-console-flash {
  0%   { opacity: .24; }
  100% { opacity: 0; }
}

/* Armed is information, not decoration, so it survives as a static tint
   rather than being dropped along with the animation. */
@media (prefers-reduced-motion: reduce) {
  .tn-search.is-console .wp-block-search__button::after {
    animation: none;
    opacity: .12;
  }

  .tn-search.is-ok .wp-block-search__button::after,
  .tn-search.is-err .wp-block-search__button::after {
    animation: none;
    opacity: .24;
  }
}

@media (max-width: 700px) {
  .tn-mast-aside {
    align-items: flex-start;
    width: 100%;
  }

  .tn-search .wp-block-search__input,
  .tn-search .wp-block-search__input:focus {
    width: 100%;
  }

  .tn-search { width: 100%; }

  /* The resting clamp()s (3.4vw/2.8vw floors of 1.8rem/1.5rem) were sized
     for widths where the brand row has the whole shell to itself; on a
     phone it shares that row with nothing else (search/nav/social already
     move to their own rows below), but "Sergej's website" plus "—
     Technical Notes" at those floors still doesn't fit ~327px of content
     width alongside the 46px logo — so it wrapped to two lines at rest
     and only closed back up to one line once the stuck sizes (1.4rem/
     1.15rem) took over, reading as a layout jump on scroll. Sizing the
     resting mobile state close to the stuck one keeps both states
     single-line, so scrolling only changes size, never line count. */
  .tn-brand {
    flex-wrap: nowrap;
    /* baseline treats the logo (a replaced element with no text baseline
       of its own) by its bottom edge, which reads fine when the logo sits
       on its own wrapped line at rest — but forcing everything onto one
       nowrap line above put the much taller 46px logo's bottom edge level
       with the *text's* baseline, leaving the title sitting noticeably
       high above the icon's visual centre. Centring keeps both readable
       regardless of the logo/text size ratio at any point in the
       resting-to-stuck transition. */
    align-items: center;
  }

  .tn-brand .wp-block-site-title {
    font-size: 1.3rem;
  }

  .tn-brand-em {
    font-size: 1.05rem;
  }

  html.tn-stuck .tn-brand .wp-block-site-title {
    font-size: 1.2rem;
  }

  html.tn-stuck .tn-brand-em {
    font-size: 1rem;
  }

  /* The desktop paddings (27/6 resting, 12/0 stuck) were sized around a
     flat 4-item nav row; with that row now collapsing to a single toggle
     button below 600px (see .tn-nav overlay rules further down), the
     header no longer needs as much breathing room on a phone, and giving
     some back keeps the resting header from crowding out the fold. */
  .tn-mast {
    padding-top: 16px;
    padding-bottom: 4px;
  }

  .tn-mast-sub {
    margin-top: 10px;
    padding: 8px 0;
  }

  /* Stuck + mobile is the tightest case: this is the state that has to
     stay well clear of the "never ~1/4 of the viewport" ceiling on a
     short phone screen. The tagline is decorative rather than functional,
     so it is the one thing dropped outright here rather than just
     shrunk — nav (now a single icon), search-collapse and social all
     already reduce to their minimum on their own. */
  html.tn-stuck .tn-mast {
    padding-top: 8px;
    padding-bottom: 0;
  }

  html.tn-stuck .tn-mast-sub {
    margin-top: 4px;
    padding: 4px 0 2px;
  }

  /* On mobile, .tn-mast-aside (holding search) always wraps onto its own
     line below the brand row — .tn-mast-row's own 24px gap is a *row* gap
     there, not a horizontal one. Flexbox gap is inserted between wrapped
     lines regardless of a line's own content height, so even though
     .tn-search collapses to max-height:0 while stuck, the 24px gap above
     that now-empty line stayed, showing up as a dead band roughly where
     the search field used to be. Closing the row gap only while stuck
     removes exactly that reserved space; at rest the search field is
     visible again and the gap is wanted, so it's untouched there. */
  html.tn-stuck .tn-mast-row {
    gap: 0;
  }

  html.tn-stuck .tn-tagline {
    display: none;
  }

  /* The breadcrumb stays visible (it's the one piece of "where am I"
     wayfinding on a page with no visible URL bar chrome on most mobile
     browsers) but tightens up in the stuck state, the same way the rest
     of the mast does. */
  html.tn-stuck .tn-crumb-bar .wp-block-shortcode {
    padding: 4px 0;
  }
}

/* ── mobile nav: header-attached dropdown, not a full-screen modal ──
   overlayMenu:"mobile" on the wp:navigation block (parts/header.html)
   turns the flat 4-item list into a single toggle button below core's
   own fixed 600px breakpoint. Core's own default for that toggle is a
   full-viewport modal (position:fixed; inset:0) — overridden below so
   the panel instead drops down from the bottom of the brand/nav row,
   as an extension of the header itself: the header stays in view, the
   breadcrumb bar is overdrawn by the panel rather than pushed down, and
   the page content beneath is blurred instead of hidden behind a solid
   scrim. The extra .tn-nav class in each selector is what wins
   specificity over core's own `:not(.has-background)` /
   `:not(.has-text-color)` fallback rules, which are otherwise exactly
   as specific.

   Core reuses the exact same list markup for two very different jobs:
   below 600px it's this dropdown (.is-menu-open), at 600px and up the
   very same .wp-block-navigation__responsive-container-content is simply
   redisplayed in place as the ordinary flat nav row (see core's own
   `@media (min-width:600px) …:not(.is-menu-open) { display:block; … }`).
   Styling ".wp-block-navigation-item a" without the .is-menu-open guard
   therefore doesn't just style the dropdown — it also overrides the
   existing .tn-nav a rule (size, tracking, underline sweep) on the
   ordinary desktop nav, which is not what these rules are for. Every
   selector below is scoped to .is-menu-open so the flat/resting nav is
   left to .tn-nav a untouched. */

.tn-nav .wp-block-navigation__responsive-container-open,
.tn-nav .wp-block-navigation__responsive-container-close {
  align-items: center;
  justify-content: center;
  color: var(--tn-text);
  padding: 10px;
  transition: color .2s ease;
}

/* Unconditional display:flex here would beat core's own
   `@media (min-width:600px) …:not(.always-shown){ display:none; }` rule
   (same specificity, later in the cascade) and leave the hamburger toggle
   visible on desktop even though all nav items are already shown flat.
   Scoping it to the same sub-600px range core uses keeps that hide rule
   intact while still giving the mobile toggle its layout below it. */
@media (max-width: 599.98px) {
  .tn-nav .wp-block-navigation__responsive-container-open,
  .tn-nav .wp-block-navigation__responsive-container-close {
    display: flex;
  }
}

.tn-nav .wp-block-navigation__responsive-container-open:hover,
.tn-nav .wp-block-navigation__responsive-container-close:hover {
  color: var(--tn-cyan);
}

/* Hamburger <-> close: a real bar morph, not a swap between two icons.
   Core's two SVGs (a two-bar glyph vs. an X path) can't be interpolated
   into each other — there's no shared path to animate between. theme.js
   replaces -open's SVG with three real <span class="tn-burger-bar">
   elements instead, so the outer bars can rotate 45°/-45° and slide to
   the middle bar's line while the middle bar itself fades away, the same
   three-line-crossing-into-a-cross animation as a typical native
   hamburger-menu icon. -close keeps no visible icon of its own — it
   stays exactly where -open is (see the negative top offset below) as
   an invisible hit target so a real click/tap lands on core's own
   closeMenuOnClick, while the animated bars living on -open are what the
   user actually sees turn into an X. */
.tn-nav .wp-block-navigation__responsive-container-open svg,
.tn-nav .wp-block-navigation__responsive-container-close svg {
  display: none;
}

.tn-burger {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  width: 22px;
  height: 16px;
}

.tn-burger-bar {
  display: block;
  width: 100%;
  height: 2px;
  border-radius: 1px;
  background: currentColor;
  transform-origin: center;
  transition: transform .35s ease, opacity .35s ease;
}

.tn-nav:has(.wp-block-navigation__responsive-container.is-menu-open) .tn-burger-bar:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}

.tn-nav:has(.wp-block-navigation__responsive-container.is-menu-open) .tn-burger-bar:nth-child(2) {
  opacity: 0;
  transform: scaleX(0);
}

.tn-nav:has(.wp-block-navigation__responsive-container.is-menu-open) .tn-burger-bar:nth-child(3) {
  transform: translateY(-7px) rotate(-45deg);
}

.tn-nav .wp-block-navigation__responsive-container-close {
  opacity: 0;
  pointer-events: none;
}

.tn-nav:has(.wp-block-navigation__responsive-container.is-menu-open) .wp-block-navigation__responsive-container-close {
  pointer-events: auto;
}

.tn-nav:has(.wp-block-navigation__responsive-container.is-menu-open) .wp-block-navigation__responsive-container-open {
  pointer-events: none;
}

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

/* Core positions this dialog as a full-viewport fixed overlay
   (position:fixed; inset:0) unconditionally, sized to cover the whole
   screen. Repositioned here to hang instead from .tn-mast — the nearest
   positioned ancestor once .tn-mast carries position:relative above —
   so top:100% always lands exactly at .tn-mast's own bottom edge
   (where .tn-crumb-bar begins) regardless of the resting/stuck header
   height, with no JS measurement needed. bottom:auto lets it size to
   its own content instead of stretching to the viewport's bottom edge;
   max-height + overflow guard against a very long menu on a very short
   screen. Because it's now anchored inside .tn-mast's own padding box,
   left/right:0 already lands it at the shell's 24px inset — the same
   content width the breadcrumb text uses, so the panel's edges line up
   with what it's covering. */
/* Core sets `.wp-block-navigation { position: relative; }` unconditionally
   on the <nav> itself (block-library/style.css) — normally there to anchor
   dropdown submenus. This nav has none, but it's still a *closer*
   positioned ancestor than .tn-mast, so without this override the panel
   below anchored to <nav>'s own small, button-sized box instead of
   .tn-mast — landing narrow and only one row down rather than spanning
   the header and clearing the whole brand/nav row. Same specificity as
   core's rule (one class each); this wins on source order alone since
   the theme stylesheet loads after core's. */
.tn-nav {
  position: static;
}

/* ── mobile nav dropdown ────────────────────────────────
   Core drives this panel with a hard display:none <-> flex swap on
   .is-menu-open, with no transition step to animate through — and it
   drops every bit of its own dropdown styling in that same instant, so
   anything keyed off that class snapped back to core's flat-nav
   defaults while the panel was still visibly on screen: the items
   reflowed into a horizontal row partway through a close.

   Everything below is therefore keyed off the mobile breakpoint alone,
   never .is-menu-open — the panel is always laid out and always styled
   as a dropdown, and only clip-path/visibility toggle. Nothing has to
   revert mid-animation, so nothing can flash. It costs no space while
   closed (the panel is absolutely positioned) and shows nothing (it is
   clipped to zero height and visibility:hidden).

   The reveal is clip-path rather than transform or height:
   • transform is unusable — core runs its own ~100ms open animation on
     this element through the Web Animations API, and a running WAAPI
     effect overrides the cascade (!important included) for whatever
     properties it targets, which are transform and opacity.
   • height/max-height is unusable — overflow has to stay visible for
     -close, which sits 60px above this box's own top edge (see the
     negative offsets below), and an overflow-clipped ancestor would cut
     that hit target off.
   Sweeping the bottom inset 100% -> 0% gives the same top-to-bottom
   unroll. The top inset stays pinned at -60px rather than 0 so -close
   is never clipped away, in the resting header height or the stuck one.

   The wordy selectors are specificity, not decoration: core styles
   these same elements with four class-level selectors of its own under
   .is-menu-open, and its `:not(.hidden-by-default):not(.is-menu-open)`
   rule (three classes) otherwise drags the panel back to
   position:relative/display:block the moment is-menu-open goes away.
   Each rule here carries one class more than core's equivalent so it
   wins outright rather than on stylesheet order. */
@media (max-width: 599.98px) {
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) {
    display: flex !important;
    flex-direction: column;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    bottom: auto;
    width: auto;
    height: auto;
    overflow: visible;
    z-index: 60;
    /* Opaque, and the same fill the header is forced to for as long as
       the menu is up (see the :has(.is-menu-open) .tn-header rule near
       the top), so the two read as one continuous surface — no glass,
       no translucency, nothing showing through from behind either.
       These two carry !important because core's own closed-state rule
       does: it forces `background-color: inherit !important` (and the
       same for color) on this element, which no amount of specificity
       can outrank — with a normal declaration here the panel fell back
       to transparent the moment a close began, and to core's flat white
       while open. */
    background-color: var(--tn-deep) !important;
    color: var(--tn-text) !important;
    border-bottom: 1px solid var(--tn-line);
    box-shadow: 0 16px 32px rgba(0, 0, 0, .35);
    clip-path: inset(-60px 0 100% 0);
    visibility: hidden;
    pointer-events: none;
    transition: clip-path .3s ease, visibility 0s linear .3s;
  }

  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container.is-menu-open:not(.disable-default-overlay) {
    clip-path: inset(-60px 0 0% 0);
    visibility: visible;
    pointer-events: auto;
    transition: clip-path .3s ease, visibility 0s linear;
  }

  /* box-sizing is core's, and only under .is-menu-open — without it the
     content box below starts counting its 24px side padding outside its
     own width partway through a close. */
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation__responsive-close,
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation__responsive-dialog,
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation__responsive-container-content {
    box-sizing: border-box;
  }

  /* The column layout is core's as well, and likewise only under
     .is-menu-open — the list's own base rule is a horizontal
     `display:flex; flex-wrap:wrap` row, which is exactly what flashed
     through as the panel collapsed.

     The padding replaces core's, whose horizontal inset is a clamp()
     reading --wp--style--root--padding-left/right — custom properties
     this theme's theme.json never defines. An undefined var() inside
     clamp() invalidates the whole declaration, so the browser drops it
     rather than substituting 0, and the list rendered flush against the
     panel edge; 24px is what the rest of the shell uses. Core's
     generous top clearance is unnecessary too, now that -close sits up
     in the header row rather than inside this content box. */
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation__responsive-container-content {
    display: flex;
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: flex-start;
    padding: 20px 24px 0;
    /* the scroll safety net for a very long menu on a very short
       screen, scoped to the list rather than the panel, whose overflow
       has to stay visible (see above) */
    max-height: 70vh;
    overflow-y: auto;
  }

  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation__container {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
  }

  /* A hairline between items and a bit more room per row is what the
     flat colour list was missing — the same quiet-rule language as the
     contents list and CV timeline elsewhere. The rows deliberately keep
     core's shrink-to-fit width, so each rule runs only as wide as the
     label it sits under. */
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation-item {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    border-bottom: 1px solid var(--tn-line-soft);
  }

  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation-item:last-child {
    border-bottom: 0;
  }

  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation-item a {
    display: block;
    font-family: var(--tn-sans);
    font-size: 15px;
    font-weight: 600;
    letter-spacing: .1em;
    text-transform: uppercase;
    color: var(--tn-text);
    text-decoration: none;
    padding: 14px 0;
    transition: color .2s ease, transform .2s ease;
  }

  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay) .wp-block-navigation-item a:hover {
    color: var(--tn-cyan);
    transform: translateX(6px);
  }
}

/* Matches the specificity of the two panel rules above (and the page
   scrim's further down) and sits after them, so it wins the tie. */
@media (prefers-reduced-motion: reduce) {
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container:not(.disable-default-overlay),
  .tn-nav.wp-block-navigation .wp-block-navigation__responsive-container.is-menu-open:not(.disable-default-overlay),
  .tn-header ~ main,
  .tn-header ~ footer {
    transition: none;
  }
}

/* -close is nested inside the panel above, so it inherits *that* as its
   positioned ancestor — its containing block's top-left corner is
   therefore .tn-mast's own bottom-left corner (0, panel top). -open's
   own on-screen box was measured at (24, 153) resting and (24, 54)
   stuck, i.e. 60px / 46px above that same corner — so a negative `top`
   of that distance, with the same 24px `left` the shell uses everywhere
   else, lands -close exactly over -open instead of at core's default
   top:0;right:0 (the panel's own top-right corner). Left is used rather
   than right because .tn-mast-sub renders the tagline first and it's
   normally empty, which puts the nav — and so -open — flush at the
   shell's left edge, not the right. */
.tn-nav .wp-block-navigation__responsive-container-close {
  top: -60px;
  left: 24px;
  right: auto;
}

html.tn-stuck .tn-nav .wp-block-navigation__responsive-container-close {
  top: -46px;
}

/* ── blur the page behind the open dropdown ─────────────
   Same idea as the lightbox scrim: rather than hiding the content below
   the header, blur it in place so the page still reads as "there, just
   not what has focus right now".

   The transition lives on the resting rule rather than the open one so
   it runs in both directions — declared only on the open rule, the
   un-blur has no transition to inherit once that rule stops matching
   and snaps sharp instead. And the trigger is .is-menu-open, which core
   clears the instant a close begins, so the page sharpens over the same
   300ms the panel takes to roll back up rather than waiting for the
   roll to finish first. pointer-events:none keeps a blurred, unfocused
   page from still being clickable/scrollable underneath the dropdown. */
.tn-header ~ main,
.tn-header ~ footer {
  transition: filter .3s ease;
}

html:has(.tn-nav .wp-block-navigation__responsive-container.is-menu-open) .tn-header ~ main,
html:has(.tn-nav .wp-block-navigation__responsive-container.is-menu-open) .tn-header ~ footer {
  filter: blur(8px) brightness(.7);
  pointer-events: none;
  user-select: none;
}

.tn-mast-sub {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  flex-wrap: wrap;
  margin-top: 18px;
  padding: 12px 0;
  border-top: 1px solid var(--tn-line-soft);
  transition: margin-top .35s ease, padding .35s ease;
}

html.tn-stuck .tn-mast-sub {
  margin-top: 8px;
  padding: 6px 0 2px;
}

.tn-tagline {
  font-family: var(--tn-serif);
  font-style: italic;
  font-size: 1.1rem;
  color: var(--tn-muted);
  margin: 0;
}

/* a sibling of the nav, so space-between parks it at the right edge;
   margin-left keeps it there even if a site tagline is ever set, and
   margin: 0 defeats core's flow-layout margin-block-start */
.tn-social {
  display: flex;
  align-items: center;
  gap: 9px;
  margin: 0 0 0 auto;
}

/* 24x24 hit area (WCAG 2.2 SC 2.5.8) around an 18x18 mark. The gap drops
   from 15px to 9px so the icons keep their original 33px centre-to-centre
   pitch — the targets grow, the rhythm does not change. */
.tn-social-link {
  display: block;
  box-sizing: border-box;
  width: 24px;
  height: 24px;
  padding: 3px;
  color: var(--tn-muted);
  transition: color .2s ease, transform .2s ease;
}

.tn-social-link:hover {
  color: var(--tn-cyan);
  transform: translateY(-1px);
}

.tn-social-link svg {
  display: block;
  width: 100%;
  height: 100%;
}

.tn-nav a {
  font-family: var(--tn-sans);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--tn-muted);
  text-decoration: none;
  /* 4px block padding lifts the 19px line box to a 27px target
     (WCAG 2.2 SC 2.5.8); the underline rides the padding-box bottom
     edge, so it stays put relative to the text */
  padding: 4px 0;
  background-image: linear-gradient(var(--tn-cyan), var(--tn-cyan));
  background-repeat: no-repeat;
  background-position: right bottom;
  background-size: 0% 2px;
  transition: color .2s ease, background-size .28s ease;
}

/* underline grows in from the left, exits to the right (the
   background-position jump is deliberately not transitioned) */
.tn-nav a:hover {
  color: var(--tn-cyan);
  background-position: left bottom;
  background-size: 100% 2px;
}

.tn-nav .current-menu-item > a,
.tn-nav [aria-current="page"] {
  color: var(--tn-text);
  background-position: left bottom;
  background-size: 100% 2px;
}

/* ── static trace dividers (square-wave rules) ──────── */

.tn-trace {
  box-sizing: content-box;
  height: 75px;
  margin-top: 6px;
  background-repeat: repeat-x;
  background-position: left top;
  /* the no-JS fallback SVGs are drawn at 56px — scale them up */
  background-size: auto 100%;
  overflow: hidden;
}

/* easter egg: hold 5s -> decoded word as flashing marquee */

.tn-trace.tn-marquee {
  display: flex;
  align-items: center;
}

.tn-marquee-text {
  display: inline-block;
  font-family: var(--tn-mono);
  font-size: 36px;
  line-height: 75px;
  white-space: nowrap;
  will-change: transform;
  animation:
    tn-marquee-scroll 14s linear infinite,
    tn-marquee-flash .9s steps(1) infinite;
}

@keyframes tn-marquee-scroll {
  to { transform: translateX(-50%); }
}

@keyframes tn-marquee-flash {
  0%   { color: var(--tn-cyan); }
  33%  { color: var(--tn-blue); }
  66%  { color: var(--tn-turquoise); }
}

@media (prefers-reduced-motion: reduce) {
  .tn-marquee-text {
    animation: none;
    color: var(--tn-cyan);
  }

  .tn-header,
  .tn-mast,
  .tn-mast-sub,
  .tn-brand .wp-block-site-title,
  .tn-brand-em,
  .tn-logo,
  .tn-crumb-bar,
  .tn-nav a,
  .tn-social-link,
  .tn-mast-aside,
  .tn-search,
  .tn-search .wp-block-search__input,
  .tn-search .wp-block-search__inside-wrapper,
  .tn-search .wp-block-search__button,
  .wp-element-button,
  .wp-block-button__link,
  .comment-form input[type="submit"],
  .tn-cv-item,
  .tn-cv-chips > span,
  .tn-cv-chips > a,
  .tn-cv-contact a.mail-link img {
    transition: none;
  }
}

.tn-trace-cyan {
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='208'%20height='56'%3E%3Cpath%20d='M0%2010H26V22H78V10H104V22H130V10H182V22H208V10'%20fill='none'%20stroke='%235FD4E0'%20stroke-opacity='0.55'/%3E%3Cpath%20d='M0%2042H26V30H78V42H104V30H130V42H182V30H208V42'%20fill='none'%20stroke='%234EC9A8'%20stroke-opacity='0.55'/%3E%3Cg%20fill='%235C666B'%20font-family='monospace'%20font-size='10'%20text-anchor='middle'%3E%3Ctext%20x='13'%20y='54'%3E1%3C/text%3E%3Ctext%20x='39'%20y='54'%3E0%3C/text%3E%3Ctext%20x='65'%20y='54'%3E0%3C/text%3E%3Ctext%20x='91'%20y='54'%3E1%3C/text%3E%3Ctext%20x='117'%20y='54'%3E0%3C/text%3E%3Ctext%20x='143'%20y='54'%3E1%3C/text%3E%3Ctext%20x='169'%20y='54'%3E1%3C/text%3E%3Ctext%20x='195'%20y='54'%3E0%3C/text%3E%3C/g%3E%3C/svg%3E");
}

.tn-trace-blue {
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='208'%20height='56'%3E%3Cpath%20d='M0%2022H26V10H78V22H104V10H130V22H182V10H208V22'%20fill='none'%20stroke='%236FA8DC'%20stroke-opacity='0.55'/%3E%3Cpath%20d='M0%2030H26V42H78V30H104V42H130V30H182V42H208V30'%20fill='none'%20stroke='%234EC9A8'%20stroke-opacity='0.55'/%3E%3Cg%20fill='%235C666B'%20font-family='monospace'%20font-size='10'%20text-anchor='middle'%3E%3Ctext%20x='13'%20y='54'%3E0%3C/text%3E%3Ctext%20x='39'%20y='54'%3E1%3C/text%3E%3Ctext%20x='65'%20y='54'%3E1%3C/text%3E%3Ctext%20x='91'%20y='54'%3E0%3C/text%3E%3Ctext%20x='117'%20y='54'%3E1%3C/text%3E%3Ctext%20x='143'%20y='54'%3E0%3C/text%3E%3Ctext%20x='169'%20y='54'%3E0%3C/text%3E%3Ctext%20x='195'%20y='54'%3E1%3C/text%3E%3C/g%3E%3C/svg%3E");
}

.tn-trace-inset {
  margin: 30px 0 8px;
  border-top: 1px solid var(--tn-line-soft);
  border-bottom: 1px solid var(--tn-line-soft);
  padding: 4px 0;
}

/* ── breadcrumb bar (path style) ────────────────────── */

.tn-crumb-bar {
  background: var(--tn-deep);
  border-bottom: 1px solid var(--tn-line-soft);
  transition: background-color .35s ease;
}

/* stays visible in compact mode — just goes translucent so the
   header's backdrop blur shows through */
html.tn-stuck .tn-crumb-bar {
  background: transparent;
}

.tn-crumb-bar .wp-block-shortcode { padding: 9px 0; }

.tn-crumbs {
  display: flex;
  align-items: baseline;
  font-family: var(--tn-mono);
  font-size: 12.5px;
  color: var(--tn-dim);
  white-space: nowrap;
  overflow: hidden;
  /* breathing room under the trail, on the nav rather than the shortcode
     wrapper around it so the caret's descender below the baseline is
     inside the padding rather than against the bar's edge */
  padding-bottom: 4px;
}

.tn-crumbs a {
  flex: none;
  color: var(--tn-muted);
  text-decoration: none;
}

.tn-crumbs a:hover { color: var(--tn-cyan); }
.tn-crumbs .sep { flex: none; margin: 0 .55em; }

/* ~/notes/glossary are short and fixed; the current-page title (the last
   segment) is the one part that's ever long enough to wrap the trail
   onto a second line — a long post title or archive label. Truncating
   just that segment, rather than the whole trail, keeps the "you are
   here" path visible up to its own name instead of losing the leading
   path context off one end. */
.tn-crumbs .here {
  overflow: hidden;
  min-width: 0;
  color: var(--tn-cyan);
  text-overflow: ellipsis;
}

/* A terminal caret closing the path, since the trail already reads as one
   (`~ / glossary / canopen` in mono). Drawn as a box rather than an "_"
   glyph so its weight and length are set here instead of inherited from
   whatever mono face the visitor actually has — underscore thickness and
   how far it sits below the baseline both vary between families, and a
   glyph would also be selectable as text.

   align-self: baseline puts the box's bottom edge on the text baseline,
   which is where an underscore belongs: it reads as attached to the last
   segment rather than trailing after it.

   It rides on the nav's ::after, not on .here::after, because .here is the
   element that ellipsises: a caret inside it would be clipped by that same
   overflow the moment a title got long. Sitting outside, it stays put at
   the end of the trail.

   Only above 600px. The longest trail on the site (the soldering-iron
   post) has 139px to spare there, but just 19px at 480 and it ellipsises
   below ~460 — and a caret trailing an "…" reads as a glitch rather than a
   prompt. Phones also hold the breadcrumb closer to the eye, where a
   moving element earns its keep least. */
@media (min-width: 600px) {
  .tn-crumbs::after {
    content: "";
    flex: none;
    align-self: baseline;
    width: .62em;
    height: .13em;
    margin-left: .45em;
    background: var(--tn-cyan);
    /* 1.8s and eased, so it swells and fades rather than snapping: a hard
       toggle at the usual ~500ms terminal rate is exactly the flicker that
       pulls the eye away from the text below it. */
    animation: tn-caret 1.8s ease-in-out infinite;
  }
}

@keyframes tn-caret {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .tn-crumbs::after {
    animation: none;
    opacity: .55;
  }
}

/* ── front page: hero ───────────────────────────────── */

.tn-eyebrow {
  color: var(--tn-cyan);
  margin: 0 0 18px;
}

.tn-hero .wp-block-post-title {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: clamp(2rem, 4.6vw, 3.3rem);
  line-height: 1.14;
  text-wrap: balance;
  margin: 0 0 18px;
}

.tn-hero .wp-block-post-title a {
  color: inherit;
  text-decoration: none;
}

.tn-hero .wp-block-post-title a:hover { color: var(--tn-cyan); }

.tn-hero .wp-block-post-excerpt {
  font-family: var(--tn-serif);
  font-style: italic;
  font-size: 1.25rem;
  color: var(--tn-muted);
  margin: 0 0 20px;
}

.tn-hero .wp-block-post-excerpt__more-text,
.tn-hero .wp-block-post-excerpt__more-link { display: none; }

.wp-block-post-date {
  font-family: var(--tn-mono);
  font-size: 12.5px;
  color: var(--tn-dim);
}

/* ── front page: welcome panel ──────────────────────── */

/* The welcome panel and the notes under it are two sections of one sheet.
   A trace divider used to hold them apart, but that figure is the footer's
   easter egg and does not belong up here — so the gap is stated directly.
   It has to be stated somewhere: .tn-welcome-meta closes on margin 0, and
   without this the "Latest notes" label sat straight on the path line. */
.tn-welcome { margin-bottom: 46px; }

.tn-welcome-title {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: clamp(2.2rem, 5vw, 3.6rem);
  line-height: 1.12;
  text-wrap: balance;
  margin: 0 0 18px;
}

.tn-welcome-lede {
  font-family: var(--tn-serif);
  font-style: italic;
  font-size: 1.25rem;
  color: var(--tn-muted);
  margin: 0 0 20px;
}

.tn-welcome-meta {
  font-family: var(--tn-mono);
  font-size: 12.5px;
  color: var(--tn-dim);
  margin: 0;
}

/* ── note preview cards ─────────────────────────────── */

/* The front page used to lead with one post rendered as a second, larger
   welcome panel: serif display title, italic serif excerpt — the same two
   faces, in the same order, as the panel directly above it, so the eye read
   the pair as one long banner and the post underneath it as decoration.

   The cards fix that by changing register rather than size. Meta goes mono,
   the title stays serif because that is what a title is here, and the
   excerpt moves to the body sans: it is the only running text on the page
   set the way the notes themselves are set, which is exactly the promise a
   preview should make.

   The hover is the contents rows': .tn-card is added to those selectors
   rather than given a copy of them, so the site's two post listings answer
   the pointer identically and there is one place to change it. */

.tn-cards {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 14px;
}

.tn-card {
  position: relative;
  display: grid;
  grid-template-columns: 220px 1fr;
  gap: 22px;
  /* stretch, not start: a thumbnail sized to its own aspect ratio ends well
     short of the text beside it and leaves a corner of empty sheet under the
     picture, which reads as a layout that failed rather than a margin. The
     picture takes the full height of whatever the text needs and crops to
     it. */
  align-items: stretch;
  padding: 18px;
  border: 1px solid var(--tn-line-soft);
  border-radius: 4px;
  /* isolate, and seed the cursor position, for the same two reasons the
     contents rows do — see the comment on that rule */
  isolation: isolate;
  --mx: 50%;
  --my: 50%;
  transition: border-color .3s ease;
}

.tn-card:hover { border-color: var(--tn-line); }

/* A card with no picture gives the text the whole width rather than leaving
   a hole where the image would have been. */
.tn-card:not(:has(.tn-card-media)) { grid-template-columns: 1fr; }

.tn-card-media {
  position: relative;
  overflow: hidden;
  border-radius: 3px;
  background: var(--tn-deep);
  /* the floor is for a card whose text is unusually short — without it the
     picture would collapse to the height of two lines of excerpt */
  min-height: 150px;
}

.tn-card-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* The photographs are daylight shots against a page this dark; at full
     strength one card's picture outweighs every word on the screen. They
     come up to full only when the card is being read. */
  filter: saturate(.72) brightness(.78);
  transform: scale(1.02);
  transition: filter .4s ease, transform .5s cubic-bezier(.33, 1, .68, 1);
}

.tn-card:hover .tn-card-img,
.tn-card:focus-within .tn-card-img {
  filter: none;
  transform: scale(1.06);
}

.tn-card-meta {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--tn-mono);
  font-size: 11.5px;
  color: var(--tn-dim);
  margin: 0 0 8px;
}

.tn-card-n {
  color: var(--tn-cyan);
  letter-spacing: .06em;
}

/* the hairline between the numeral and the date, so the meta line reads as
   one rule with two labels on it rather than three loose fragments */
.tn-card-hair {
  flex: 1;
  height: 1px;
  background: var(--tn-line);
}

.tn-card-title {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: 1.5rem;
  line-height: 1.22;
  text-wrap: balance;
  margin: 0 0 8px;
}

.tn-card-title a {
  color: inherit;
  text-decoration: none;
}

/* stretched over the whole card, so the card is one click target */
.tn-card-title a::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: 4px;
}

.tn-card:hover .tn-card-title a,
.tn-card-title a:focus-visible { color: var(--tn-cyan); }

.tn-card-cats {
  font-size: 10px;
  letter-spacing: .18em;
  color: var(--tn-faint);
  margin: 0 0 10px;
}

/* lifted back above the stretched title link, or they would not be clickable */
.tn-card-cats a {
  position: relative;
  z-index: 3;
  color: var(--tn-cyan-dim);
  text-decoration: none;
}

.tn-card-cats a:hover,
.tn-card-cats a:focus-visible { color: var(--tn-cyan); }

.tn-card-excerpt {
  font-family: var(--tn-sans);
  font-size: .95rem;
  line-height: 1.6;
  color: var(--tn-text);
  margin: 0;
  max-width: 62ch;
}

.tn-card-more {
  font-family: var(--tn-mono);
  font-size: 11.5px;
  letter-spacing: .08em;
  color: var(--tn-cyan);
  margin: 12px 0 0;
  opacity: 0;
  transform: translateX(-4px);
  transition: opacity .3s ease, transform .3s ease;
}

.tn-card:hover .tn-card-more,
.tn-card:focus-within .tn-card-more {
  opacity: 1;
  transform: none;
}

.tn-card-arrow { display: inline-block; }

.tn-card:hover .tn-card-arrow { animation: tn-card-nudge 1.1s ease-in-out infinite; }

@keyframes tn-card-nudge {
  0%, 100% { transform: translateX(0); }
  50%      { transform: translateX(4px); }
}

.tn-cards-more {
  font-family: var(--tn-mono);
  font-size: 12.5px;
  letter-spacing: .08em;
  margin: 22px 0 0;
  text-align: right;
}

.tn-cards-more a {
  color: var(--tn-cyan);
  text-decoration: none;
  border-bottom: 1px solid var(--tn-cyan-dim);
  padding-bottom: 2px;
}

.tn-cards-more a:hover,
.tn-cards-more a:focus-visible { border-bottom-color: var(--tn-cyan); }

.tn-cards-empty {
  color: var(--tn-dim);
  font-family: var(--tn-mono);
  font-size: 13px;
}

/* Below the article breakpoint the picture goes over the text rather than
   beside it: at 168px the thumbnail is already the larger half of a phone
   column, and a 4:3 crop that narrow shows nothing of the photograph. */
@media (max-width: 700px) {
  .tn-card {
    grid-template-columns: 1fr;
    gap: 14px;
  }

  .tn-card-media { aspect-ratio: 16 / 9; }

  .tn-card-title { font-size: 1.3rem; }
}

/* The hover motion is the whole point of the card, so with reduced motion it
   is removed rather than shortened — the brackets and the "read note" cue
   simply rest in their lit state, which keeps the affordance visible without
   anything travelling. */
@media (prefers-reduced-motion: reduce) {
  .tn-card,
  .tn-card-img,
  .tn-card-more,
  .tn-card::before { transition: none; }

  .tn-card-more { opacity: 1; transform: none; }

  .tn-card:hover .tn-card-arrow { animation: none; }
}

/* A card list can sit inside an article layout too: the DIY Projects page is
   [tn_note_index type="diy"] in an ordinary page body, so the intro
   above it can be written in the editor like the glossary's. Two article
   rules reach into the cards there and are won back here — the h3 section
   heading (its size and 42px top margin) and the bordered, auto-height
   content image, which left the photo stranded at the top of a box it is
   meant to fill. The prose link sweep is kept off by a :not() in that rule,
   the way the contents list's is. */
.tn-article .wp-block-post-content .tn-card-title {
  font-size: 1.5rem;
  margin: 0 0 8px;
}

.tn-article .wp-block-post-content .tn-card-img {
  max-width: none;
  height: 100%;
  border: 0;
  border-radius: 0;
}

@media (max-width: 700px) {
  .tn-article .wp-block-post-content .tn-card-title { font-size: 1.3rem; }
}

/* ── front page: about-me strip ─────────────────────── */

/* The CV as one row between the welcome panel and the notes: portrait, name,
   role, and the way in. It is a .tn-card, so the pointer gets the same answer
   it gets one section down — border, glass, the whole row as one link. What
   changes is the grid: this is a signpost to a page rather than a preview of
   an article, and at note-card height it would push the notes off the first
   screen. The gap under it is the welcome panel's, for the same reason. */
.tn-about-me { margin-bottom: 46px; }

.tn-card.tn-about-card {
  grid-template-columns: 64px 1fr auto;
  grid-template-areas: "media body more";
  align-items: center;
  gap: 18px;
  padding: 14px 18px;
}

.tn-about-media {
  grid-area: media;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  overflow: hidden;
  background: var(--tn-deep);
}

.tn-about-body {
  grid-area: body;
  min-width: 0;
}

.tn-about-card .tn-card-title {
  font-size: 1.35rem;
  margin: 0 0 2px;
}

.tn-about-role {
  font-family: var(--tn-sans);
  font-size: .95rem;
  line-height: 1.5;
  color: var(--tn-muted);
  margin: 0;
}

/* On a row this short the cue is the only sign there is more, so it does not
   wait for the hover the way the note cards' does; it still nudges on one. */
.tn-about-card .tn-card-more {
  grid-area: more;
  margin: 0;
  opacity: 1;
  transform: none;
  white-space: nowrap;
}

/* On a phone the cue drops under the role instead of squeezing it. */
@media (max-width: 700px) {
  .tn-card.tn-about-card {
    grid-template-columns: 56px 1fr;
    grid-template-areas: "media body" "media more";
    gap: 6px 14px;
  }

  .tn-about-media {
    width: 56px;
    height: 56px;
  }

  .tn-about-card .tn-card-title { font-size: 1.2rem; }
}

/* ── contents list (dot leaders) ────────────────────── */

.tn-contents { margin-top: 34px; }

.tn-contents .wp-block-post-template {
  list-style: none;
  padding: 0;
  margin: 0;
}

.tn-contents .wp-block-post-template > li {
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 14px 10px;
  margin: 0;
  border-bottom: 1px solid var(--tn-line-soft);
  /* isolate so the glass overlay below can sit at z-index -1: above the
     row's own background, still underneath the title and metadata */
  position: relative;
  isolation: isolate;
  /* The cursor position has to be seeded here rather than on ::before,
     which only inherits it: buttons.js writes --mx/--my to this element,
     and without a resting value the overlay's gradients are invalid at
     computed-value time (background-image collapses to none) until the
     first pointermove. */
  --mx: 50%;
  --my: 50%;
}

.tn-contents .wp-block-post-template > li:first-child {
  border-top: 1px solid var(--tn-line-soft);
}

/* Same cursor-following glass as the buttons, but rendered on a ::before
   overlay (the row is a flex container, so its own background can't carry
   the four-layer stack without disturbing the layout) and keyed to the
   greys rather than cyan. The ceiling is --tn-surface (38,44,48) — the
   flat colour this hover used to be — reached only by the lit edge right
   under the cursor; everything else stays below it, so the row brightens
   to at most what it used to be, but unevenly and following the pointer.
   A row is a much larger target than a button, so the radii are wide.
   --glass is declared on the pseudo-element itself: it is registered with
   inherits:false, so a value set on the <li> would not reach ::before. */
.tn-contents .wp-block-post-template > li::before,
.tn-card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  --glass: 0;
  background-image:
    radial-gradient(340px circle at var(--mx) var(--my),
      rgba(38, 44, 48, calc(var(--glass) * .80)),
      rgba(38, 44, 48, 0) 70%),
    linear-gradient(
      rgba(38, 44, 48, calc(var(--glass) * .18)),
      rgba(38, 44, 48, calc(var(--glass) * .18))),
    radial-gradient(240px circle at var(--mx) var(--my),
      rgba(38, 44, 48, var(--glass)),
      rgba(38, 44, 48, calc(var(--glass) * .55)) 40%,
      rgba(38, 44, 48, calc(var(--glass) * .18)) 100%),
    linear-gradient(transparent, transparent);
  background-clip: padding-box, padding-box, border-box, border-box;
  background-origin: border-box;
  background-repeat: no-repeat;
  border: 1px solid transparent;
  border-radius: 4px;
  transition: --glass .3s ease;
}

.tn-contents .wp-block-post-template > li:hover::before,
.tn-card:hover::before { --glass: 1; }

.tn-contents .wp-block-post-title {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: 1.22rem;
  line-height: 1.4;
  margin: 0;
}

/* A contents list can also sit inside an article layout — the glossary page
   is one. Two more specific rules apply there and have to be won back: the
   h2/h3 section-heading sizes and margins, and .tn-article
   .wp-block-post-title, which is meant for the one big title at the top of
   an article and carries a rule underneath it. Neither belongs on a row of
   a list. (The home page needs none of this: its main is .tn-shell without
   .tn-article, so neither rule reaches the contents list there.) */
.tn-article .wp-block-post-content .tn-contents .wp-block-post-title {
  font-size: 1.22rem;
  line-height: 1.4;
  margin: 0;
  padding: 0;
  border: 0;
}

.tn-contents .wp-block-post-title a {
  color: inherit;
  text-decoration: none;
}

.tn-contents li:hover .wp-block-post-title a { color: var(--tn-cyan); }

/* dotted leader between title and metadata */
.tn-contents .wp-block-post-template > li::after {
  content: "";
  flex: 1;
  min-width: 24px;
  order: 1;
  border-bottom: 1px dotted var(--tn-faint);
  transform: translateY(-4px);
}

.tn-contents .wp-block-post-terms { order: 2; }

.tn-contents .wp-block-post-date {
  order: 3;
  color: var(--tn-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

@media (max-width: 640px) {
  .tn-contents .wp-block-post-template > li { flex-wrap: wrap; }
  .tn-contents .wp-block-post-template > li::after { display: none; }
  .tn-contents .wp-block-post-date { margin-left: auto; }
}

/* alphabetical section dividers, injected server-side (see
   technical_notes_glossary_dividers() in functions.php) as plain <li>
   elements ahead of the first entry under each letter. They match the
   `> li` selector above and would otherwise inherit its flex row layout,
   border and hover glass — all cancelled here in favour of a quiet
   section label in the smallcaps/rule-head voice already used for the
   "Terms" heading above the list, so a divider reads as structure rather
   than another row. */
.tn-contents .wp-block-post-template > li.tn-contents-divider {
  display: block;
  /* horizontal padding matches the 10px the entry rows above indent their
     own text by (14px 10px), so the letter lines up over the titles it
     introduces rather than sitting flush with the sheet edge */
  padding: 0 10px;
  margin: 28px 0 6px;
  border: 0;
  font-family: var(--tn-sans);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--tn-cyan);
  pointer-events: none;
}

.tn-contents .wp-block-post-template > li.tn-contents-divider:first-child {
  margin-top: 0;
}

.tn-contents .wp-block-post-template > li.tn-contents-divider::before,
.tn-contents .wp-block-post-template > li.tn-contents-divider::after {
  content: none;
}

/* topic tags: each carries its own accent shade */

.wp-block-post-terms {
  font-family: var(--tn-sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  white-space: nowrap;
}

.wp-block-post-terms a {
  color: var(--tn-cyan);
  text-decoration: none;
}

.wp-block-post-terms a[href*="infra"],
.wp-block-post-terms a[href*="docker"] { color: var(--tn-blue); }
.wp-block-post-terms a[href*="git"] { color: var(--tn-turquoise); }

/* ── pagination ─────────────────────────────────────── */

.wp-block-query-pagination {
  display: flex;
  gap: 18px;
  margin-top: 26px;
  font-family: var(--tn-mono);
  font-size: 13px;
  align-items: center;
}

.wp-block-query-pagination-previous,
.wp-block-query-pagination-next,
.wp-block-query-pagination .page-numbers {
  --glass: 0;
  --mx: 50%;
  --my: 50%;
  position: relative;
  font-family: var(--tn-mono);
  font-size: 13px;
  letter-spacing: .05em;
  color: var(--tn-text);
  background-image:
    radial-gradient(120px circle at var(--mx) var(--my),
      rgba(95, 212, 224, calc(var(--glass) * .20)),
      rgba(95, 212, 224, 0) 65%),
    linear-gradient(var(--tn-surface), var(--tn-surface)),
    radial-gradient(90px circle at var(--mx) var(--my),
      rgba(226, 250, 253, calc(var(--glass) * .85)),
      rgba(95, 212, 224, calc(var(--glass) * .40)) 45%,
      rgba(46, 110, 122, calc(var(--glass) * .18)) 100%),
    linear-gradient(var(--tn-line), var(--tn-line));
  background-clip: padding-box, padding-box, border-box, border-box;
  background-origin: border-box;
  background-repeat: no-repeat;
  border: 1px solid transparent;
  padding: 8px 16px;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-decoration: none;
  transition:
    --glass .28s ease,
    color .2s ease,
    transform .12s ease;
}

.wp-block-query-pagination-previous:hover,
.wp-block-query-pagination-next:hover,
.wp-block-query-pagination .page-numbers:hover {
  --glass: 1;
  color: var(--tn-heading);
}

.wp-block-query-pagination-previous:active,
.wp-block-query-pagination-next:active,
.wp-block-query-pagination .page-numbers:active {
  transform: translateY(1px) scale(.99);
}

/* styling the page numbers as a combined button */
.wp-block-query-pagination-numbers {
  display: inline-flex;
  gap: 0;
}

/* overlap borders on combined page numbers to look like a single button block */
.wp-block-query-pagination .page-numbers {
  border-radius: 0;
  margin-left: -1px;
}

/* restore outer border-radius for the combined button group */
.wp-block-query-pagination .page-numbers:first-child,
.wp-block-query-pagination-numbers .page-numbers:first-child {
  border-top-left-radius: 3px;
  border-bottom-left-radius: 3px;
  margin-left: 0;
}

.wp-block-query-pagination .page-numbers:last-child,
.wp-block-query-pagination-numbers .page-numbers:last-child {
  border-top-right-radius: 3px;
  border-bottom-right-radius: 3px;
}

/* separate buttons for previous and next links */
.wp-block-query-pagination-previous,
.wp-block-query-pagination-next {
  border-radius: 3px;
}

/* current page highlights */
.wp-block-query-pagination .page-numbers.current {
  color: var(--tn-cyan);
  background-image:
    radial-gradient(120px circle at var(--mx) var(--my),
      rgba(95, 212, 224, calc(var(--glass) * .20)),
      rgba(95, 212, 224, 0) 65%),
    linear-gradient(var(--tn-deep), var(--tn-deep)),
    radial-gradient(90px circle at var(--mx) var(--my),
      rgba(226, 250, 253, calc(var(--glass) * .85)),
      rgba(95, 212, 224, calc(var(--glass) * .40)) 45%,
      rgba(46, 110, 122, calc(var(--glass) * .18)) 100%),
    linear-gradient(var(--tn-cyan-dim), var(--tn-cyan-dim));
}

.wp-block-query-pagination .page-numbers:hover,
.wp-block-query-pagination .page-numbers.current {
  z-index: 1;
}

/* ── article view ───────────────────────────────────── */

.tn-article-grid { gap: 44px; }

.tn-rail {
  font-family: var(--tn-mono);
  font-size: 12px;
  color: var(--tn-dim);
  line-height: 2;
}

.tn-rail .wp-block-post-date { display: block; }

.tn-rail .wp-block-post-terms {
  display: block;
  white-space: normal;
  margin: 6px 0;
}

/* ── separators in the article body ─────────────────────
   Core draws its separator as a 2px mid-grey rule, which is both heavier and
   warmer than anything else on the page — every other line the theme draws is
   1px of --tn-line, so the section break above the comments was reading as a
   foreign element rather than as part of the article.

   The rail has its own separator rule directly below. It is equal in
   specificity to this one, so it has to stay *after* it in source order to
   keep its own tighter 14px spacing. */
.tn-article .wp-block-separator {
  border: 0;
  border-top: 1px solid var(--tn-line);
  background: none;
  height: auto;
  width: auto;
  opacity: 1;
  /* Zero below: the comments heading brings its own leading, and the section
     break belongs to the article it closes rather than to what follows. */
  margin: 44px 0 0;
}

.tn-rail hr,
.tn-rail .wp-block-separator {
  border: 0;
  border-top: 1px solid var(--tn-line);
  margin: 14px 0;
  background: none;
  height: auto;
  width: auto;
}

@media (min-width: 782px) {
  .tn-rail {
    border-right: 1px solid var(--tn-line-soft);
    padding-right: 24px;
  }
}

/* ── article contents list (rail) ───────────────────── */

/* Where a heading comes to rest when it is jumped to. The compact masthead
   measures 115px tall (theme.js collapses it past 120px of scroll), so this
   clears it with 17px of air. It is also the sticky offset for the contents
   list itself, which is what keeps the two pinned to the same line.

   Declared as scroll-margin on the headings rather than as a scroll offset in
   the click handler so that a plain #anchor URL — pasted, bookmarked, or
   arrived at from a search result — lands in the same place as a click on the
   list, without JavaScript being involved at all. */
:root { --tn-toc-top: 132px; }

.tn-article .wp-block-post-content h2,
.tn-article .wp-block-post-content h3,
.tn-article .wp-block-post-content h4,
/* the comments section is a contents entry too, and it is outside the post
   content, so it needs the offset named separately */
.tn-article .wp-block-comments {
  scroll-margin-top: var(--tn-toc-top);
}

/* Both rail sections carry their own rule and label rather than sitting under
   a separator block from the template: each one is inserted by a filter and
   can be absent (no categories to relate to, fewer than two headings), and a
   separator that outlives the thing it separates leaves a stray line in the
   margin. */
.tn-toc,
.tn-rail-related {
  margin-top: 20px;
  padding-top: 16px;
  border-top: 1px solid var(--tn-line);
}

/* An article rail keeps a separator block between its categories and its
   tags, and a post with no tags renders that block as nothing at all — which
   leaves the separator resting directly on the next section's own rule, two
   lines 21px apart. The adjacent sibling is the tell: these sections only
   ever follow a separator directly when whatever it was separating them from
   did not render, so the section drops its rule and lets the separator's
   14px bottom margin do the spacing. */
.tn-rail .wp-block-separator + .tn-rail-related,
.tn-rail .wp-block-separator + .tn-toc {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}

.tn-toc-label,
.tn-rail-label {
  /* .tn-smallcaps is sized for body copy; the rail runs two steps smaller,
     and at .22em of tracking the label outgrows a 160px column otherwise. */
  font-size: 10px;
  letter-spacing: .18em;
  color: var(--tn-cyan);
  margin: 0 0 10px;
}

.tn-toc-list {
  position: relative;                 /* the marker below positions against it */
  list-style: none;
  margin: 0;
  padding: 0;
  /* The rail sets line-height 2 for the metadata above; a wrapped heading
     needs its two lines to read as one entry, not as two. */
  line-height: 1.45;
}

/* Items draw the grey rule; the cyan is drawn by the single marker below,
   sliding between them. Items butt together, so the separate borders read as
   one continuous line for it to travel along. */
.tn-toc-item {
  border-left: 2px solid var(--tn-line);
  padding: 5px 0 5px 12px;
}

/* Once the marker exists the rule is drawn by one element instead, spanning
   only from the first entry's text to the last entry's — the two ends the
   marker can actually reach. Drawn by the items, the rule also covered their
   5px of padding, so the marker on the first entry started 5px below the top
   of the rule and left a stub of grey above itself. The item borders go
   transparent rather than away, so the 2px they occupy stays reserved and
   nothing shifts; and they remain the rule outright when there is no
   JavaScript to place the marker or size this one.

   The marker lives *inside* the rule, and the rule hides its overflow. That
   is the point of the nesting: the cyan physically cannot paint outside the
   grey, at any zoom, device pixel ratio or font, however the arithmetic that
   positions it turns out. Two rounds of this bar hanging past the end of the
   rule were both fixed by correcting a calculation, and both came back —
   containment does not depend on getting a number right. It also means the
   marker can no longer contribute scrollable overflow to the rail, which is
   where the stray scrollbar came from. */
.tn-toc-list.has-marker .tn-toc-item { border-left-color: transparent; }

.tn-toc-rule {
  position: absolute;
  left: 0;
  /* Pinned to the list, whose box the items fill exactly — so the rule covers
     every position the marker can take without anything having to add up. */
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--tn-line);
  overflow: hidden;
  pointer-events: none;
}

/* The moving indicator.

   One element that slides, rather than a border colour handed from item to
   item. That is not a stylistic preference, it is the cheaper of the two by
   some way: transitioning colour and border-colour on the items measured 106
   style recalcs against 12, and 12.7% CPU against 7.1% with the list absent,
   for one scroll through the article; this measures 87 and 10.5%. Chromium
   does not composite the transition even with will-change, so it is not free
   — it is simply half the price for a better result.

   Sized by scaleY rather than by animating height, because height is a layout
   property and would put a full layout in every frame.

   The base is 100px and every bar scales *down* to fit. It was 1px scaling up,
   which is the same arithmetic and quietly wrong: a 1px box is 1.5 device px
   at 150% zoom, the browser snaps it to 2 (1.333 CSS px), and scaleY then
   multiplies that 33% error by 27 to 45 — every bar drew a third too long, on
   exactly the zoom levels an integer device pixel ratio never reproduces. A
   100px base makes the same snap a 0.5% error, and theme.js divides by the
   height it measures rather than the one written here, so even that cancels. */
.tn-toc-marker {
  position: absolute;
  left: 0;
  top: 0;
  width: 2px;
  height: 100px;
  background: var(--tn-cyan);
  transform-origin: 0 0;
  opacity: 0;
  pointer-events: none;
  transition: transform .34s cubic-bezier(.33, 1, .68, 1), opacity .2s ease;
}

.tn-toc-marker.is-on { opacity: 1; }

/* Placement that is not a move between sections — the first one, and every
   re-measure after a reflow — must not sweep in from wherever the marker
   happened to be left. Only the travel is suppressed; the fade still runs. */
.tn-toc-marker.is-instant { transition: opacity .2s ease; }

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

.tn-toc-d1 { padding-left: 24px; }
.tn-toc-d2 { padding-left: 36px; }

.tn-toc-item a {
  display: block;
  color: var(--tn-dim);
  text-decoration: none;
}

.tn-toc-item a:hover,
.tn-toc-item a:focus-visible,
.tn-toc-item.is-current a {
  color: var(--tn-cyan);
}

/* Below the columns breakpoint the rail is no longer a rail: it wraps to full
   width and stacks above the article, where there is nothing to stick to. */
@media (min-width: 782px) {
  .tn-toc {
    position: sticky;
    top: var(--tn-toc-top);
    /* A contents list longer than the screen would otherwise pin with its
       tail hanging off the bottom, permanently out of reach. Keep the list
       internally scrollable, but hide its scrollbar chrome: page-level
       navigation should not grow a second visible scrollbar beside the
       document's own. Wheel, touch, keyboard and focus scrolling still work. */
    max-height: calc(100vh - var(--tn-toc-top) - 24px);
    overflow-y: auto;
    overscroll-behavior: contain;
    -ms-overflow-style: none;
    scrollbar-width: none;
  }

  .tn-toc::-webkit-scrollbar {
    display: none;
    width: 0;
    height: 0;
  }
}

/* A heading is only focusable because the click handler moves focus there, so
   the ring would announce a landing spot the reader never chose to tab to. */
.tn-article .wp-block-post-content h2:focus,
.tn-article .wp-block-post-content h3:focus,
.tn-article .wp-block-post-content h4:focus {
  outline: none;
}

.tn-article .wp-block-post-title {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: clamp(1.9rem, 4vw, 2.8rem);
  line-height: 1.18;
  text-wrap: balance;
  margin: 0 0 22px;
  padding-bottom: 22px;
  border-bottom: 1px solid var(--tn-line-soft);
}

.tn-article .wp-block-post-content {
  line-height: 1.7;
}

.tn-article .wp-block-post-content > p { max-width: 65ch; }

/* drop cap on the opening paragraph — unless that paragraph is a card
   list's empty state ("No projects yet."), a status line, not an opening */
.tn-article .wp-block-post-content > p:first-child:not(.tn-cards-empty)::first-letter {
  font-family: var(--tn-serif);
  float: left;
  font-size: 3.4em;
  line-height: .82;
  padding: .06em .12em 0 0;
  color: var(--tn-cyan);
  font-weight: 400;
}

.tn-article .wp-block-post-content h2,
.tn-article .wp-block-post-content h3 {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: 1.6rem;
  margin: 42px 0 12px;
}

/* Prose links share the nav's left-to-right underline sweep. Two stacked
   layers rather than the nav's one: a dim rule at full width that is
   always there (a link buried in a paragraph still has to look like a
   link before you point at it — the nav can go bare because its items
   read as navigation on their own), and the bright cyan bar that grows
   in over it. box-decoration-break keeps both painted per line when a
   link wraps, instead of one background stretched across the fragments.
   Excludes the CV's contact strip and skill chips, which carry their own
   treatment, any link that wraps an image, and contents lists and note
   cards — a row in a dot-leader list or a card in a stack is not running
   copy, and its own hover is already the affordance.
   The front page's welcome text is running copy as well, written in the
   Site Editor as plain paragraphs, so its links join in.
   The comments block gets the same treatment so a link in a comment body
   matches one in the post above it — but its own furniture (author name,
   permalink date, reply and edit links) is excluded below, since those
   are controls rather than prose and a 2px bar under every one of them
   turns the thread into a ladder. */
.tn-article .wp-block-post-content a:not(.tn-cv-contact a):not(.tn-cv-chips a):not(.tn-contents a):not(.tn-cards a):not(.tn-cite):not(:has(img)),
.tn-welcome p a:not(:has(img)),
.wp-block-comments a:not(:has(img)):not(.wp-element-button):not(.wp-block-comment-author-name a):not(.wp-block-comment-date a):not(.wp-block-comment-edit-link a):not(.wp-block-comment-reply-link a) {
  color: var(--tn-cyan);
  text-decoration: none;
  padding-bottom: 2px;
  background-image:
    linear-gradient(var(--tn-cyan), var(--tn-cyan)),
    linear-gradient(var(--tn-link-rest), var(--tn-link-rest));
  background-repeat: no-repeat;
  background-position: right bottom, left bottom;
  background-size: 0% 2px, 100% 1px;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  transition: background-size .28s ease;
}

/* grows in from the left, exits to the right — the background-position
   jump is deliberately not transitioned (same trick as .tn-nav a) */
.tn-article .wp-block-post-content a:not(.tn-cv-contact a):not(.tn-cv-chips a):not(.tn-contents a):not(.tn-cards a):not(.tn-cite):not(:has(img)):hover,
.tn-welcome p a:not(:has(img)):hover,
.wp-block-comments a:not(:has(img)):not(.wp-element-button):not(.wp-block-comment-author-name a):not(.wp-block-comment-date a):not(.wp-block-comment-edit-link a):not(.wp-block-comment-reply-link a):hover {
  background-position: left bottom, left bottom;
  background-size: 100% 2px, 100% 1px;
}

/* comment furniture: metadata, not prose, so it follows the footer's
   quieter pattern (muted → cyan) instead of the sweep above. Without
   this it would fall through to the browser default blue, which is the
   only reason these links looked out of place to begin with. */
.wp-block-comment-author-name a,
.wp-block-comment-date a,
.wp-block-comment-edit-link a,
.wp-block-comment-reply-link a {
  color: var(--tn-muted);
  text-decoration: none;
  transition: color .2s ease;
}

.wp-block-comment-author-name a:hover,
.wp-block-comment-date a:hover,
.wp-block-comment-edit-link a:hover,
.wp-block-comment-reply-link a:hover {
  color: var(--tn-cyan);
}

.tn-article .wp-block-post-content p code,
.tn-article .wp-block-post-content li code {
  font-family: var(--tn-mono);
  font-size: .87em;
  background: var(--tn-surface);
  border: 1px solid var(--tn-line);
  padding: 1px 6px;
  border-radius: 4px;
}

.tn-article .wp-block-post-content pre,
.tn-article .wp-block-code {
  background: var(--tn-deep);
  border: 1px solid var(--tn-line);
  border-radius: 6px;
  padding: 16px;
  margin: 26px 0;
  overflow-x: auto;
  font-family: var(--tn-mono);
  font-size: 13.5px;
  line-height: 1.6;
  color: var(--tn-text);
  /* Core's block library sets white-space: pre-wrap on .wp-block-code, which
     folds a long statement onto the next line at column zero — in C that
     reads as a new statement at the wrong nesting depth, and it put the
     tail of a ternary directly under the enclosing brace in the watchdog
     sample. The panel already carries overflow-x: auto, so honouring the
     newlines and letting a long line scroll is both truer and no worse. */
  white-space: pre;
}

/* ── code copy control ───────────────────────────────── */

/* The wrapper takes over the panel's vertical rhythm so the two are not
   both contributing margin, and becomes the positioning context for the
   button. The button cannot sit inside the <pre>: that box scrolls
   horizontally, and an absolutely positioned child of it slides out of
   view with the code as soon as a line is long enough to matter. */
.tn-article .wp-block-post-content .tn-copy-wrap {
  position: relative;
  margin: 26px 0;
}

.tn-article .wp-block-post-content .tn-copy-wrap > pre {
  margin: 0;
}

.tn-article .wp-block-post-content .tn-copy {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 9px;
  border: 1px solid var(--tn-line);
  border-radius: 5px;
  background: var(--tn-surface);
  color: var(--tn-dim);
  font-family: var(--tn-mono);
  font-size: 11px;
  letter-spacing: .04em;
  line-height: 1;
  cursor: pointer;
  /* Held back until the panel is hovered so it does not sit on top of the
     first line of every sample permanently — but never fully transparent,
     because a control that is invisible until touched cannot be discovered
     on a device with no pointer. See the coarse-pointer block below. */
  opacity: .45;
  transition: opacity .2s ease, color .2s ease,
    border-color .2s ease, background-color .2s ease;
}

.tn-article .wp-block-post-content .tn-copy-wrap:hover .tn-copy,
.tn-article .wp-block-post-content .tn-copy:focus-visible {
  opacity: 1;
}

.tn-article .wp-block-post-content .tn-copy:hover {
  color: var(--tn-cyan);
  border-color: var(--tn-cyan-dim);
  background: var(--tn-page);
}

.tn-article .wp-block-post-content .tn-copy:active {
  transform: translateY(1px);
}

.tn-article .wp-block-post-content .tn-copy.is-done {
  color: var(--tn-turquoise);
  border-color: var(--tn-turquoise);
  opacity: 1;
}

.tn-article .wp-block-post-content .tn-copy.is-fail {
  color: #E05F5F;
  border-color: #E05F5F;
  opacity: 1;
}

.tn-article .wp-block-post-content .tn-copy-glyph {
  display: inline-flex;
}

.tn-article .wp-block-post-content .tn-copy-glyph svg {
  width: 13px;
  height: 13px;
  /* the post-content image rule puts a border and radius on every img and
     would otherwise catch this one too */
  border: 0;
}

/* There is no hover on a touch screen, so the control shows at full
   strength; the label is dropped instead to buy back the width, since a
   phone-width panel has little enough of it. */
@media (hover: none) {
  .tn-article .wp-block-post-content .tn-copy {
    opacity: .85;
    padding: 6px;
  }

  .tn-article .wp-block-post-content .tn-copy-label {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tn-article .wp-block-post-content .tn-copy {
    transition: none;
  }

  .tn-article .wp-block-post-content .tn-copy:active {
    transform: none;
  }
}

/* Core sets pre-wrap on the inner <code> as well as on the <pre>, and the
   <code> is what actually holds the text — measured pre / pre-wrap on the
   two elements with only the rule above in place, which left the wrapping
   exactly as it was. */
.tn-article .wp-block-post-content pre > code {
  white-space: inherit;
}

/* C samples, tokenised by theme.js. Only the classes it emits are styled —
   anything it could not classify keeps --tn-text from the panel above, so a
   sample degrades to plain monospace rather than to a wrong colour. */
.tn-article .wp-block-post-content .tn-c-kw   { color: var(--tn-code-kw); }
.tn-article .wp-block-post-content .tn-c-type { color: var(--tn-code-type); }
.tn-article .wp-block-post-content .tn-c-fn   { color: var(--tn-code-fn); }
.tn-article .wp-block-post-content .tn-c-num  { color: var(--tn-code-num); }
.tn-article .wp-block-post-content .tn-c-str  { color: var(--tn-code-str); }
.tn-article .wp-block-post-content .tn-c-punc { color: var(--tn-code-punc); }

.tn-article .wp-block-post-content .tn-c-pre {
  color: var(--tn-code-pre);
  font-weight: 600;
}

/* Comments carry the intent of the sample, so they are dimmed rather than
   greyed out entirely — --tn-code-com is 5.33:1 on the panel, the same tone
   the theme uses for metadata elsewhere. Italic does the separating work
   that a further drop in contrast would otherwise have to do. */
.tn-article .wp-block-post-content .tn-c-com {
  color: var(--tn-code-com);
  font-style: italic;
}

.tn-article .wp-block-post-content blockquote {
  font-family: var(--tn-serif);
  font-style: italic;
  font-size: 1.2rem;
  border-left: 2px solid var(--tn-cyan);
  padding: 4px 0 4px 20px;
  color: var(--tn-muted);
  margin: 26px 0;
  max-width: 58ch;
}

.tn-article .wp-block-post-content img {
  max-width: 100%;
  height: auto;
  border: 1px solid var(--tn-line);
  border-radius: 3px;
}

/* Click-to-enlarge (core's lightbox, switched on in theme.json). Core
   renders the overlay as a sibling inside the post content, so the rule
   above would otherwise put a 1px border and rounded corners on the
   full-screen copy too. The scrim and close button take their colours from
   the global background/text automatically, so they need no help here. */
.tn-article .wp-block-post-content .wp-lightbox-overlay img {
  border: 0;
  border-radius: 0;
}

/* Only the trigger button opens the lightbox (the click handler is taken
   off the image in functions.php), so the image must not advertise itself
   as clickable — core sets cursor: zoom-in on it. */
.wp-lightbox-container img {
  cursor: default;
}

/* Core puts cursor: zoom-in on the enlarge trigger itself, which reads as a
   magnifier hovering a control that is, in the end, a button. Pointer on
   both core's trigger and the drawing's keeps them identical to each other
   as well as conventional. */
.wp-lightbox-container button {
  cursor: pointer;
}

/* Core's scrim is a flat, opaque fill (no way to see the page underneath),
   which reads as a hard black wall behind the enlarged image. Swapping it
   for the same frosted-glass recipe as the sticky header — a translucent
   deep tint plus saturate+blur — keeps the surrounding page legible as
   context while still giving the photo full contrast to sit on top of. */
.wp-lightbox-overlay .scrim {
  /* Core's Interactivity API sets `background-color: var(--wp--preset--
     color--ground)` directly as an inline style on this element at open
     time — an inline style always wins over any stylesheet rule
     regardless of selector specificity, which is why the translucent tint
     below only ever reached this element's backdrop-filter and not its
     background-color until !important was added here. */
  background-color: rgba(19, 21, 23, .62) !important;
  opacity: 1;
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  backdrop-filter: saturate(180%) blur(20px);
}

/* ── photo hover: the contents-row glass, over the picture ── */

/* Same four layers, same radii, same timing as the contents rows, with the
   two changes a photograph forces. It rides on ::after above the picture
   instead of ::before behind it, because there is nothing to see through
   an opaque image. And it screens rather than painting flat: the row's
   --tn-surface reads as a lift against a dark background, but laid over a
   photo the same grey is just a veil. Screening keeps that exact ceiling
   while reading as light catching glass.

   isolation pins the blend to the figure, so the overlay mixes with the
   photo and not with whatever the page has behind it. */
.tn-article .wp-block-post-content .wp-block-image {
  position: relative;
  isolation: isolate;
  --mx: 50%;
  --my: 50%;
}

.tn-article .wp-block-post-content .wp-block-image::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  --glass: 0;
  background-image:
    radial-gradient(340px circle at var(--mx) var(--my),
      rgba(38, 44, 48, calc(var(--glass) * .80)),
      rgba(38, 44, 48, 0) 70%),
    linear-gradient(
      rgba(38, 44, 48, calc(var(--glass) * .18)),
      rgba(38, 44, 48, calc(var(--glass) * .18))),
    radial-gradient(240px circle at var(--mx) var(--my),
      rgba(38, 44, 48, var(--glass)),
      rgba(38, 44, 48, calc(var(--glass) * .55)) 40%,
      rgba(38, 44, 48, calc(var(--glass) * .18)) 100%),
    linear-gradient(transparent, transparent);
  background-clip: padding-box, padding-box, border-box, border-box;
  background-origin: border-box;
  background-repeat: no-repeat;
  border: 1px solid transparent;
  border-radius: 3px;
  mix-blend-mode: screen;
  /* This is the contents-row gradient stack verbatim, but screen-blended
     over an image instead of normal-blended behind a row's text, and screen
     lifts far more per unit alpha — measured 33.2/255 against the row's 8.1,
     four times as loud. Damping the composite is the fix rather than
     rewriting the five alphas: the innermost radial reaches full opacity by
     design, and under normal blending that grey would blot out the middle
     of the diagram, so the screen blend has to stay. .25 measures 8.5. */
  opacity: .25;
  transition: --glass .3s ease;
}

.tn-article .wp-block-post-content .wp-block-image:hover::after { --glass: 1; }

/* Raw inline <svg> dropped into an HTML block — a draw.io export, in
   practice. Three of the exporter's own attributes fight the page, and two
   need !important because they arrive as inline styles, which outrank any
   stylesheet rule:

   - `color-scheme: light dark` makes Chromium invert the drawing whenever
     the visitor's browser resolves to dark, so a diagram already drawn in
     this theme's palette rendered near-white (measured mean luma 198/255
     against 43 when correct). `normal` fixes it; `color-scheme: dark` does
     *not*, because it is declaring support for dark at all that triggers
     the treatment — so the intuitive fix is the one that fails.
   - `max-height`, the export's own pixel height, would otherwise letterbox
     the drawing inside its own box once the width is free to move.
   - <svg> is display: inline, so it sat on a text baseline at the content
     box's left edge, where margin-inline: auto has nothing to centre.

   Everything else — the palette, the viewBox — is left to the drawing.

   Sized to its own export width rather than stretched to the column: the
   stroke weights, corner radii and font sizes inside a drawing are chosen
   at one scale, and blowing a 595px export up to 806 thickens every hairline
   and swells the labels out of proportion with the body text beside them. It
   still shrinks below its natural width when the column is narrower, which
   costs nothing — scaling a vector down keeps it sharp.

   Both selectors are needed: technical_notes_figure_caption() wraps the
   drawing in a .tn-figure to caption it, but that filter does not run in
   the block editor, where add_editor_style() loads this sheet against the
   unwrapped markup. Matched as children rather than descendants so the
   trigger button's own <svg> icon is never caught by these rules. */
.tn-article .wp-block-post-content > svg,
.tn-article .wp-block-post-content > .tn-figure > svg {
  color-scheme: normal !important;
  max-height: none !important;
  display: block;
  width: auto;
  max-width: 100%;
  height: auto;
  margin-inline: auto;
}

/* ── tables ──────────────────────────────────────────── */

/* border+radius live on the wrapper, not the table: border-collapse fuses
   cell borders into one hairline (no doubled edges between rows), but it
   also flattens border-radius on the table element itself in most
   engines, so the rounded corners have to come from a wrapper that clips
   the table's square corners with overflow: hidden instead. */
.tn-article .wp-block-post-content .wp-block-table {
  margin: 26px 0;
  overflow-x: auto;
  overflow-y: hidden;
  border: 1px solid var(--tn-line);
  border-radius: 8px;
  /* the glass surface under the table is what the header/scrollbar-track
     recipe elsewhere calls --tn-surface; a plain flat fill here (no blur)
     is enough to lift the table off the page sheet the way a card does */
  background: var(--tn-surface);
}

.tn-article .wp-block-post-content table {
  width: 100%;
  border-collapse: collapse;
  font-size: .92rem;
}

/* Core's "fixed width table cells" toggle divides the width equally
   regardless of what is in a column, which starves a prose column next to
   numeric ones: the five-column metrics table gave "Share of dangerous
   faults covered by diagnostics" the same 115px as "≥ 90 %", and core's
   overflow-wrap: break-word then split the last word across two lines as
   "diagnostic / s".

   Auto layout measured better at every width and never worse: zero
   horizontal scroll at 1440, 900 and 390px, and the metrics table fell from
   527px tall to 301px because the columns finally match their contents. */
.tn-article .wp-block-post-content table.has-fixed-layout {
  table-layout: auto;
}

/* Auto layout fits the columns to the sheet, but it cannot invent width: a
   table with more or longer columns than the sheet can hold gets squeezed
   below the width of its own words, and core's overflow-wrap: break-word
   then splits them mid-character. The CANopen entry showed it plainly —
   "Segmented" as "Seg/men/ted", "Block" as "Bloc/k", and a yes/no column
   narrow enough to break "yes" into "y/es".

   Refusing the mid-word break is only half of it: with the words intact the
   cells need a floor, or the column just clips instead. min-content is that
   floor — the longest word in each column — and once the table asks for more
   than the sheet, .wp-block-table's own overflow-x finally has something to
   scroll, which is the behaviour that block was built for and never reached.

   A break at a hyphen ("Pre-/operational") is left alone: that one is
   correct typography, not a squeeze. No-op for tables that already fit. */
.tn-article .wp-block-post-content table th,
.tn-article .wp-block-post-content table td {
  overflow-wrap: normal;
  word-break: normal;
}

.tn-article .wp-block-post-content table {
  min-width: min-content;
}

/* Core puts its own 3px currentColor border-bottom directly on <thead>
   (and a matching border-top on <tfoot>) — separate from, and on top of,
   the 1px themed border already set on the th cells below. At
   currentColor that rendered as a thick near-white line under the header
   regardless of the cells' own border. */
.tn-article .wp-block-post-content thead,
.tn-article .wp-block-post-content tfoot {
  border: 0;
}

.tn-article .wp-block-post-content thead th {
  font-family: var(--tn-sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .12em;
  text-transform: uppercase;
  text-align: left;
  color: var(--tn-dim);
  background: var(--tn-deep);
  /* core's own table styles set a full 1px border in currentColor on every
     cell; at --tn-dim/--tn-text that reads as a near-white line on three
     sides once only border-bottom below is overridden, so the border is
     zeroed out first and rebuilt with just the one themed edge. */
  border: 0;
  border-bottom: 1px solid var(--tn-line);
  padding: 12px 16px;
  /* NOT nowrap. This rule never fired until the header rows were promoted,
     because no table on the site had ever been authored with a <thead> — and
     the first time it did fire, "Covered by a safety mechanism" and "Effect
     on the safety goal" ran straight over each other: core's tables carry
     has-fixed-layout, so a nowrap header wider than its equal-width column
     overflows the cell instead of widening it. */
  white-space: normal;
  /* a two-line header sits on the rule with its last line, next to the
     one-line headers beside it, instead of floating above them */
  vertical-align: bottom;
}

/* No-JS fallback for a table authored without core's "Header section"
   toggle: the header row is then ordinary <td> in <tbody> and nothing above
   reaches it. theme.js promotes that row to a real <thead>/<th scope="col">
   so it is announced as a header too — and the :has() guard here switches
   this paint off the instant that happens, so the two can never both apply
   and the row never gets styled twice. */
.tn-article .wp-block-post-content table:not(:has(thead)) tbody tr:first-child td {
  font-family: var(--tn-sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .12em;
  text-transform: uppercase;
  text-align: left;
  color: var(--tn-dim);
  background: var(--tn-deep);
  border: 0;
  border-bottom: 1px solid var(--tn-line);
  padding: 12px 16px;
  white-space: normal;
  vertical-align: bottom;
}

/* the row is furniture, not data, so it must not answer the pointer the way
   the body rows below it do */
.tn-article .wp-block-post-content table:not(:has(thead)) tbody tr:first-child {
  background-image: none;
}

/* Core allows an emergency break anywhere inside a cell, which under auto
   layout splits a quantity that is joined by no-break spaces: "≥ 90 %" came
   apart as "≥ 90 / %" and "< 10⁻⁷ h⁻¹" broke between the 10⁻ and its own
   exponent. Turning that off makes each quantity a single unbreakable run,
   and auto layout then sizes the column to hold it — the two rules only
   work together. */
.tn-article .wp-block-post-content th,
.tn-article .wp-block-post-content td {
  overflow-wrap: normal;
  word-break: normal;
}

.tn-article .wp-block-post-content td,
.tn-article .wp-block-post-content tbody th {
  padding: 10px 16px;
  border: 0;
  border-bottom: 1px solid var(--tn-line-soft);
  color: var(--tn-text);
  vertical-align: top;
}

.tn-article .wp-block-post-content tbody tr:last-child td,
.tn-article .wp-block-post-content tbody tr:last-child th {
  border-bottom: 0;
}

/* alternating rows read better than a hairline grid alone once a table
   runs past four or five rows; the tint is deliberately faint (2% white)
   so it stays a reading aid rather than competing with the hover glass
   below */
.tn-article .wp-block-post-content tbody tr:nth-child(even) {
  /* background-color, not the `background` shorthand: the shorthand resets
     every layer it doesn't mention, including background-image — which is
     exactly where the hover glass below lives, so writing it as a shorthand
     here silently erased the hover on every even row. */
  background-color: rgba(255, 255, 255, .025);
}

/* Same cursor-following glass recipe as the contents rows and photo
   hover (--glass is the shared @property declared with the buttons,
   above) — the "hover animation already used on the wp-block-post
   cards". --mx/--my are fed by theme.js, which now also tracks pointer
   position over table rows. Painted as the row's own background rather
   than a pseudo-element: a <tr> cannot reliably host ::before/::after
   layered above its cells the way a block-level row can. */
.tn-article .wp-block-post-content tbody tr {
  --glass: 0;
  --mx: 50%;
  --my: 50%;
  /* Calibrated to the contents-row hover rather than picked by eye: that
     row lifts its sample band by a mean 8.1/255 per channel at the peak,
     and .12/.05 here measured 26.1 — over three times as loud, which is
     what read as too much. .038/.016 measures 8.1. The greys the rows use
     are unavailable to a table: .wp-block-table already sits on
     --tn-surface, which *is* rgba(38,44,48), so a grey wash would be
     invisible — hence whites and cyan, at the alpha that matches. */
  background-image:
    radial-gradient(280px circle at var(--mx) var(--my),
      rgba(233, 250, 253, calc(var(--glass) * .038)),
      rgba(233, 250, 253, 0) 70%),
    linear-gradient(
      rgba(95, 212, 224, calc(var(--glass) * .016)),
      rgba(95, 212, 224, calc(var(--glass) * .016)));
  transition: --glass .3s ease;
}

.tn-article .wp-block-post-content tbody tr:hover {
  --glass: 1;
}

@media (prefers-reduced-motion: reduce) {
  .tn-article .wp-block-post-content tbody tr {
    transition: none;
  }
}

@media (max-width: 480px) {
  .tn-article .wp-block-post-content th,
  .tn-article .wp-block-post-content td {
    padding: 9px 12px;
  }
}

/* Exponents and indices — "10⁻⁷ h⁻¹" in the PMHF row of the metrics table.
   The UA default is font-size: smaller with the element still contributing
   its full leading, which pushes a table row taller than its neighbours;
   line-height: 0 with an explicit size takes that back so a row carrying an
   exponent lines up with one that does not. */
.tn-article .wp-block-post-content sup,
.tn-article .wp-block-post-content sub {
  font-size: .72em;
  line-height: 0;
  font-variant-numeric: tabular-nums;
}

/* ── formulas (core's Math block, plus inline <math>) ───── */

/* Block formulas: the same dark recessed panel the code blocks use
   (--tn-deep on --tn-line), so a formula reads as "worked example set
   apart from prose" the same way a code sample does, rather than
   inventing a second visual language for a second kind of literal
   content. */
.tn-article .wp-block-post-content .wp-block-math {
  background: var(--tn-deep);
  border: 1px solid var(--tn-line);
  border-radius: 6px;
  padding: 20px 24px;
  margin: 26px 0;
  overflow-x: auto;
  overflow-y: hidden;
  text-align: center;
}

.tn-article .wp-block-post-content .wp-block-math math {
  font-size: 1.15rem;
  color: var(--tn-heading);
}

/* A formula cannot reflow the way a sentence can, so on a phone the panel
   gives back its side padding and the type steps down before the block is
   allowed to start scrolling sideways — measured on the widest formula in
   the glossary, which overflowed its 294px column by 17px at 1.15rem and
   clears it with 26px to spare here. */
@media (max-width: 480px) {
  .tn-article .wp-block-post-content .wp-block-math {
    padding: 16px 14px;
  }

  .tn-article .wp-block-post-content .wp-block-math math {
    font-size: 1rem;
  }
}

/* Inline formulas sit directly in running prose (core renders a bare
   <math> with no wrapper when it's typed mid-sentence rather than as its
   own block) — a quiet cyan-tinted chip, the same idea as inline `code`
   just below, keeps it legible as "notation" without the weight of the
   block treatment. */
.tn-article .wp-block-post-content p math,
.tn-article .wp-block-post-content li math {
  font-size: 1.02em;
  color: var(--tn-cyan);
  background: rgba(95, 212, 224, .08);
  border-radius: 4px;
  padding: 0 .3em;
}

/* ── citations and references ────────────────────────── */

/* The "[4]" markers an author types in prose, linked by theme.js to the
   matching entry below. Set in the mono face at --tn-dim so a run of them
   reads as apparatus rather than as running text, and raised with
   vertical-align rather than wrapped in <sup>: <sup> shrinks the box but
   still contributes its full line-height, which opened a visible gap in
   every paragraph that carried a citation. */
.tn-article .wp-block-post-content .tn-cite {
  font-family: var(--tn-mono);
  font-size: .74em;
  font-variant-numeric: tabular-nums;
  vertical-align: .34em;
  line-height: 1;
  color: var(--tn-dim);
  /* Excluding .tn-cite from the prose-link rule above also took away its
     text-decoration: none, and the browser default underline came back —
     at .74em it sat right under the brackets and read as a smudge. The
     brackets are the affordance at rest; the underline is kept for hover
     and focus so the link is not signalled by colour alone. */
  text-decoration: none;
  transition: color .18s ease;
}

/* "[1][4]" is common and the two must not fuse into one token */
.tn-article .wp-block-post-content .tn-cite + .tn-cite {
  margin-left: .12em;
}

.tn-article .wp-block-post-content .tn-cite:hover,
.tn-article .wp-block-post-content .tn-cite:focus-visible {
  color: var(--tn-cyan);
  text-decoration: underline;
  text-underline-offset: .22em;
}

/* The reference list itself. Hanging indent so the numbers form a column
   the eye can run down, which is the whole point of arriving here from a
   citation — the entry has to be findable, not just present. */
.tn-article .wp-block-post-content .tn-ref {
  position: relative;
  /* the vertical padding is carried at rest, not added by the highlight —
     the number is absolutely positioned, and its static top edge sits below
     whatever padding-top the paragraph has, so growing that on highlight
     would drop the number out of line with its own entry */
  /* The left padding carries the hanging indent plus the gutter the
     highlight bar lives in — without that gutter the bar butts straight up
     against the "[1]", and the two read as one glyph. */
  padding: 5px 0 5px calc(3.4em + 14px);
  margin: 0 0 8px;
  font-size: .95rem;
  color: var(--tn-muted);
  scroll-margin-top: var(--tn-toc-top);
}

.tn-article .wp-block-post-content .tn-ref-no {
  position: absolute;
  left: 14px;            /* clears the highlight bar — see .tn-ref above */
  font-family: var(--tn-mono);
  font-size: .82em;
  font-variant-numeric: tabular-nums;
  color: var(--tn-cyan-dim);
}

.tn-article .wp-block-post-content .tn-ref em {
  color: var(--tn-text);       /* the title is the part being cited */
}

/* Landing highlight: without it a jump into a run of near-identical lines
   leaves the reader to work out which one they were sent to. The tint is
   held rather than animated away, so it still answers the question after
   the scroll has settled — and theme.js drops it on the next click away.
   Driven by a class, not :target: Chromium keeps a :target match alive
   after the fragment is removed, so that highlight could never be put out
   again without a real navigation. */
.tn-article .wp-block-post-content .tn-ref.is-cited {
  color: var(--tn-text);
  background: rgba(95, 212, 224, .07);
  box-shadow: inset 2px 0 0 var(--tn-cyan);
  border-radius: 0 3px 3px 0;
}

.tn-article .wp-block-post-content .tn-ref.is-cited .tn-ref-no {
  color: var(--tn-cyan);
}

/* previous / next */

/* The glossary and DIY back-links share these rules rather than copying
   them, so the footers of the single views stay identical by construction. */
.tn-post-nav,
.tn-glossary-back,
.tn-diy-back {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  border-top: 1px solid var(--tn-line);
  margin-top: 44px;
  padding-top: 22px;
  font-family: var(--tn-serif);
  font-size: 1.1rem;
}

.tn-post-nav a,
.tn-glossary-back a,
.tn-diy-back a {
  color: var(--tn-muted);
  text-decoration: none;
}

.tn-post-nav a:hover,
.tn-glossary-back a:hover,
.tn-diy-back a:hover { color: var(--tn-cyan); }

/* The glossary link writes its arrow inside the anchor, so the arrow is part
   of the link: one colour, and it turns cyan with the rest of it. Core's
   post-navigation-link puts the arrow in a *sibling* span outside the
   anchor, so it took the body colour instead — brighter than the link it
   belongs to — and stayed put on hover, which is the whole of the
   difference between the two footers.

   Colouring the wrapper and letting the anchor inherit gives the arrow the
   link's colour by construction; :has() moves the pair together, keyed to
   the anchor rather than the wrapper so the empty half of the row (a post
   with no previous) is not a hover target. */
.tn-post-nav .wp-block-post-navigation-link {
  color: var(--tn-muted);
  transition: color .2s ease;
}

.tn-post-nav .wp-block-post-navigation-link a { color: inherit; }

.tn-post-nav .wp-block-post-navigation-link:has(a:hover),
.tn-post-nav .wp-block-post-navigation-link:has(a:focus-visible) { color: var(--tn-cyan); }

/* core emits the arrow tight against the title */
.tn-post-nav [class*="__arrow-previous"] { margin-right: .4em; }
.tn-post-nav [class*="__arrow-next"] { margin-left: .4em; }

/* An empty wrapper still takes a flex slot, so on the oldest and newest
   notes the surviving link was pushed to the wrong side of the footer. */
.tn-post-nav .wp-block-post-navigation-link:empty { display: none; }

@media (prefers-reduced-motion: reduce) {
  .tn-post-nav .wp-block-post-navigation-link { transition: none; }
}

/* ── archive / search headers ───────────────────────── */

.tn-archive-title,
.wp-block-query-title {
  font-family: var(--tn-serif);
  font-weight: 400;
  letter-spacing: .025em;
  word-spacing: .06em;
  color: var(--tn-heading);
  font-size: clamp(1.6rem, 3.2vw, 2.2rem);
  margin: 0 0 24px;
}

.wp-block-search__input {
  color: var(--tn-text);
  padding: 8px 12px;
  font-family: var(--tn-mono);
  font-size: 14px;
}

/* ── buttons (block buttons, comment submit, search) ── */

/* --glass fades the specular layers in and out; declaring it with
   @property is what makes it animatable (a plain custom property would
   snap from 0 to 1). Unsupported browsers just get an instant switch. */
@property --glass {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

.wp-element-button,
.wp-block-button__link,
.comment-form input[type="submit"] {
  --glass: 0;
  --mx: 50%;
  --my: 50%;
  position: relative;
  font-family: var(--tn-mono);
  font-size: 13px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--tn-text);
  /* Four stacked layers, painted top to bottom:
       1. specular bloom that follows the cursor   (padding box)
       2. the opaque body fill                     (padding box)
       3. lit edge, brightest nearest the cursor   (border box)
       4. the resting border colour                (border box)
     Layers 1-2 clip to the padding box, so only 3-4 show through the
     transparent 1px border — a gradient border that still respects
     border-radius. Backgrounds rather than pseudo-elements because the
     comment submit is an <input>, which cannot have ::before/::after. */
  background-image:
    radial-gradient(120px circle at var(--mx) var(--my),
      rgba(95, 212, 224, calc(var(--glass) * .20)),
      rgba(95, 212, 224, 0) 65%),
    linear-gradient(var(--tn-surface), var(--tn-surface)),
    radial-gradient(90px circle at var(--mx) var(--my),
      rgba(226, 250, 253, calc(var(--glass) * .85)),
      rgba(95, 212, 224, calc(var(--glass) * .40)) 45%,
      rgba(46, 110, 122, calc(var(--glass) * .18)) 100%),
    linear-gradient(var(--tn-line), var(--tn-line));
  background-clip: padding-box, padding-box, border-box, border-box;
  background-origin: border-box;
  background-repeat: no-repeat;
  border: 1px solid transparent;
  border-radius: 3px;
  padding: 9px 18px;
  cursor: pointer;
  transition:
    --glass .28s ease,
    color .2s ease,
    transform .12s ease;
}

.wp-element-button:hover,
.wp-block-button__link:hover,
.comment-form input[type="submit"]:hover {
  --glass: 1;
  color: var(--tn-heading);
}

.wp-element-button:active,
.wp-block-button__link:active,
.comment-form input[type="submit"]:active {
  transform: translateY(1px) scale(.99);
}

.wp-element-button:focus-visible,
.wp-block-button__link:focus-visible,
.comment-form input[type="submit"]:focus-visible {
  outline: 2px solid var(--tn-cyan);
  outline-offset: 3px;
}

/* keep the search button matched to its input's height */
.wp-block-search__button {
  padding: 8px 14px;
}

/* ── glass fields (search + text inputs) ────────────── */

/* Same four-layer construction as the buttons, but the states differ:
   the specular highlight appears on hover only. Once a field has focus
   the user is reading and typing, so it settles into a darker, static
   fill — a moving highlight under the caret is just distracting. */
.tn-search .wp-block-search__inside-wrapper,
.wp-block-search__input,
.comment-form textarea,
.comment-form input[type="text"],
.comment-form input[type="email"],
.comment-form input[type="url"] {
  --glass: 0;
  --mx: 50%;
  --my: 50%;
  --field-fill: var(--tn-surface);
  --field-edge: var(--tn-faint);
  /* core leaves the wrapper white underneath the layers */
  background-color: var(--field-fill);
  background-image:
    radial-gradient(150px circle at var(--mx) var(--my),
      rgba(95, 212, 224, calc(var(--glass) * .16)),
      rgba(95, 212, 224, 0) 65%),
    linear-gradient(var(--field-fill), var(--field-fill)),
    radial-gradient(100px circle at var(--mx) var(--my),
      rgba(226, 250, 253, calc(var(--glass) * .85)),
      rgba(95, 212, 224, calc(var(--glass) * .40)) 45%,
      rgba(46, 110, 122, calc(var(--glass) * .18)) 100%),
    linear-gradient(var(--field-edge), var(--field-edge));
  background-clip: padding-box, padding-box, border-box, border-box;
  background-origin: border-box;
  background-repeat: no-repeat;
  border: 1px solid transparent;
  border-radius: 3px;
  transition: --glass .28s ease, box-shadow .2s ease;
}

/* glass on hover, but never while the field is being edited */
.tn-search .wp-block-search__inside-wrapper:hover:not(:focus-within),
.wp-block-search__input:hover:not(:focus-within),
.comment-form textarea:hover:not(:focus-within),
.comment-form input[type="text"]:hover:not(:focus-within),
.comment-form input[type="email"]:hover:not(:focus-within),
.comment-form input[type="url"]:hover:not(:focus-within) {
  --glass: 1;
}

/* the settled editing state: darker, calm, cyan edge */
.tn-search .wp-block-search__inside-wrapper:focus-within,
.wp-block-search__input:focus-within,
.comment-form textarea:focus-within,
.comment-form input[type="text"]:focus-within,
.comment-form input[type="email"]:focus-within,
.comment-form input[type="url"]:focus-within {
  --glass: 0;
  --field-fill: var(--tn-deep);
  --field-edge: var(--tn-cyan-dim);
  outline: none;
  box-shadow: 0 0 0 3px rgba(95, 212, 224, .12);
}

/* In the header the glass and the focus ring belong to the wrapper; the
   input nested inside it must stay bare. Otherwise the input draws its
   own ring, whose right edge lands in the gap before the icon divider
   and reads as a stray vertical line. */
.tn-search .wp-block-search__input,
.tn-search .wp-block-search__input:hover:not(:focus-within),
.tn-search .wp-block-search__input:focus-within {
  --glass: 0;
  background: transparent;
  background-image: none;
  border: 0;
  box-shadow: none;
  outline: none;
}

/* ── comment form fields ────────────────────────────── */

.comment-form textarea,
.comment-form input[type="text"],
.comment-form input[type="email"],
.comment-form input[type="url"] {
  width: 100%;
  color: var(--tn-text);
  font-family: var(--tn-mono);
  font-size: 14px;
  line-height: 1.6;
  padding: 10px 12px;
}

.comment-form ::placeholder {
  color: var(--tn-muted);
}

.comment-form label {
  display: inline-block;
  font-family: var(--tn-sans);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--tn-muted);
  margin-bottom: 6px;
}

/* ── footer ─────────────────────────────────────────── */

.tn-footer {
  /* flow-root (not block) gives the footer its own block-formatting
     context, so a last child's bottom margin can't collapse through the
     footer's own bottom edge — it stayed flush without this when the
     trace bar was reordered to the end, since .tn-footer carries no
     border/padding below to stop the collapse on its own. */
  display: flow-root;
  background: var(--tn-deep);
  border-top: 1px solid var(--tn-line);
  margin-top: 56px;
  transition: filter .3s ease;
}

/* full-bleed differential trace across the top of the footer */
.tn-foot-trace {
  margin: 18px 0 0;
}

/* The trace can be reordered to the end of the footer (the "hold 5s"
   easter egg) via the template part. When it trails .tn-foot-inner instead
   of leading it, its own 0 bottom margin left it flush against the
   footer's edge, and .tn-foot-inner's 46px padding-bottom (sized for
   closing the footer, not for leading into the trace) stacked on top of
   the trace's own 18px top margin for a 64px gap above it. Mirroring the
   opening spacing — 26px into the trace, 18px out of it — keeps both
   footer layouts reading the same regardless of which element is last. */
.tn-foot-inner:has(+ .tn-foot-trace) {
  padding-bottom: 26px;
}

.tn-foot-inner + .tn-foot-trace {
  margin: 0 0 18px;
}


/* nowrap, not wrap: with flex-wrap:wrap, the browser decides whether a
   second item needs a new line using each item's *unshrunk* content size —
   shrinking is only applied within a line already decided, never consulted
   for the wrap decision itself. That silently broke the two-column layout
   at ~965px, nearly 350px above the width the columns actually need once
   they're allowed to reflow their own text. nowrap forces both columns
   to stay on one line and shrink/reflow instead, so the row only gives up
   on two columns at the deliberate breakpoint below, not a content-driven
   accident. */
.tn-foot-inner {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 32px;
  flex-wrap: nowrap;
  padding-top: 26px;
  padding-bottom: 46px;
}

/* two stacks: identity on the left, routes out on the right. margin: 0
   defeats core's flow-layout margin-block-start on the group children.
   min-width: 0 overrides the flex item default (min-width: auto), which
   would otherwise re-introduce the same early-wrap problem now that
   .tn-foot-inner is nowrap and actually relies on shrinking to fit. */
.tn-foot-col {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 0;
  min-width: 0;
}

.tn-foot-right {
  align-items: flex-end;
  text-align: right;
}

.tn-foot-note {
  font-family: var(--tn-serif);
  font-style: italic;
  font-size: 1.05rem;
  color: var(--tn-dim);
  margin: 0;
  max-width: 46ch;
  /* the 46ch measure left "them." alone on the second line; pretty pulls a
     word down to keep it company */
  text-wrap: pretty;
}

/* the colophon is a shortcode block ([tn_year]), so the paragraph is
   generated by wpautop rather than authored — style it through the group */
.tn-foot-meta,
.tn-foot-meta p {
  font-family: var(--tn-mono);
  font-size: 11.5px;
  letter-spacing: .02em;
  color: var(--tn-dim);
  margin: 0;
}

.tn-foot-links {
  font-family: var(--tn-sans);
  font-size: .85rem;
  margin: 0;
  color: var(--tn-dim);
}

.tn-foot-links a {
  color: var(--tn-muted);
  text-decoration: none;
  /* inline text links, so SC 2.5.8 does not apply — but the padding still
     buys a little room on touch without disturbing the baseline */
  padding: 2px 0;
}

.tn-foot-links a:hover { color: var(--tn-cyan); }

.tn-foot-social {
  margin: 0;
}

/* Obfuscated contact links (functions.php + theme.js module 14). Until one
   is reached for it carries a prompt rather than the address, and the dotted
   underline is what says the prompt is the control and not a truncation. It
   goes away with .is-revealed, at which point the link is an ordinary link
   and inherits whatever its surroundings style. */
.tn-obf {
  cursor: pointer;
  text-decoration: underline dotted currentColor;
  text-underline-offset: .18em;
}

.tn-obf.is-revealed {
  text-decoration: inherit;
}

/* Mid-decode. No font switch here: the run is set in whatever face the link
   already had, and the line is held still by the cells instead — theme.js
   sizes each one to the exact advance of the character it will become. An
   earlier version swapped in the monospace face to get that stability for
   free, and it cost a visible jolt on the last frame when the text changed
   font underneath the reader.

   Line breaking is handled by .is-atomic rather than here, because it has to
   outlast the decode — see theme.js. */
.tn-obf.is-decoding {
  text-decoration: none;
}

/* Set by theme.js on a link it is about to animate, and left in place
   afterwards: the cells and the finished text have to break the same way, and
   the only way two different structures break the same way is not at all. It
   is only applied once the string has been measured against the column, so it
   cannot push anything sideways. */
.tn-obf.is-atomic {
  white-space: nowrap;
}

/* Cells not yet resolved. inline-block so the width theme.js sets applies,
   and so the scaleX that fits the glyph into it has a box to work on;
   transform-origin at the left edge keeps the fitted glyph on the cell's own
   start rather than creeping toward its middle.

   --tn-cyan at 45% for the glow; the theme has no color-mix() anywhere else,
   so this stays literal to match. */
.tn-obf .tn-obf-x {
  display: inline-block;
  transform-origin: 0 50%;
  white-space: pre;
  color: var(--tn-cyan);
  text-shadow: 0 0 6px rgba(95, 212, 224, .45);
}

/* Resolved, but still a cell: it keeps its box so the characters after it do
   not move, and only stops looking encrypted. */
.tn-obf .tn-obf-x.is-set {
  color: inherit;
  text-shadow: none;
}

/* Reduced motion needs no override: theme.js returns before any of the above
   is applied, so the address simply appears as ordinary text. */

/* Below this, shrinking the two columns further (rather than stacking
   them) starts wrapping the note and links onto too many lines to read
   comfortably — measured at 4+ wrapped lines each once the row narrows
   past this point. This is also where .tn-foot-right's own alignment
   flip below was already written for a stacked layout; it just never
   used to be reachable because the row broke to one column ~350px
   earlier than this on its own. */
@media (max-width: 640px) {
  .tn-foot-inner {
    flex-direction: column;
  }

  .tn-foot-right {
    align-items: flex-start;
    text-align: left;
  }

  .tn-foot-social {
    margin: 0;
  }
}

/* ── standalone pages: full-sheet reading column ────── */

/* pages carry no meta rail, so every block fills the sheet — the same
   full width the article grid gives posts */
.tn-page-body {
  max-width: none;
  margin: 0;
}

.tn-page-body .wp-block-post-content > *,
.tn-page-body .wp-block-post-content > p,
.tn-page-body .wp-block-post-content > ul,
.tn-page-body .wp-block-post-content > ol,
.tn-page-body .wp-block-post-content blockquote {
  max-width: none;
}

/* ...but running prose still needs a measure. The sheet is 806px wide on a
   desktop viewport, and uncapped that ran ~90 characters per line — well
   past the 45-75 comfortable range, and the reason pages read heavier than
   posts (which the 160px meta rail happens to constrain to 577px / ~63).
   Capping only paragraphs and lists leaves the sheet, the CV's grids, chips
   and contact strip at full width; p.tn-cv-contact re-opens itself below.

   55ch looks low for a ~70-character target because ch is the advance of
   "0" — 11.45px at 18px system-ui — while real mixed-case prose averages
   ~9px per character. 55ch lands at 630px, measured at 70 characters. The
   65ch on posts above is inert for the same reason (it computes to 744px,
   wider than any column the grid produces); it is left alone because the
   rail already holds that text at a good measure. */
.tn-page-body .wp-block-post-content > p,
.tn-page-body .wp-block-post-content > ul,
.tn-page-body .wp-block-post-content > ol,
.tn-page-body .wp-block-post-content .tn-cv-desc {
  max-width: 55ch;
}

/* A card list is an <ol> as well, but not prose: the cards are laid out for
   the whole sheet, as on the front page and the Notes index, and held to the
   measure they broke every title and excerpt early on the DIY Projects page. */
.tn-page-body .wp-block-post-content > .tn-cards { max-width: none; }

/* .tn-entry is a body of reference prose that owns the whole sheet: the
   standalone prose pages, and — below 782px, where the article columns
   stack — glossary entries too. On a desktop glossary entry the wrapper now
   sits inside the article grid's content column instead, which is narrower
   than the measure below, so every cap here resolves to the column and the
   rules go quiet without needing a second set scoped to that case.

   With the whole sheet to fill there is no meta rail on the other side, so
   the entry takes one measure centred in it. Capping only the paragraphs
   left them 177px short of the headings, the eyebrow rule, the formula and
   the back link — one narrow element among full-width ones, which is what
   read as unbalanced.

   So the measure goes on every child rather than on the wrapper: that
   keeps the common centre axis, while letting the blocks that are *not*
   prose opt back out below. Capping the wrapper (which is what this used
   to do) also squeezed the diagram and the table into the 630px column
   inside an 806px sheet, wasting 88px a side and cramping both — line
   length is a property of running text, not of the container.

   Deliberately not applied to .tn-page-body at large: the CV mixes capped
   prose with full-width items, chips and the skills grid, and those need a
   common left edge rather than a centred column.

   The cap is an absolute length, not the 55ch used elsewhere, because ch
   is the advance of "0" in each element's *own* font: one 55ch
   declaration shared across these children resolved to 440px on the mono
   eyebrow and 1133px on the serif h1 — past the sheet, so no cap at all —
   against 630px on body-font prose. --tn-measure is that prose value,
   measured, and it is a variable because three rules below need the same
   column: the children, and the captions that align back to them. */
.tn-page-body.tn-entry {
  --tn-measure: 630px;
  max-width: none;
  margin-inline: 0;
}

/* box-sizing is set here because the theme has no global border-box reset:
   these children were previously sized by the 630px wrapper, so padding sat
   inside that width. Capping them individually under content-box measured
   the cap against the *content* box instead, which pushed the formula panel
   (24px padding + 1px border a side) to 680px — 25px past the column on
   each side. Paragraphs and headings have no inline padding, so this is
   inert for them and only the padded blocks are affected. */
.tn-page-body.tn-entry > *,
.tn-page-body.tn-entry .wp-block-post-content > * {
  box-sizing: border-box;
  max-width: var(--tn-measure);
  margin-inline: auto;
}

/* the content wrapper itself must stay open, or it would re-impose the
   measure on the figures below before they get a chance to break out */
.tn-page-body.tn-entry .wp-block-post-content {
  max-width: none;
}

/* Diagrams, tables and code read better wide: a byte-lane diagram or a
   two-column reference table gains information density from the extra
   176px, where prose would only lose it. These take the full sheet and
   stay centred on the same axis as the text. Below ~600px the sheet is
   already narrower than the measure, so this resolves to no change;
   .wp-block-table carries its own overflow-x, so a wide table scrolls
   rather than pushing the page sideways. */
/* .tn-figure is the caption wrapper technical_notes_figure_caption() adds;
   the bare block selectors alongside it still matter, because that filter
   does not run in the block editor and add_editor_style() loads this sheet
   there too. */
.tn-page-body.tn-entry .wp-block-post-content > .tn-figure,
.tn-page-body.tn-entry .wp-block-post-content > .wp-block-image,
.tn-page-body.tn-entry .wp-block-post-content > .wp-block-table,
.tn-page-body.tn-entry .wp-block-post-content > .wp-block-math,
.tn-page-body.tn-entry .wp-block-post-content > .wp-block-code,
.tn-page-body.tn-entry .wp-block-post-content > svg,
.tn-page-body.tn-entry .wp-block-post-content > pre,
/* theme.js wraps every code panel to hang the copy button off it, which
   makes the wrapper the direct child and would otherwise drop the panel
   back to the prose measure */
.tn-page-body.tn-entry .wp-block-post-content > .tn-copy-wrap {
  max-width: none;
}

/* The wrapper takes over the block's vertical rhythm — figure carries a UA
   `margin: 1em 40px`, so leaving it alone would both indent the diagram and
   double the gap above it against the block's own 26px. */
.tn-page-body.tn-entry .wp-block-post-content > .tn-figure {
  margin: 26px 0;
}

.tn-page-body.tn-entry .tn-figure > :first-child {
  margin: 0;
}

/* The image figure shrink-wraps its picture instead of filling the sheet.
   Two things ride on the figure's box rather than the picture's, and both
   were landing in the empty space beside a diagram narrower than its
   column: the hover glass, which is an ::after pinned to the figure's
   inset and so spanned 804px against the drawing's 599, and core's enlarge
   trigger, which its own setButtonStyles() positions from the figure edge
   — it computed right: 223px, overshooting the picture by 120px, because
   it counts the centring offset twice.

   Sizing the figure to the picture makes both correct by construction
   rather than by re-deriving either offset here. Restates the auto margins
   because the :first-child rule above, which zeroes them, matches this
   element too and has equal specificity. */
.tn-page-body.tn-entry .tn-figure > .wp-block-image {
  width: max-content;
  max-width: 100%;
  margin: 0;
}

/* Unlike a table, an image figure stays in the text column rather than
   breaking out to the sheet: a diagram reads as part of the argument it
   illustrates, so it shares the paragraphs' left edge instead of floating
   on its own axis, and the measure caps it. The auto inline margin centres
   the *column* in the sheet — which is what puts its left edge on the
   prose — while the figure's own contents stay left-aligned. Overrides the
   breakout rule above at equal specificity, so it has to follow it. */
.tn-page-body.tn-entry .wp-block-post-content > .tn-figure--image {
  max-width: var(--tn-measure);
  margin: 26px auto;
}


/* Glossary figures render at their own size, centred, rather than being
   stretched to the sheet: these are technical drawings whose stroke
   weights, corner radii and label sizes are chosen at one scale, and
   enlarging a 595px export to 806 thickens every hairline and swells the
   text out of proportion with the body copy beside it. max-width still
   shrinks them when the column is narrower, which costs nothing.

   Scoped to .tn-entry, so the CV portrait and in-post screenshots keep
   whatever size the editor gave them. */
.tn-page-body.tn-entry .wp-block-post-content .wp-block-image img {
  display: block;
  width: auto;
  max-width: 100%;
  height: auto;
  margin-inline: auto;
}

/* Captions take the prose column, not the figure's full width: a caption is
   running text, so it belongs on the same left edge and the same measure as
   the paragraphs that discuss it — centring the measure inside the wider
   figure lands it exactly there. Below ~600px the figure is already narrower
   than the measure, so the cap is inert and the caption keeps the column.
   Mono at the breadcrumb's size and dimness: present for reference, quiet
   enough that it never competes with the prose. */
.tn-page-body.tn-entry .tn-figcaption {
  max-width: var(--tn-measure);
  margin: 10px auto 0;
  font-family: var(--tn-mono);
  font-size: 12.5px;
  line-height: 1.55;
  color: var(--tn-dim);
}

/* Cyan is this theme's label colour — it is what .tn-eyebrow uses for the
   "GLOSSARY" kicker, so a figure number in it reads as the same class of
   thing. --tn-muted, tried first, sits one step from --tn-dim and made the
   label indistinguishable from the caption text beside it. */
.tn-page-body.tn-entry .tn-figcaption-label {
  color: var(--tn-cyan);
  text-transform: uppercase;
  letter-spacing: .06em;
}

/* ── CV page ────────────────────────────────────────── */

/* summary and portrait share a row. Wrapping the lead in this group takes
   it out of `.wp-block-post-content > p`, so it loses both the 55ch cap and
   the drop-cap rule (and the rule that was cancelling the drop cap) — all
   three are meant to stop applying here. The column arithmetic replaces the
   cap: 806px sheet - 160px portrait - 32px gap = 614px, measured at 68
   characters, which is inside the comfortable range on its own. */
.tn-cv-intro {
  display: flex;
  align-items: stretch;
  gap: 32px;
  margin: 0 0 20px;
}

/* core's flow layout would otherwise push the portrait down 24px */
.tn-cv-intro > * { margin-block-start: 0; }

.tn-cv-intro .tn-cv-lead {
  flex: 1 1 auto;
  max-width: none;
  margin: 0;
}

/* The figure is itself a flex container so the image can be stretched to
   the row height. A percentage height will not do it: the row height comes
   from the paragraph, so the figure's height is not definite at the time
   the image resolves, and the 1:1 aspect-ratio implied by the img's
   width/height attributes then wins and snaps it back to a square. */
.tn-cv-portrait {
  flex: 0 0 auto;
  display: flex;
  /* Explicit, because once the lightbox is on, core adds
     .wp-lightbox-container to this same figure and styles it
     flex-direction: column. Under column the cross axis is horizontal, so
     the image would stretch sideways and snap back to a square while the
     figure stayed tall — which also pushed core's enlarge button down by
     the leftover height, since it positions from figureHeight - imgHeight. */
  flex-direction: row;
  align-self: stretch;
  width: 180px;
  /* The cap lives on the figure, not the image. If the image alone were
     capped, the figure would keep stretching to the row height and the
     leftover space would push core's enlarge button down with it. */
  max-height: 260px;
  margin: 0;
}

.tn-cv-portrait img {
  display: block;
  width: 100%;
  height: auto;
  align-self: stretch;
  min-height: 0;
  /* clear the intrinsic square so the box may be taller than it is wide.
     How tall is bounded by the figure's max-height above: between roughly
     660 and 900px the summary grows past 350px, and a stretched square
     source would lose half its width to the crop and cut into the face. */
  aspect-ratio: auto;
  object-fit: cover;
  border: 1px solid var(--tn-line);
  border-radius: 3px;
}

.tn-cv-lead {
  font-size: 1.1rem;
  line-height: 1.68;
  color: var(--tn-text);
  border-left: 2px solid var(--tn-cyan-dim);
  padding-left: 22px;
  margin: 0 0 20px;
}

/* the lead replaces the article drop cap */
.tn-article .wp-block-post-content > p.tn-cv-lead::first-letter {
  float: none;
  font-family: inherit;
  font-size: inherit;
  line-height: inherit;
  padding: 0;
  color: inherit;
}

/* the contact strip spans the sheet, not the prose measure */
.tn-page-body .wp-block-post-content > p.tn-cv-contact { max-width: none; }

.tn-cv-contact {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 0;
  margin: 0 0 34px;
  padding-bottom: 22px;
  border-bottom: 1px solid var(--tn-line-soft);
  font-family: var(--tn-mono);
  font-size: 12.5px;
  letter-spacing: .015em;
  color: var(--tn-muted);
}

.tn-cv-contact > * {
  padding: 0 16px;
  border-left: 1px solid var(--tn-line);
  line-height: 1.4;
}

.tn-cv-contact > *:first-child {
  padding-left: 0;
  border-left: 0;
}

.tn-article .wp-block-post-content .tn-cv-contact a {
  color: var(--tn-muted);
  text-decoration: none;
  transition: color .2s ease;
}

.tn-article .wp-block-post-content .tn-cv-contact a:hover {
  color: var(--tn-cyan);
}

/* the email encoder plugin swaps the address for a generated PNG;
   size it to the mono text rather than letting its raster height rule */
.tn-cv-contact a.mail-link {
  display: flex;
  align-items: center;
}

.tn-cv-contact a.mail-link img {
  display: block;
  height: 14px;
  width: auto;
  opacity: .85;
  transition: opacity .2s ease;
}

.tn-cv-contact a.mail-link:hover img {
  opacity: 1;
}

/* ── CV section headings with masked SVG glyphs ─────── */

/* the icon is a mask, so it takes its color from `background` and
   stays a single request-free inline asset */
.tn-page-body .wp-block-post-content h2.tn-cv-h {
  display: flex;
  align-items: center;
  gap: 13px;
  font-size: 1.75rem;
  margin: 52px 0 20px;
}

.tn-cv-h::before {
  content: "";
  flex: 0 0 auto;
  width: 21px;
  height: 21px;
  background: var(--tn-cyan);
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* the rule trailing off to the sheet edge */
.tn-cv-h::after {
  content: "";
  flex: 1 1 auto;
  height: 1px;
  background: linear-gradient(90deg, var(--tn-line) 0%, transparent 100%);
}

.tn-cv-h-work::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='7' width='19' height='13.5' rx='2'/%3E%3Cpath d='M8 7V5.5A2 2 0 0 1 10 3.5h4a2 2 0 0 1 2 2V7'/%3E%3Cpath d='M2.5 12.5h19'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='7' width='19' height='13.5' rx='2'/%3E%3Cpath d='M8 7V5.5A2 2 0 0 1 10 3.5h4a2 2 0 0 1 2 2V7'/%3E%3Cpath d='M2.5 12.5h19'/%3E%3C/svg%3E");
}

.tn-cv-h-skills::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='6.5' y='6.5' width='11' height='11' rx='1.5'/%3E%3Cpath d='M9.5 2.5v4M14.5 2.5v4M9.5 17.5v4M14.5 17.5v4M2.5 9.5h4M2.5 14.5h4M17.5 9.5h4M17.5 14.5h4'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='6.5' y='6.5' width='11' height='11' rx='1.5'/%3E%3Cpath d='M9.5 2.5v4M14.5 2.5v4M9.5 17.5v4M14.5 17.5v4M2.5 9.5h4M2.5 14.5h4M17.5 9.5h4M17.5 14.5h4'/%3E%3C/svg%3E");
}

.tn-cv-h-projects::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2.5 2.5 7.5 12 12.5l9.5-5z'/%3E%3Cpath d='M2.5 16.5 12 21.5l9.5-5'/%3E%3Cpath d='M2.5 12 12 17l9.5-5'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2.5 2.5 7.5 12 12.5l9.5-5z'/%3E%3Cpath d='M2.5 16.5 12 21.5l9.5-5'/%3E%3Cpath d='M2.5 12 12 17l9.5-5'/%3E%3C/svg%3E");
}

.tn-cv-h-edu::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21.5 9.5 12 4.5 2.5 9.5 12 14.5z'/%3E%3Cpath d='M6.5 11.8v4.7c0 1.7 2.5 3 5.5 3s5.5-1.3 5.5-3v-4.7'/%3E%3Cpath d='M21.5 9.5v5'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21.5 9.5 12 4.5 2.5 9.5 12 14.5z'/%3E%3Cpath d='M6.5 11.8v4.7c0 1.7 2.5 3 5.5 3s5.5-1.3 5.5-3v-4.7'/%3E%3Cpath d='M21.5 9.5v5'/%3E%3C/svg%3E");
}

.tn-cv-h-research::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 3h6'/%3E%3Cpath d='M10 3v6.2L4.7 18.4A2 2 0 0 0 6.4 21.5h11.2a2 2 0 0 0 1.7-3.1L14 9.2V3'/%3E%3Cpath d='M7.5 15h9'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 3h6'/%3E%3Cpath d='M10 3v6.2L4.7 18.4A2 2 0 0 0 6.4 21.5h11.2a2 2 0 0 0 1.7-3.1L14 9.2V3'/%3E%3Cpath d='M7.5 15h9'/%3E%3C/svg%3E");
}

.tn-cv-h-seminars::before {
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='4' width='19' height='16' rx='2'/%3E%3Cpath d='M6.5 9.5 9.5 12.5 6.5 15.5'/%3E%3Cpath d='M12.5 15.5h5'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='4' width='19' height='16' rx='2'/%3E%3Cpath d='M6.5 9.5 9.5 12.5 6.5 15.5'/%3E%3Cpath d='M12.5 15.5h5'/%3E%3C/svg%3E");
}

/* ── CV entries: a traced timeline down the left ────── */

.tn-cv-item {
  position: relative;
  padding: 0 0 0 26px;
  margin: 0 0 26px;
  border-left: 1px solid var(--tn-line);
  transition: border-left-color .25s ease;
}

.tn-cv-item:last-child { margin-bottom: 0; }

.tn-cv-item:hover { border-left-color: var(--tn-cyan-dim); }

.tn-page-body .wp-block-post-content h3.tn-cv-role {
  font-size: 1.35rem;
  line-height: 1.35;
  margin: 0 0 7px;
  color: var(--tn-heading);
}

.tn-cv-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 0;
  font-family: var(--tn-mono);
  font-size: 12.5px;
  letter-spacing: .02em;
  color: var(--tn-dim);
  margin: 0 0 10px;
}

.tn-cv-meta > * {
  padding: 0 12px;
  border-left: 1px solid var(--tn-line);
  line-height: 1.4;
}

.tn-cv-meta > *:first-child {
  padding-left: 0;
  border-left: 0;
}

.tn-cv-org { color: var(--tn-muted); }
.tn-cv-grade { color: var(--tn-turquoise); }

.tn-cv-desc {
  margin: 0;
  color: var(--tn-text);
}

/* ── tech chips ─────────────────────────────────────── */

.tn-cv-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 12px 0 0;
}

/* a chip is either a bare <span> or an <a> — so chips can be linked
   from the editor without any further styling work */
.tn-cv-chips > a {
  font-family: var(--tn-mono);
  font-size: 12.5px;
  letter-spacing: .03em;
  color: var(--tn-muted);
  background: var(--tn-surface);
  border: 1px solid var(--tn-line);
  border-radius: 3px;
  padding: 3px 9px;
  transition: color .2s ease, border-color .2s ease, background-color .2s ease;
}

.tn-cv-chips > a:hover {
  color: var(--tn-cyan);
  border-color: var(--tn-cyan-dim);
  background: var(--tn-deep);
}

.tn-cv-chips > span {
  --glass: 0;
  --mx: 50%;
  --my: 50%;
  font-family: var(--tn-mono);
  font-size: 12.5px;
  letter-spacing: .03em;
  color: var(--tn-muted);
  background-color: var(--tn-surface);
  background-image:
    radial-gradient(100px circle at var(--mx) var(--my),
      rgba(95, 212, 224, calc(var(--glass) * .20)),
      rgba(95, 212, 224, 0) 65%),
    linear-gradient(var(--tn-surface), var(--tn-surface)),
    radial-gradient(80px circle at var(--mx) var(--my),
      rgba(226, 250, 253, calc(var(--glass) * .85)),
      rgba(95, 212, 224, calc(var(--glass) * .40)) 45%,
      rgba(46, 110, 122, calc(var(--glass) * .18)) 100%),
    linear-gradient(var(--tn-line), var(--tn-line));
  background-clip: padding-box, padding-box, border-box, border-box;
  background-origin: border-box;
  background-repeat: no-repeat;
  border: 1px solid transparent;
  border-radius: 3px;
  padding: 3px 9px;
  cursor: default;
  transition:
    --glass .28s ease,
    color .2s ease;
}

.tn-cv-chips > span:hover {
  --glass: 1;
  color: var(--tn-cyan);
}

/* links inside a chip take the chip's own look, not the article's
   cyan underline — covers both <a class="chip"> and <span><a></span> */
.tn-page-body .wp-block-post-content .tn-cv-chips a {
  color: inherit;
  text-decoration: none;
}

/* ── skills matrix ──────────────────────────────────── */

.tn-cv-skills {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 26px 34px;
  margin: 0;
}

.tn-cv-skill { margin: 0; }

.tn-cv-skill-label {
  font-family: var(--tn-sans);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--tn-dim);
  margin: 0 0 9px;
  padding-bottom: 7px;
  border-bottom: 1px solid var(--tn-line-soft);
}

.tn-cv-skill .tn-cv-chips { margin-top: 0; }

/* stack the strip once the row would wrap awkwardly */
@media (max-width: 640px) {
  .tn-cv-contact {
    display: block;
    font-size: 12px;
  }

  .tn-cv-contact > * {
    display: block;
    padding: 0;
    border-left: 0;
    margin-bottom: 4px;
  }

  .tn-cv-lead {
    padding-left: 16px;
    font-size: 1.05rem;
  }

  /* side by side would leave the summary about 20 characters wide, so the
     row stacks. column-reverse puts the portrait on top, which is the
     conventional order for a CV on a phone; the DOM keeps summary-first,
     so the reading order is unchanged. */
  .tn-cv-intro {
    flex-direction: column-reverse;
    align-items: flex-start;
    gap: 20px;
  }

  /* stacked, so there is no row height to match — back to a fixed square */
  .tn-cv-portrait {
    width: 128px;
    align-self: flex-start;
  }

  .tn-cv-portrait img {
    width: 128px;
    height: 128px;
    max-height: none;
  }

  .tn-cv-skills { grid-template-columns: minmax(0, 1fr); }

  .tn-cv-meta {
    display: block;
  }

  .tn-cv-meta > * {
    display: block;
    padding: 0;
    border-left: 0;
  }

  .tn-page-body .wp-block-post-content h2.tn-cv-h { font-size: 1.5rem; }
}

/* ── Latest Posts block (e.g. in article margin rail) ── */

/* The related-terms list a glossary rail carries instead shares these rules
   rather than restating them: it is the same bulleted stack of links in the
   same column, and the only reason it is not a Latest Posts block is that its
   contents are computed per entry (see technical_notes_glossary_related()). */
.wp-block-latest-posts,
.tn-rail-related-list {
  list-style: none;
  padding-left: 0;
  margin: 0;
}

.wp-block-latest-posts > li,
.tn-rail-related-list > li {
  position: relative;
  padding-left: 14px;
  margin: 0 0 10px;
  line-height: 1.5;
}

.wp-block-latest-posts > li::before,
.tn-rail-related-list > li::before {
  content: "\2022";
  position: absolute;
  left: 0;
  color: var(--tn-cyan);
}

.wp-block-latest-posts a,
.tn-rail-related-list a {
  color: var(--tn-muted);
  text-decoration: none;
}

.wp-block-latest-posts a:hover,
.tn-rail-related-list a:hover { color: var(--tn-cyan); }

/* ── SK chip logo ───────────────────────────────────── */

.tn-logo {
  width: 46px;
  height: 46px;
  flex: none;
  align-self: center;
  transition: width .35s ease, height .35s ease;
}

html.tn-stuck .tn-logo {
  width: 32px;
  height: 32px;
}

.tn-logo .pin {
  stroke: var(--tn-muted);
  stroke-width: 1.5;
  stroke-linecap: round;
}

.tn-logo .body {
  fill: var(--tn-deep);
  stroke: var(--tn-muted);
  stroke-width: 1.5;
}

.tn-logo .trace {
  fill: none;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.tn-logo .c { stroke: var(--tn-cyan); }
.tn-logo .b { stroke: var(--tn-blue); }
.tn-logo .t { stroke: var(--tn-turquoise); }

.tn-logo .via { stroke: none; }
.tn-logo .vc { fill: var(--tn-cyan); }
.tn-logo .vb { fill: var(--tn-blue); }
.tn-logo .vt { fill: var(--tn-turquoise); }

.tn-logo .label {
  fill: var(--tn-cyan);
  font-family: var(--tn-mono);
  font-size: 12.5px;
  font-weight: 600;
  letter-spacing: .04em;
}

/* ── matrix rain backdrop ───────────────────────────── */

.tn-backdrop-layer {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}

.tn-matrix {
  position: absolute;
  inset: 0;
  z-index: 0;
  opacity: .35;
  /* on the resting rule, not the open one below, so it runs in both
     directions — declared only on the open rule there is nothing left
     to interpolate once it stops matching and the canvas snaps sharp */
  transition: filter .3s ease, opacity .6s ease;
}

/* redpill (theme.js modules 2 and 8). The other half of the palette
   swap: the glyph colours and alpha are set on the canvas, the surface
   opacity has to be set here. The backdrop only ever shows in the
   gutters around the opaque page sheet, so running it this much hotter
   costs no legibility anywhere — and at the resting .35 a red backdrop
   just reads as the theme having drifted rather than as something you
   did. The .6s transition above lets it swell in rather than cut. */
.tn-matrix.is-red {
  opacity: .62;
}

/* nyan replaces the rain rather than recolouring it, so it gets the
   surface outright: the sky is meant to be the sky, not a wash over
   --tn-bg. Still z-index -1 — it stays behind every page sheet, so the
   only thing that changes is what fills the gutters. */
.tn-matrix.is-nyan {
  opacity: 1;
}

/* The official nyan.cat GIF stays as a native image so its full 12-frame
   animation is not flattened when the canvas repaints. This fixed layer is
   stacked above the canvas inside their shared negative backdrop layer,
   placing the cats over their trails but behind every opaque site sheet. */
.tn-nyan-cats {
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  transition: filter .3s ease, opacity .2s ease;
}

.tn-nyan-cats.is-active {
  visibility: visible;
  opacity: 1;
}

.tn-nyan-cats img {
  position: absolute;
  top: 0;
  left: 0;
  width: 136px;
  max-width: none;
  height: 84px;
  image-rendering: pixelated;
  user-select: none;
  will-change: transform;
}

/* Doge is another full replacement renderer. Its pixel-art sprites and
   foreground captions are both painted into the canvas by theme.js. */
.tn-matrix.is-doge {
  opacity: 1;
}

/* The canvas is a sibling of main rather than part of it, so the scrim
   that blurs the page behind the open dropdown misses it and it stayed
   sharp in the gutters either side of the page sheet while everything
   around it went soft. Same filter as that rule, same .is-menu-open
   trigger, so the rain settles and lifts in step with the content.

   Deliberately *only* filter: an earlier attempt also raised z-index to
   float the canvas above main (back when the dropdown was translucent
   and wanted rain visible through it). That is what made the rain paint
   over article text further down the page, and it needed a JS-measured
   clip-path to fence it back in. The dropdown is opaque now, so there
   is nothing to show through and no reason to lift it: leaving z-index
   at -1 keeps the canvas behind every sheet exactly as it is at rest,
   and filter alone cannot change paint order. */
html:has(.tn-nav .wp-block-navigation__responsive-container.is-menu-open) .tn-matrix,
html:has(.tn-nav .wp-block-navigation__responsive-container.is-menu-open) .tn-nyan-cats {
  filter: blur(8px) brightness(.7);
}

/* ── DC motor animation ─────────────────────────────── */

/* A four-layer SVG diagram carried over from the author's older site. The
   original sized everything in physical units — a 10cm stage with a 4.2cm
   rotor and 9.3cm annotation overlays — which is 378px wide and overflows
   a phone. The same proportions are kept as percentages of the stage, so
   the whole assembly scales with whatever column it sits in; because the
   stage is 10cm square, every original millimetre is simply 1%.

   The layers are background images on spans rather than <img> elements, so
   the article image rules (1px border, radius, height:auto) and the photo
   hover glass cannot reach them. The image URLs stay inline in the block
   markup, with the content, rather than being hardcoded here. */

.tn-motor-figure {
  margin: 34px 0;
}

.tn-motor {
  display: block;
  position: relative;
  width: min(378px, 100%);
  aspect-ratio: 1;
  margin: 0 auto;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

.tn-motor:focus-visible {
  outline: 2px solid var(--tn-cyan);
  outline-offset: 4px;
  border-radius: 3px;
}

.tn-motor-layer {
  position: absolute;
  margin: auto;
  background-repeat: no-repeat;
  background-position: center;
  /* contain reproduces the xMidYMid meet the SVGs did inside a square
     <img> box — a1/a2 are 169x243, so they letterbox rather than stretch */
  background-size: contain;
}

.tn-motor-stator {
  inset: 0;
}

.tn-motor-rotor {
  top: 3%;
  left: 0;
  right: 0;
  bottom: 0;
  width: 42%;
  height: 42%;
  animation: tn-motor-spin 8s linear infinite;
}

/* the two field annotations sit in the same place and cross-fade */
.tn-motor-a1,
.tn-motor-a2 {
  left: 0;
  right: 2%;
  bottom: 0;
  width: 93%;
  height: 93%;
}

.tn-motor-a1 {
  top: 0;
  opacity: 1;
  animation: tn-motor-field-a 8s linear infinite;
}

.tn-motor-a2 {
  top: 1%;
  opacity: 0;
  animation: tn-motor-field-b 8s linear infinite;
}

/* Nothing moves until asked. This has to out-specify the layer rules
   rather than merely precede them: each of those sets the `animation`
   shorthand, which resets animation-play-state to its initial `running`,
   so a plain .tn-motor-layer declaration further up would be undone. */
.tn-motor .tn-motor-layer {
  animation-play-state: paused;
}

.tn-motor.is-running .tn-motor-layer {
  animation-play-state: running;
}

.tn-motor-hint {
  font-family: var(--tn-mono);
  font-size: 12.5px;
  color: var(--tn-dim);
  text-align: center;
  margin: 14px 0 0;
}

@keyframes tn-motor-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

/* Held for the first quarter turn, away for the middle half, back for the
   last quarter — the original left the 100% stop implicit, which resolved
   to the element's own opacity of 1. */
@keyframes tn-motor-field-a {
  0%   { opacity: 1; }
  25%  { opacity: 0; }
  75%  { opacity: 0; }
  100% { opacity: 1; }
}

@keyframes tn-motor-field-b {
  0%   { opacity: 0; }
  30%  { opacity: 1; }
  100% { opacity: 0; }
}
