/* peril.io — the site-wide design system (docs/site-plan.md § Design).
 *
 * Voice: the landing page's animated low-poly teal canvas (bg.js) behind
 * everything, with content floating on dark glass panels — the same recipe
 * the login gate and portal use, so the whole domain reads as one place.
 * Raleway (variable, self-hosted) for display and nav; Merriweather
 * (variable, self-hosted, true italic) for body; mono smallcaps for the
 * "metadata" voice — dates and tags. One cyan accent, spent on links.
 * A single committed dark theme: the canvas is the ground, and
 * there is no light version of it. Two layouts: mobile (base, a top bar over
 * the column) and desktop (≥42rem, the nav as a left rail beside the
 * centered content column, with a post's table of contents as a second
 * panel beneath it); ≥74rem hangs detached right rails off posts (margin
 * notes) and project pages (the build timeline) without moving the
 * centered column. Motion is brief and easing-led, and
 * sits behind prefers-reduced-motion; the canvas defaults to calm, with
 * Still one click away in the nav (the pick persists) — and to still on
 * phones and for reduced-motion visitors. Touch devices also trade the
 * glass blur for a plain, more opaque fill (see the :root override). */

@font-face {
  font-family: "Raleway";
  src: url("/fonts/raleway-latin.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Merriweather";
  src: url("/fonts/merriweather-latin.woff2") format("woff2");
  font-weight: 300 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Merriweather";
  src: url("/fonts/merriweather-italic-latin.woff2") format("woff2");
  font-weight: 300 900;
  font-style: italic;
  font-display: swap;
}

:root {
  color-scheme: dark;
  /* Tuned to bg.js's teal palette (near-black #0a1619 → teal → cyan). */
  --bg: #06100f;
  --ink: #c3d7d7;
  --bright: #eaf6f6;
  --muted: #7c9494;
  --accent: #5fe4f8;
  --edge: rgb(150 220 225 / 14%);
  --code-bg: rgb(0 0 0 / 28%);
  /* The glass recipe — heavy blur + faint teal fill, edgeless: the shadow
   * and the inset top highlight carry the definition. The reading panel is
   * more opaque than the gate's cards: long-form text over moving facets
   * needs a calmer ground. */
  --glass-fill: rgb(7 18 20 / 62%);
  --glass-blur: blur(26px) saturate(125%);
  --glass-shadow: 0 18px 50px rgb(0 0 0 / 45%), inset 0 1px 0 rgb(255 255 255 / 6%);
  /* Whatever floats above the glass — the locked-link popover, the welcome
   * toast — is lifted by this one shadow. */
  --float-shadow: 0 10px 30px rgb(0 0 0 / 45%);
  --display: "Raleway", ui-sans-serif, "Helvetica Neue", sans-serif;
  --serif: "Merriweather", "Iowan Old Style", Georgia, serif;
  --mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
}

/* Touch devices (phones, tablets): the glass blur goes. A backdrop filter
 * re-blurs whatever is behind the panel whenever it changes — the animating
 * canvas, every frame, or the scroll — and mobile GPUs pay dearly for a
 * 26px radius over a screen-sized panel. A plain, more opaque fill stands
 * in, tuned to read like the blurred 62%: the facets remain as a faint
 * texture rather than a sharp pattern behind the text. */
@media (hover: none) and (pointer: coarse) {
  :root {
    --glass-fill: rgb(7 18 20 / 84%);
    --glass-blur: none;
  }
}

* { box-sizing: border-box; }
/* Placeholder weather: the background is a JS-painted canvas, so every new
 * document opens on an empty, transparent one. Until bg.js (or the login
 * bundle) paints its first frame — and whenever that outlasts the view
 * transition's cover — this approximation of the mesh's lighting shows
 * through instead of flat black: the same teal palette, brightest toward
 * the top-left like the light field. Fixed so it tracks the viewport, as
 * the canvas does. */
html {
  background:
    radial-gradient(60rem 42rem at 12% -6%, rgb(96 165 170 / 45%), transparent 60%),
    linear-gradient(168deg, #17383e, #0b1c20 55%, var(--bg));
  background-attachment: fixed;
}
/* Merriweather has a big x-height and dark texture — it reads a size larger
 * than it measures, so the base is set smaller and looser than usual. */
body {
  margin: 0;
  color: var(--ink);
  font: 1rem/1.75 var(--serif);
  -webkit-font-smoothing: antialiased;
}
::selection { background: var(--accent); color: #04222b; }

/* The faceted canvas: fixed behind everything, content scrolls over it. */
.bg-canvas {
  position: fixed;
  inset: 0;
  z-index: 0;
  display: block;
}

/* --- The glass column --------------------------------------------------- */
/* Header, a post's table of contents, and main are floating panels over
 * the canvas. */
.site-header, .toc, main {
  position: relative;
  z-index: 1;
  width: min(44rem, calc(100% - 1.5rem));
  margin-inline: auto;
  padding-inline: 1.25rem;
  background: var(--glass-fill);
  -webkit-backdrop-filter: var(--glass-blur);
  backdrop-filter: var(--glass-blur);
  border-radius: 4px;
  box-shadow: var(--glass-shadow);
}

/* Links rest without an underline — the accent is the whole signal; the
 * underline arrives on hover. */
a {
  color: var(--accent);
  text-decoration-line: none;
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
}
/* The longhand (the shorthand would reset the thickness), and :where() so
 * this hover carries no specificity — any class-styled link (.brand,
 * .run-project, the chips) keeps its own no-underline rule on hover. */
a:where(:hover) { text-decoration-line: underline; }

/* --- Header / nav: the small mono voice ------------------------------- */
.site-header {
  /* Above main (both are z-index:1 glass siblings, main later in the DOM),
   * so the locked-link popover can extend over the content panel. */
  z-index: 2;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1rem;
  padding-block: 1.1rem;
  margin-top: 0.75rem;
  margin-bottom: clamp(1.25rem, 4vh, 2.5rem);
}
.brand {
  font-family: var(--display);
  font-weight: 700;
  font-size: 1.1rem;
  letter-spacing: -0.01em;
  color: var(--bright);
  text-decoration: none;
}
.brand:hover { color: var(--accent); }
.site-header nav {
  display: flex;
  flex-wrap: wrap;      /* the motion control is wide; let narrow bars break */
  align-items: center;  /* links center against the two-row motion control */
  gap: 0.5rem 1.1rem;
  font-family: var(--display);
  font-weight: 600;
  font-size: 0.76rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}
/* Nav links carry no underline — the color change is the whole signal. */
.site-header nav a { color: var(--muted); text-decoration: none; }
.site-header nav a:hover,
.site-header nav a[aria-current="page"] { color: var(--bright); }
/* Dimmed via color, not opacity — the tooltip below lives inside the link
 * and must stay fully opaque. */
.site-header a.locked { color: rgb(124 148 148 / 50%); position: relative; }
.site-header a.locked:hover { color: rgb(124 148 148 / 50%); }
/* The "requires login" popover, from the link's data-tip. In the mobile top
 * bar it opens below, anchored right so it stays on-screen; the desktop rail
 * moves it to the link's right instead (see the ≥42rem block). */
.site-header a.locked::after {
  content: attr(data-tip);
  position: absolute;
  top: calc(100% + 0.55rem);
  right: 0;
  z-index: 3;
  width: max-content;
  max-width: 14rem;
  padding: 0.45rem 0.65rem;
  /* Inverted from the dark cards so it can't blend into the panels. */
  background: var(--bright);
  border-radius: 4px;
  box-shadow: var(--float-shadow);
  font-family: var(--serif);
  font-size: 0.78rem;
  font-weight: 400;
  line-height: 1.45;
  letter-spacing: normal;
  text-transform: none;
  color: var(--bg);
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
}
.site-header a.locked:hover::after,
.site-header a.locked:focus-visible::after { opacity: 1; visibility: visible; }
/* Logout: site.js swaps the Login link for a button — dress it as a link. */
button.nav-auth {
  /* `all: unset` hands back the nav's font, letter-spacing and caps on its
   * own — every one of them is an inherited property. */
  all: unset;
  cursor: pointer;
  color: var(--muted);
}
button.nav-auth:hover { color: var(--bright); }
/* The magic-link arrival toast (site.js): the fallback greeting for
 * landings without a home lede to retype (links minted with next=…) — one
 * floating card, centered near the top of the viewport, announcing the
 * session the link just installed. Shown exactly once, a second after
 * landing — .show settles it in and a timer fades it out; without the
 * motion block's transition (reduced motion) it simply appears and is
 * removed. */
.welcome-toast {
  position: fixed;
  left: 50%;
  top: 120px;
  z-index: 4;
  transform: translate(-50%, -0.75rem);
  max-width: min(30rem, calc(100vw - 2rem));
  padding: 1rem 1.6rem;
  background: rgb(10 24 27 / 94%);
  border: 1px solid var(--edge);
  border-radius: 4px;
  box-shadow: var(--float-shadow);
  font-family: var(--display);
  font-weight: 600;
  font-size: 1rem;
  letter-spacing: 0.03em;
  color: var(--bright);
  opacity: 0;
}
.welcome-toast.show {
  opacity: 1;
  transform: translate(-50%, 0);
}
/* The motion control — Stormy / Calm / Still as one radiogroup of labels.
 * No underlines: hover and the active state color the label in its own
 * weather — electric cyan for Stormy, teal for Calm, pale grey for Still
 * (a step brighter than the muted resting state, so the pick still reads).
 * bg.js unhides the control and drives aria-checked. Hidden on mobile: the
 * base rule is display:none and the desktop layout turns it on. */
.bg-cycle {
  display: none;
  gap: 1.1rem;
}
.bg-cycle button {
  all: unset;
  cursor: pointer;
  color: var(--muted);
}
.bg-cycle button[data-set="stormy"]:hover,
.bg-cycle button[data-set="stormy"][aria-checked="true"] { color: var(--accent); }
.bg-cycle button[data-set="calm"]:hover,
.bg-cycle button[data-set="calm"][aria-checked="true"] { color: rgb(110 190 200); }
.bg-cycle button[data-set="still"]:hover,
.bg-cycle button[data-set="still"][aria-checked="true"] { color: var(--ink); }
/* A post's table of contents (shell.ts renders it from the body's h2/h3
 * headings): a second panel under the header, in the desktop rail only —
 * the mobile top bar has no room for it — so, as with the motion control,
 * the base rule hides it and the desktop layout shows it. */
.toc { display: none; }
.toc ol { list-style: none; margin: 0; padding: 0; }

/* --- Shared surfaces ----------------------------------------------------- */
/* The chip: a small raised label under a faint white wash, worn by the post
 * lists' continue links and by the carousel arrows — so it resets <a> and
 * <button> alike, and each caller adds only its own metrics. */
.chip {
  all: unset;
  cursor: pointer;
  display: inline-block;
  padding: 0.2rem 0.8rem;
  border-radius: 4px;
  background: rgb(255 255 255 / 5%);
  color: var(--ink);
  font-family: var(--display);
  font-weight: 600;
}
.chip:hover:not(:disabled) { color: var(--bright); background: rgb(255 255 255 / 8%); }
.chip:disabled { cursor: default; opacity: 0.35; }
/* The "→" trailing the continue and more links — it slides on hover. */
.arrow { display: inline-block; }
a:hover .arrow { transform: translateX(4px); }
/* The detached rail cards (§ Desktop-wide): post margin notes and the
 * project build timeline. The same material as the panels, without the
 * blur: a backdrop-filter here can't sample the canvas — the panel's own
 * blur, its view-transition-name, and the article's finished .rise
 * animation each isolate the card from what's behind it (measured; all
 * three would have to go). So the cards take the recipe touch devices use
 * for the panels — the panel's hue at 84%, which reads like the blurred
 * 62% — and the glass shadow with its inset highlight. Painted only where
 * the rails leave the column; narrower than that the notes read as inset
 * prose and the builds stack. Asides join by selector rather than class:
 * their markup comes from the markdown pipeline, which strips them by tag
 * for the intro and word count. */
@media (min-width: 74rem) {
  .rail-card,
  main:is(.post, .about) article aside,
  main:is(.post, .about) article .aside {
    background: rgb(7 18 20 / 84%);
    box-shadow: var(--glass-shadow);
  }
}

/* After the `all: unset` buttons and chips above, so the ring survives the
 * reset. The carousel track is a plain div, focusable for keyboard scroll. */
a:focus-visible,
button:focus-visible,
.carousel-track:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --- Main panel --------------------------------------------------------- */
main { padding-block: 2rem 2.5rem; margin-bottom: 0.75rem; }

/* --- Type ------------------------------------------------------------- */
h1, h2, h3 {
  font-family: var(--display);
  font-weight: 650;
  line-height: 1.14;
  letter-spacing: -0.012em;
  color: var(--bright);
  text-wrap: balance;
}
h1 { font-size: 2.1rem; margin: 0 0 0.75rem; }
h2 { font-size: 1.45rem; margin: 2.2rem 0 0.6rem; }
h3 { font-size: 1.15rem; margin: 1.8rem 0 0.4rem; }

.meta, time {
  font-family: var(--mono);
  font-size: 0.72rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
}

/* --- Hero (home) -------------------------------------------------------- */
.hero h1 {
  font-size: clamp(2.4rem, 6.5vw, 2.8rem);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.06;
  margin: 0 0 1.2rem;
}
.hero .lede p { font-size: 1.05rem; color: var(--muted); margin: 0 0 0.8rem; }
/* The magic-link greeting retypes the lede in place (site.js): .typing
 * rides a blinking caret on the line while the typewriter runs, .welcome
 * lifts the message out of the lede's muted voice. Neither class appears
 * for reduced-motion visitors — they get a plain swap and back. */
.lede p.typing::after {
  content: "";
  display: inline-block;
  width: 2px;
  height: 1.05em;
  margin-left: 2px;
  vertical-align: -0.15em;
  background: var(--accent);
  animation: caret-blink 0.9s steps(1) infinite;
}
.lede p.welcome { color: var(--bright); }
@keyframes caret-blink { 50% { opacity: 0; } }

/* --- Post lists (home + blog index) ------------------------------------ */
.post-list { list-style: none; margin: 0; padding: 0; }
/* Title | date on one row (grid reorders the time-first markup), blurb
 * spanning below. */
.post-list li {
  padding-block: 1.35rem;
  border-top: 1px solid var(--edge);
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  column-gap: 1rem;
}
.post-list li:first-child { border-top: 0; }
/* Dates in the display face — the nav voice, not the mono metadata one.
 * Shared with the article headers below (which covers Updated too). */
.post-list li time,
article header time { font-family: var(--display); font-weight: 600; }
.post-list li time { grid-row: 1; grid-column: 2; }
/* Direct child only: markdown blurbs (project descriptions) can hold
 * links of their own, which keep the normal body-link look. */
.post-list li > a {
  grid-row: 1;
  grid-column: 1;
  justify-self: start;
  font-family: var(--display);
  font-weight: 600;
  font-size: 2rem;
  line-height: 1.25;
  color: var(--bright);
  text-decoration: none;
}
.post-list li > a:hover { color: var(--accent); }
/* Body ink, matching the post page's own text. */
.post-list p { grid-column: 1 / -1; color: var(--ink); font-size: 0.98rem; }
/* Wins the size/color tug-of-war with `.post-list p` above; family/weight
 * come from the global .subtitle rule (§ Articles). */
.post-list .subtitle { font-size: 1.05rem; margin: 0.35rem 0 0; color: var(--muted); }
.post-list .continue { margin: 0.9rem 0 0; }
/* The chip (§ Shared surfaces) in the lists' caps voice. */
.post-list .continue .chip {
  padding: 0.4rem 0.9rem;
  font-size: 0.72rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}
/* The quiet caps links that end a list or a page — "More writing", and the
 * back links on posts and project pages (§ Articles). */
.more a,
.back a {
  font-family: var(--display);
  font-weight: 600;
  font-size: 0.78rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  text-decoration: none;
}
.empty { color: var(--muted); font-style: italic; }

/* --- Articles (blog posts, about) --------------------------------------- */
/* Anchor jumps (the rail's table of contents) land a heading a breath
 * below the viewport's top edge rather than flush against it. */
article :is(h2, h3)[id] { scroll-margin-top: 1.5rem; }
article header { margin-bottom: 2.2rem; }
/* Post header: title | date on one row, same recipe as the list pages (the
 * grid reorders the time-first markup); subtitle, Updated, and tags span
 * full width below. Scoped to posts — project headers grid differently. */
main.post article > header {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  column-gap: 1rem;
}
main.post article > header > time { grid-row: 1; grid-column: 2; }
main.post article > header h1 { grid-row: 1; grid-column: 1; margin-bottom: 0; }
main.post article > header .subtitle,
main.post article > header .updated,
main.post article > header .tags { grid-column: 1 / -1; }
.subtitle {
  font-family: var(--display);
  font-weight: 500;
  font-size: 1.2rem;
  color: var(--muted);
  margin: 0.1rem 0 0.6rem;
}
.updated { margin: 0.4rem 0 0; }
.tags { list-style: none; display: flex; gap: 0.45rem; padding: 0; margin: 0.8rem 0 0; }
.tags li {
  font-family: var(--mono);
  font-size: 0.68rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  border: 1px solid var(--edge);
  border-radius: 999px;
  padding: 0.05rem 0.55rem;
}
article p, article li { max-width: 65ch; }
/* Markdown lists: the browser-default ~40px indent reads as excessive in
 * the panels — pull bullets back in line with the paragraph edge (markers
 * just outside, wrapped lines hanging from the text). Class-styled lists
 * (.tags, .project-links, .build-timeline) zero their own padding and win
 * on specificity. */
article ul, article ol,
#about-private ul, #about-private ol { padding-left: 1.1em; }
/* Always disc — nested levels (e.g. changelogs inside the timeline's ul)
 * would otherwise demote to circle/square. */
article ul, #about-private ul, .changelog ul { list-style: disc; }
pre {
  background: var(--code-bg);
  border: 1px solid var(--edge);
  padding: 0.9rem 1.05rem;
  border-radius: 4px;
  overflow-x: auto;
  font-size: 0.82rem;
  line-height: 1.6;
}
code { font-family: var(--mono); font-size: 0.88em; }
:not(pre) > code { background: var(--code-bg); border-radius: 4px; padding: 0.08em 0.35em; }
img { max-width: 100%; border-radius: 6px; }
blockquote { margin: 1.2rem 0; padding-left: 1.1rem; border-left: 3px solid var(--accent); color: var(--muted); }
/* Asides (`::: aside` in posts and the about copy): margin notes. In the
 * one-column layout they read as inset notes; the wide layout floats them
 * into a right margin column that main.post / main.about reserve (see the
 * ≥74rem block). span.aside is the mid-paragraph inline form —
 * block-displayed so it reads the same. */
article aside,
article .aside {
  display: block;
  position: relative; /* anchors the lit accent bar below */
  margin: 1.2rem 0;
  padding: 0.95rem 1.15rem;
  font-size: 0.8rem;
  line-height: 1.6;
  color: rgb(164 185 185); /* between --muted and --ink: quiet but readable */
  border-radius: 4px; /* the site's panel radius — for the lit fill and the rail cards */
}
/* The lit accent bar, as a pseudo-element rather than a border so it can
 * wipe in without touching layout (a real border would nudge the text and
 * fight the ≥74rem cards' border: 0). Scales top-down — the ref reaching
 * into the note — on the compositor. */
article aside::before,
article .aside::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 2px;
  border-radius: 4px 0 0 4px; /* follows the card's corners */
  background: var(--accent);
  transform: scaleY(0);
  transform-origin: top;
}
article aside p { max-width: none; margin: 0 0 0.55em; }
article aside p:last-child { margin-bottom: 0; }
/* [[term|id]] references: a quiet dotted underline; hovering either end
 * lights both (the .lit class, paired up by site.js). */
.aside-ref {
  text-decoration: underline dotted;
  text-decoration-color: var(--muted);
  text-underline-offset: 3px;
  cursor: help;
}
.aside-ref.lit {
  color: var(--bright);
  text-decoration-color: var(--accent); /* still dotted — only the ink lifts */
}
main:is(.post, .about) article aside.lit,
main:is(.post, .about) article .aside.lit {
  color: var(--ink);
  background: rgb(14 32 36 / 84%); /* the card's opacity, a hair lighter in tone */
}
main:is(.post, .about) article aside.lit::before,
main:is(.post, .about) article .aside.lit::before { transform: scaleY(1); }
hr { border: 0; border-top: 1px solid var(--edge); margin: 2.5rem 0; }
.back { margin-top: 3.5rem; }

/* The two sections hung below an article behind a hairline: the gated half
 * of /about/ (site.js unhides + fills it after login) and the project
 * page's builds — which ≥74rem lifts out into a rail of its own. Only the
 * builds are a query container: containment would also make #about-private
 * the containing block for its aligned asides, which pin against the
 * article. */
#about-private,
.builds { margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid var(--edge); }
.builds { container-type: inline-size; }

/* --- Projects (gated) ---------------------------------------------------- */
/* The index reuses .post-list — same list as home/blog, no styles of its
 * own. The rules below are the project page. */

/* Project page header extras. */
.project-links {
  list-style: none;
  display: flex;
  gap: 1.1rem;
  padding: 0;
  margin: 0.8rem 0 0;
  font-family: var(--display);
  font-weight: 600;
  font-size: 0.78rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}
/* The screenshot carousel: a scroll-snap track — swipe/scroll works with no
 * JS; projects.js wires the buttons and the counter. */
.carousel { margin: 2rem 0 0; }
.carousel-track {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none; /* the nav row below is the affordance */
  border-radius: 6px;
}
.carousel-track::-webkit-scrollbar { display: none; }
.carousel-track figure { flex: 0 0 100%; margin: 0; scroll-snap-align: center; }
.carousel-track img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  cursor: zoom-in; /* opens the lightbox (projects.js) */
}
/* Captions sit top-left above the image, so the nav row below can hug it. */
.carousel-track figcaption { margin: 0 0 0.45rem; }
.carousel-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.1rem;
  margin-top: 0.45rem;
}
/* The lightbox (projects.js): the clicked screenshot as large as the
 * window allows, uncropped, over a dimmed backdrop. */
.lightbox {
  padding: 0;
  border: 1px solid var(--edge);
  border-radius: 6px;
  background: var(--bg);
  max-width: calc(100vw - 2rem);
  max-height: calc(100vh - 2rem);
  /* Dialogs default to overflow:auto, and the 1px borders leave the image
   * 2px taller than the content box — enough to grow a scrollbar. */
  overflow: hidden;
}
.lightbox img {
  display: block;
  max-width: calc(100vw - 2rem - 2px);
  max-height: calc(100vh - 2rem - 2px);
  object-fit: contain;
  cursor: zoom-out; /* the whole surface closes, image included */
}
.lightbox::backdrop {
  background: rgb(3 10 10 / 72%);
  backdrop-filter: blur(4px);
}
.lightbox-close {
  all: unset;
  position: absolute;
  top: 0.4rem;
  right: 0.55rem;
  cursor: pointer;
  padding: 0 0.45rem;
  border-radius: 4px;
  color: var(--bright);
  background: rgb(0 0 0 / 45%);
  font-family: var(--display);
  font-size: 1.3rem;
  line-height: 1.4;
}
.lightbox-close:hover { background: rgb(0 0 0 / 70%); }

/* The title row: h1 left, the Run Project CTA right. projects.js points the
 * button (rendered hidden) at the newest build and unhides it. */
main.project article header {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  column-gap: 1rem;
}
main.project article header > * { grid-column: 1; }
main.project article header .run-project { grid-row: 1; grid-column: 2; }
/* The one deliberate accent surface: the page's call to action. */
.run-project {
  padding: 0.45rem 0.95rem;
  background: rgb(95 228 248 / 7%);
  border-radius: 4px;
  color: var(--accent);
  text-decoration: none;
  font-family: var(--display);
  font-weight: 600;
  font-size: 0.8rem;
  letter-spacing: 0.03em;
  white-space: nowrap;
}
.run-project:hover { background: rgb(95 228 248 / 11%); }

/* The builds section — the dated timeline; the hairline above it comes from
 * the shared rule in § Articles. */
.build-status { color: var(--muted); font-style: italic; }
/* Builds as a timeline: a hairline down the left with one node per
 * build — date, label, changelog hanging off each dot. */
.build-timeline { list-style: none; margin: 1.1rem 0 0; padding: 0; position: relative; }
.build-timeline::before {
  content: "";
  position: absolute;
  top: 0.5lh; /* the first node's center — dots sit at 0.5lh too */
  bottom: 0.4rem;
  left: 4px;
  width: 1px;
  background: var(--edge);
}
/* Label | date on one row (the same grid reorder the post lists use:
 * the markup is time-first), the changelog spanning below. Direct-child
 * selectors throughout — a changelog's own bullet list nests more li/a. */
.build-timeline > li {
  position: relative;
  padding: 0 0 1.4rem 1.5rem;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  column-gap: 1rem;
}
.build-timeline > li:last-child { padding-bottom: 0.25rem; }
.build-timeline > li > time {
  grid-row: 1;
  grid-column: 2;
  text-align: right;
  font-size: 0.64rem; /* a point under the site's 0.72rem meta voice */
  /* Faded a step past the muted ink the shared time rule sets. */
  color: color-mix(in srgb, var(--muted) 60%, transparent);
}
.build-timeline .changelog { grid-column: 1 / -1; }
.build-timeline > li::before {
  content: "";
  position: absolute;
  left: 0;
  top: calc(0.5lh - 4.5px); /* centered on the label/date row's first line */
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: rgb(150 220 225 / 45%);
}
.build-timeline > li > a {
  grid-row: 1;
  grid-column: 1;
  justify-self: start;
  font-family: var(--display);
  font-weight: 600;
  color: var(--bright);
  text-decoration: none;
}
.build-timeline > li > a:hover { color: var(--accent); }
/* The aside voice (article aside): the same quiet 0.8rem. */
.changelog { margin-top: 0.35rem; color: var(--muted); font-size: 0.8rem; line-height: 1.6; }
.changelog p, .changelog ul { margin: 0.35rem 0; }
.changelog ul { padding-left: 1.1em; }

/* Month markers — projects.js drops one in wherever the list crosses into
 * an older month: a quiet pill label with a tick on the line, not a dot. */
.build-timeline > li.month-marker {
  padding-bottom: 1.1rem;
  font-family: var(--display);
  font-weight: 600;
  font-size: 0.68rem;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted);
}
.build-timeline > li.month-marker > span {
  justify-self: start; /* content-sized, not stretched across the grid */
  background: rgb(255 255 255 / 4%);
  border: 1px solid var(--edge);
  border-radius: 999px;
  padding: 0.15rem 0.6rem;
}
.build-timeline > li.month-marker::before {
  top: calc(0.5lh - 0.5px);
  width: 9px;
  height: 1px;
  border-radius: 0;
  background: var(--edge);
}

/* A narrow builds column (the shrinking ≥74rem rail): below 20rem the
 * timeline furniture (line, dots, ticks) comes off and the text takes the
 * full width. .builds is the container (inline-size, § Articles); size
 * queries measure its content box, so 20rem outer minus the rail card's
 * 2 × 1.3rem side padding is the 17.4rem threshold. */
@container (width < 17.4rem) {
  .build-timeline::before,
  .build-timeline > li::before,
  .build-timeline > li.month-marker::before { display: none; }
  .build-timeline > li { padding-left: 0; }
}

/* --- Desktop: the nav is a rail beside a centered content column -------- */
@media (min-width: 42rem) {
  /* One layout for every desktop size: the content column and the nav rail
   * sit together as a centered pair, 20px apart, the rail on the left. */
  body {
    font-size: 1.0625rem;
    display: flex;
    justify-content: center;
    align-items: flex-start;
    gap: 1.25rem;
    padding: 1.25rem 0.75rem;
  }
  /* A phantom mirror of the rail: with it, centering the flex row puts the
   * content column itself dead-center in the viewport. The huge shrink
   * factor collapses it first, so narrow viewports spend the space on
   * content and let the column drift off-center instead. */
  body::after {
    content: "";
    flex: 0 1000 13rem;
  }
  /* The rail: the header panel and, on posts, the table of contents panel
   * under it, stacked and stuck as one. z-index above main (the panels are
   * z-index:1 glass siblings) so the locked-link popover can ride over the
   * content panel; sticky makes the rail a stacking context, so the header's
   * own bump wouldn't reach outside it. */
  .rail {
    position: sticky;
    top: 1.25rem;
    z-index: 2;
    flex: 0 0 13rem;
    display: flex;
    flex-direction: column;
    gap: 1.25rem;
  }
  .site-header {
    width: auto;
    margin: 0;
    flex-direction: column;
    align-items: stretch;
    gap: 1.4rem;
    padding: 1.5rem 1.4rem;
  }
  .brand { font-size: 1.25rem; }
  .site-header nav {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.9rem;
  }
  /* The motion control docks at the rail's foot, full column width. */
  .bg-cycle {
    display: flex;
    justify-content: space-between;
    align-self: stretch;
    margin-top: 0.5rem;
    padding-top: 1.1rem;
    border-top: 1px solid var(--edge);
  }
  .bg-cycle[hidden] { display: none; }
  /* The table of contents panel: the nav's face but sentence case, lighter,
   * and a step smaller, so the site's own links stay the louder list. h3s
   * nest, indented, smaller again. Links rest muted; hover and the section
   * being read (site.js sets aria-current) brighten. A long list scrolls
   * inside itself past roughly what the header panel leaves of the
   * viewport — a sticky rail taller than the viewport would hide its foot
   * until the page's end. */
  .toc {
    display: block;
    width: auto;
    margin: 0;
    padding: 1.25rem 1.4rem 1.4rem;
    font-family: var(--display);
    font-weight: 500;
    font-size: 0.8rem;
    line-height: 1.35;
  }
  .toc-title {
    margin: 0 0 0.75rem;
    font-weight: 600;
    font-size: 0.68rem;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: color-mix(in srgb, var(--muted) 75%, transparent);
  }
  .toc > ol {
    max-height: max(8rem, calc(100vh - 26rem));
    overflow-y: auto;
    scrollbar-width: thin;
  }
  .toc ol ol { margin: 0.4rem 0 0 0.85rem; font-size: 0.74rem; }
  .toc li { text-wrap: pretty; }
  .toc li + li { margin-top: 0.45rem; }
  .toc a { color: var(--muted); text-decoration: none; }
  .toc a:hover,
  .toc a[aria-current] { color: var(--bright); }
  /* Main is a shrinkable flex item: its basis is the column's ideal width,
   * and narrow viewports squeeze it against the rail instead of overlapping. */
  main {
    width: auto;
    flex: 0 1 44rem;
    min-width: 0;
    margin: 0;
    padding-inline: 2rem;
    padding-block: 2.5rem 3rem;
  }
  h1 { font-size: 2.4rem; }

  /* Beside the rail the popover sits to the link's right, vertically
   * centered — riding over the gap onto the content panel (hence the
   * header's z-index bump). */
  .site-header a.locked::after {
    left: calc(100% + 0.75rem);
    right: auto;
    top: 50%;
    transform: translateY(-50%);
  }

}

/* --- Desktop-wide: detached side rails ---------------------------------- */
/* The reading panel never moves: every page keeps the same centered flex
 * slot (the body::after phantom included), and the extra columns — post
 * margin notes, the project build timeline — hang off the panel's right
 * edge out-of-flow, so they cannot push it. Each rail takes the room the
 * viewport has to spare, capped at its ideal width: 50vw - 24.5rem is the
 * space right of the centered panel (half of 44rem, + the 1.25rem gap)
 * that keeps a viewport-edge margin matching the 1.25rem gap (of which the
 * body padding provides 0.75rem). 74rem is where the phantom is fully open
 * (true centering) with ~12.5rem left over for a rail. */
@media (min-width: 74rem) {
  /* Posts and the about page: margin notes float past the panel edge as
   * their own cards — the .rail-card surface (§ Shared surfaces) carries
   * the fill and the shadow. flow-root so a trailing note can't spill past
   * the article; the pull is the note's width + the panel's 2rem padding +
   * the 1.25rem rail gap. */
  main:is(.post, .about) { --aside-w: min(18rem, 50vw - 24.5rem); }
  main:is(.post, .about) article {
    display: flow-root;
    position: relative; /* the containing block for aligned asides */
  }
  main:is(.post, .about) article aside,
  main:is(.post, .about) article .aside {
    float: right;
    clear: right;
    width: var(--aside-w);
    margin: 0 calc(-1 * (var(--aside-w) + 3.25rem)) 1.1rem 0;
  }
  /* site.js flips the article to aligned mode: notes leave the float flow
   * and pin (top set inline, per note) beside the line of their [[ref]].
   * 100% is the article's right edge; + the panel padding + the rail gap. */
  main:is(.post, .about) article.aside-aligned aside,
  main:is(.post, .about) article.aside-aligned .aside {
    float: none;
    position: absolute;
    left: calc(100% + 3.25rem);
    margin: 0;
  }

  /* Projects: the build timeline as a card off the panel's right edge —
   * absolute against main (the positioned glass panel), so it takes no
   * space in the flow. Its surface is .rail-card, as the post notes'. */
  main.project .builds {
    position: absolute;
    top: 0;
    left: calc(100% + 1.25rem);
    width: min(22rem, 50vw - 24.5rem);
    margin: 0;
    padding: 1.4rem 1.3rem 1.5rem;
    border-top: 0;
    font-size: 0.9rem;
    border-radius: 4px;
  }
  main.project .builds h2 { margin-top: 0; font-size: 1.05rem; }
}

/* --- Motion: brief, eased, optional -------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; } /* the table of contents' anchor jumps */
  a, button.nav-auth {
    transition: color 140ms ease;
  }
  .arrow { transition: transform 180ms cubic-bezier(0.2, 0.7, 0.2, 1.2); }
  .chip { transition: color 140ms ease, background-color 140ms ease; }
  .site-header a.locked::after { transition: opacity 140ms ease, visibility 140ms; }
  .aside-ref { transition: color 140ms ease, text-decoration-color 140ms ease; }
  article aside, article .aside { transition: color 140ms ease, background-color 140ms ease; }
  article aside::before, article .aside::before { transition: transform 200ms cubic-bezier(0.2, 0.75, 0.25, 1); }
  .bg-cycle button { transition: color 140ms ease; }
  .welcome-toast { transition: opacity 260ms ease, transform 260ms cubic-bezier(0.2, 0.75, 0.25, 1); }
  .run-project { transition: background-color 140ms ease; }
  .rise {
    opacity: 0;
    animation: rise 560ms cubic-bezier(0.2, 0.75, 0.25, 1) forwards;
    animation-delay: calc(var(--i, 0) * 70ms);
  }
  @keyframes rise {
    from { opacity: 0; transform: translateY(14px); }
    to { opacity: 1; transform: none; }
  }
}

/* --- Page transitions ----------------------------------------------------- */
/* Cross-document view transitions between the static pages — pure CSS, and
 * a progressive enhancement: browsers without support just navigate. Behind
 * the same reduced-motion gate as everything else, so those visitors get an
 * instant swap. Three named pieces, each with its own treatment:
 *   - canvas: skips the cross-fade entirely (animation: none keeps both
 *     snapshots at full opacity, the live one on top), so the weather never
 *     blends with a stale still of itself;
 *   - header: the default cross-fade — it's near-identical on every page,
 *     so all that shows is the aria-current color handing over;
 *   - main: the old panel drops out fast, the new one does the site's
 *     fade-rise; the group's default morph tweens the panel's box between
 *     page widths (44rem column ↔ the wide home/projects panels). */
@media (prefers-reduced-motion: no-preference) {
  @view-transition {
    navigation: auto;
  }
  .bg-canvas { view-transition-name: canvas; }
  .site-header { view-transition-name: header; }
  main { view-transition-name: main; }
  ::view-transition-old(canvas),
  ::view-transition-new(canvas) { animation: none; }
  ::view-transition-old(main) { animation: page-out 120ms ease both; }
  ::view-transition-new(main) { animation: page-in 240ms cubic-bezier(0.2, 0.75, 0.25, 1) both; }
  @keyframes page-out {
    to { opacity: 0; }
  }
  @keyframes page-in {
    from { opacity: 0; transform: translateY(10px); }
    to { opacity: 1; transform: none; }
  }
}
