:root {
  color-scheme: dark;
  --bg: #0d0d0d;
  --bg-card: #161616;
  --border: #262626;
  --text: #ededed;
  --text-muted: #9a9a9a;
  --text-faint: #6b6b6b;
  --accent: #e2703a;
  --sidebar-w: 300px;

  /* Liquid glass */
  --glass-bg: rgba(255, 255, 255, 0.055);
  --glass-bg-strong: rgba(255, 255, 255, 0.1);
  --glass-border: rgba(255, 255, 255, 0.14);
  --glass-highlight: rgba(255, 255, 255, 0.35);
  --glass-blur: 20px;

  /* A handful of one-off translucent surfaces that can't just reuse the
     --glass-* tokens above (scrollbar, mobile nav panels) — themed here too
     so light mode (see [data-theme="light"] below) doesn't leave white-on-white
     or dark-on-dark spots. */
  --scrollbar-track: rgba(255, 255, 255, 0.055);
  --scrollbar-thumb-flat: rgba(255, 255, 255, 0.55);
  --scrollbar-thumb-from: rgba(255, 255, 255, 0.75);
  --scrollbar-thumb-to: rgba(255, 255, 255, 0.35);
  --scrollbar-thumb-hover-from: rgba(255, 255, 255, 0.85);
  --scrollbar-thumb-hover-to: rgba(255, 255, 255, 0.45);
  --island-idle-from: rgba(255, 255, 255, 0.075);
  --island-idle-to: rgba(255, 255, 255, 0.025);
  --island-idle-border: rgba(255, 255, 255, 0.1);
  /* Mobile top bar (.sidebar idle pill, .nav-toggle) glass — same airy
     tint as --island-idle-* rather than a denser Safari-matched gray, so
     it reads as the site's usual liquid glass instead of a separate,
     heavier chrome. */
  --mobile-chrome-from: rgba(255, 255, 255, 0.075);
  --mobile-chrome-to: rgba(255, 255, 255, 0.025);
  --mobile-chrome-border: rgba(255, 255, 255, 0.1);
  --mobile-chrome-fg: var(--text-muted);
  --panel-solid-from: rgba(30, 30, 30, 0.96);
  --panel-solid-to: rgba(12, 12, 12, 0.96);
}

/* Light theme: toggled via the sun/moon pair in the dynamic island (see
   .theme-toggle) and persisted to localStorage as "siteTheme" — applied
   early by a tiny inline script in <head> so there's no dark-then-light
   flash on load. Light is the default; a visitor opts into dark instead.
   Same restrained-palette approach as dark: white surface,
   a few greys for text/borders, one darkened accent for contrast on
   white (orange, same as dark theme — the Dynamic Island and the rest
   of the site keep it; only the specific spots below are overridden
   to gray). */
:root[data-theme="light"] {
  color-scheme: light;
  --bg: #ffffff;
  --bg-card: #f4f4f5;
  --border: #e3e3e6;
  --text: #17181a;
  --text-muted: #55565a;
  --text-faint: #8b8b90;
  --accent: #b8501d;

  --glass-bg: rgba(0, 0, 0, 0.035);
  --glass-bg-strong: rgba(0, 0, 0, 0.065);
  --glass-border: rgba(0, 0, 0, 0.12);
  --glass-highlight: rgba(255, 255, 255, 0.75);

  --scrollbar-track: rgba(0, 0, 0, 0.04);
  --scrollbar-thumb-flat: rgba(0, 0, 0, 0.32);
  --scrollbar-thumb-from: rgba(0, 0, 0, 0.38);
  --scrollbar-thumb-to: rgba(0, 0, 0, 0.18);
  --scrollbar-thumb-hover-from: rgba(0, 0, 0, 0.48);
  --scrollbar-thumb-hover-to: rgba(0, 0, 0, 0.26);
  --island-idle-from: rgba(0, 0, 0, 0.045);
  --island-idle-to: rgba(0, 0, 0, 0.015);
  --island-idle-border: rgba(0, 0, 0, 0.08);
  --mobile-chrome-from: rgba(0, 0, 0, 0.045);
  --mobile-chrome-to: rgba(0, 0, 0, 0.015);
  --mobile-chrome-border: rgba(0, 0, 0, 0.08);
  --panel-solid-from: rgba(255, 255, 255, 0.97);
  --panel-solid-to: rgba(244, 244, 247, 0.97);
}

/* Footer copyright and the avatar tooltip border read as too loud/orange
   against a white surface, so light theme swaps just these for a neutral
   gray. Everywhere else — including the hero name — keeps the orange
   accent. Dark theme is untouched throughout. */
:root[data-theme="light"] footer.work-sub p {
  color: var(--text-muted);
}

:root[data-theme="light"] #tooltip-bubble.is-punchy {
  border-color: var(--text-muted);
}

/* Mobile renders backdrop-filter blur noticeably grainier than desktop
   (software-blur dithering on phone GPUs) — a small blur-radius trim here
   is enough to calm it without touching desktop, which keeps --glass-blur
   at its full 20px. */
@media (max-width: 860px) {
  :root {
    --glass-blur: 16px;
  }
}

* {
  box-sizing: border-box;
}

a,
button {
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}

html,
body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* Anchor jumps (Home/Works/News, "See what I've built") animate instead of
   snapping instantly — matters most for the mobile fullscreen panel: tapping
   a nav link both closes the panel (its own fade-out transition, see the
   860px media query) and jumps the page, and an instant jump used to land
   the page on the new section a full frame before the panel had even
   started shrinking, so the close read as broken/missing instead of the
   mirror of the open. Doesn't affect .work .cards' own horizontal drag/
   drift scrolling below — scroll-behavior is per scroll-container, not
   inherited, so it only ever governs the document's own scroll. */
html {
  scroll-behavior: smooth;
}

/* Intro splash: a big version of the avatar, shown on every page load. It
   fades in, holds, spins one and a half turns, then the whole overlay
   fades out to reveal the homepage underneath. Plain site background —
   sampling a color from the photo never looked quite right. Sits above
   absolutely everything. */
#intro-splash {
  position: fixed;
  inset: 0;
  z-index: 10000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg);
  transition: opacity 0.55s ease;
}

#intro-splash img {
  width: 100px;
  height: 100px;
  border-radius: 50%;
  opacity: 0;
  transform: scale(0.85) rotate(0deg);
  transition: opacity 0.5s ease, transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}

#intro-splash.is-shown img {
  opacity: 1;
  transform: scale(1) rotate(0deg);
}

#intro-splash.is-spinning img {
  transform: scale(1) rotate(720deg);
  transition: transform 1s cubic-bezier(0.65, 0, 0.35, 1);
}

#intro-splash.is-leaving {
  opacity: 0;
  pointer-events: none;
}

/* Ambient particle field, injected by script.js; stays behind all content.
   Canvas is a replaced element, so top/right/bottom/left:0 alone won't
   reliably stretch it — width/height must be set explicitly. */
#particles-bg {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 0;
  pointer-events: none;
}

/* Custom orange cursor dot, injected by script.js. Fine-pointer devices
   only — mobile/touch keeps the native tap behavior. !important beats the
   handful of cursor:pointer rules already declared later in this file for
   .card-preview, .ai-item, .nav-toggle, etc. */
@media (pointer: fine) {
  * {
    cursor: none !important;
  }

  #cursor-dot {
    position: fixed;
    top: 0;
    left: 0;
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: var(--accent);
    box-shadow: 0 0 10px 2px rgba(226, 112, 58, 0.7), 0 0 22px 6px rgba(226, 112, 58, 0.3);
    pointer-events: none;
    z-index: 9999;
    transform: translate(-50%, -50%);
    transition: width 0.2s ease, height 0.2s ease;
  }

  #cursor-dot.is-hover {
    width: 22px;
    height: 22px;
  }
}

/* Site scrollbar: liquid-glass pill (WebKit/Blink; Firefox and any browser
   honoring the standard scrollbar-color/width fall back to the html rule) */
html,
body {
  scrollbar-width: auto;
  scrollbar-color: var(--scrollbar-thumb-flat) var(--scrollbar-track);
}

::-webkit-scrollbar {
  width: 14px;
}

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

::-webkit-scrollbar-thumb {
  background: linear-gradient(160deg, var(--scrollbar-thumb-from), var(--scrollbar-thumb-to));
  background-clip: padding-box;
  border: 3px solid transparent;
  border-radius: 999px;
  box-shadow: inset 0 1px 0 var(--glass-highlight);
}

::-webkit-scrollbar-thumb:hover {
  background: linear-gradient(160deg, var(--scrollbar-thumb-hover-from), var(--scrollbar-thumb-hover-to));
  background-clip: padding-box;
}

h1,
h2 {
  font-family: "IBM Plex Serif", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
  font-weight: 500;
  letter-spacing: -0.01em;
  line-height: 1.15;
  margin: 0 0 12px;
}

a {
  color: inherit;
}

.page {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: flex-start;
  max-width: 1400px;
  margin: 0 auto;
}

/* Section nav (Home/Work), merged into the top floating bar; highlights
   the section currently in view. Wrapping the brand avatar + links means
   hover only triggers over this pair, not the whole pill — the avatar
   itself doubles as the "hover reveals navigation" hint. */
.nav-trigger {
  display: flex;
  align-items: center;
  gap: 0;
  /* Cancels out the island's own gap after this item while it has nothing
     visible in it (bio section, avatar not flown in yet, not hovered) —
     otherwise that gap would show up as dead space at the island's start. */
  margin-right: calc(-1 * var(--island-gap));
  transition: gap 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
    margin-right 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* No internal gap here even once the avatar is shown: Home/Works stay
   at max-width:0 until hover, so any gap here would just be dead space
   between the avatar and the divider — the island's own gap (below)
   already provides the trailing space, keeping every group in the bar
   equidistant. */
.sidebar.show-brand-avatar .nav-trigger {
  gap: 0;
}

/* Hovering shows Home/Works flush at the trigger's start (see the avatar
   hide rule below), so the gap that made room for the avatar collapses
   too — otherwise a stray gap would linger where the avatar just was. */
.sidebar.show-brand-avatar .nav-trigger:hover,
.sidebar.show-brand-avatar .nav-trigger:focus-within {
  gap: 0;
}

.sidebar.show-brand-avatar .nav-trigger,
.nav-trigger:hover,
.nav-trigger:focus-within {
  margin-right: 0;
}

/* Wraps everything in the island except the hamburger. On desktop this
   is completely invisible to layout — its children behave exactly as if
   they were direct children of .sidebar, same as before. On mobile (see
   the media query) it becomes the vertical dropdown panel itself. */
.sidebar-panel {
  display: contents;
}

/* Groups the theme/lang pills so they can be placed side by side on mobile
   (see the 860px media query) without disturbing their desktop placement —
   display:contents here flattens it away, same trick as .sidebar-panel
   above, so on desktop the two pills remain direct flex children of
   .sidebar exactly as before. */
.switch-row {
  display: contents;
}

/* Mobile-only shortcut to GitHub/Email sitting right in the idle trigger
   (see the media query) — hidden entirely on desktop, where those links
   are already always visible in .social-list. */
.quick-links {
  display: none;
}

.section-links {
  position: relative;
  display: flex;
  align-items: center;
  gap: 4px;
  /* No padding at rest: border-box can't shrink a box below its own
     padding, so any padding here would floor this at rest to that many
     px wide even with max-width:0 — leaving dead space between the
     avatar and the divider that follows. It's added back below, only
     once this is actually expanded and needs it. */
  padding: 0;
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  transition: max-width 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease,
    padding 0.3s ease;
}

.nav-trigger:hover .section-links,
.nav-trigger:focus-within .section-links {
  /* Fits Home/Works/News (or the longer Italian "Progetti") plus the
     padding above without clipping the last link — measured ~200-206px
     rendered content, so 250px leaves headroom instead of cutting "News"
     off mid-word (or its hover pill off mid-pill). */
  max-width: 250px;
  opacity: 1;
  /* A couple px of breathing room on every side so the sliding pill (see
     script.js: setupHoverPill) isn't flush against the overflow:hidden
     edge — without it, the pill on the first/last link got a hair clipped. */
  padding: 2px 3px;
}

/* Stays hidden while the trigger itself is empty (bio section, avatar
   not flown in yet) — otherwise it's the one leftover mark showing with
   nothing anchoring it on the left. Reappears with the avatar/links. */
.nav-trigger + .divider {
  width: 0;
  opacity: 0;
  transition: width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
}

.sidebar.show-brand-avatar .nav-trigger + .divider,
.nav-trigger:hover + .divider,
.nav-trigger:focus-within + .divider {
  width: 1px;
  opacity: 1;
}

.section-link {
  position: relative;
  z-index: 1;
  padding: 6px 12px;
  border-radius: 999px;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 0.85rem;
  white-space: nowrap;
  transition: color 0.2s ease;
}

.section-link:hover {
  color: var(--text);
}

/* Icon shown only in the mobile dropdown's compact icon-only row (see the
   860px media query) — desktop keeps the plain text link as-is. */
.section-link-icon {
  display: none;
  flex-shrink: 0;
}

/* Active section: just a brighter color — the gliding pill (see
   script.js: setupHoverPill) is what actually rests on it, so there's no
   separate static highlight to keep in sync here. */
.section-link.is-active {
  color: var(--text);
}

/* In-island brand: just the avatar, always the first item in the dynamic
   island. On the homepage it stays hidden while the real hero avatar is
   on screen; once you scroll past it, a flying copy travels from the
   hero to this spot (see script.js) and this one fades in to receive it.
   On every other page (no hero avatar) it's shown from the start. */
.sidebar-brand {
  position: relative;
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.sidebar-brand:hover {
  transform: scale(1.04);
}

.sidebar-brand-avatar {
  width: 0;
  height: 28px;
  border-radius: 50%;
  object-fit: cover;
  opacity: 0;
  box-shadow: 0 0 0 1px var(--glass-border);
  /* Closing (this rule's own transition, used when show-brand-avatar is
     removed): opacity finishes well before width shrinks to a sliver, so
     it's already invisible by the time object-fit: cover would otherwise
     show a squeezed, smeared crop of the image. */
  transition: width 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
}

.sidebar.show-brand-avatar .sidebar-brand-avatar {
  width: 28px;
  opacity: 1;
  /* Opening: opacity is delayed until width has mostly sprung open, for the
     same reason in reverse -- fading it in immediately overlapped the
     handoff from the flying #avatar-flyer with the earliest (near-zero-width)
     sliver of this reveal, showing a smeared ghost of the avatar right at
     arrival. */
  transition: width 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease 0.15s;
}

/* Hovering the trigger reveals Home/Works and hides the avatar in favor
   of them — same swap the old hint icon used to do. */
.nav-trigger:hover .sidebar-brand-avatar,
.nav-trigger:focus-within .sidebar-brand-avatar {
  width: 0;
  opacity: 0;
}

/* Unread-news dot on the island avatar (see script.js: showIslandNotification
   / the News section IIFE). Decorative only — clearing happens by scrolling
   the News section into view, not by clicking the dot itself, so it stays
   aria-hidden and un-clickable; the real link target is still just "go to
   #about" like the rest of the avatar. Gated to show-brand-avatar so it
   never floats over an avatar that hasn't actually flown in/shown yet. */
.island-badge {
  position: absolute;
  top: -1px;
  right: -1px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--bg);
  transform: scale(0);
  transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.sidebar.show-brand-avatar .island-badge.is-shown {
  transform: scale(1);
  animation: island-badge-pulse 1.8s ease-in-out infinite;
}

.nav-trigger:hover .island-badge,
.nav-trigger:focus-within .island-badge {
  transform: scale(0);
}

@keyframes island-badge-pulse {
  0%,
  100% {
    box-shadow: 0 0 0 0 rgba(226, 112, 58, 0.55);
  }
  50% {
    box-shadow: 0 0 8px 2px rgba(226, 112, 58, 0.65);
  }
}

/* Briefly expands the island to announce new stories by name (see
   script.js: showIslandNotification) — same open/close mechanic as
   .section-links above (max-width + opacity), toggled by JS instead of
   hover so it can show itself unprompted, then collapse back down to
   just the .island-badge dot above. */
.island-notify-label {
  max-width: 0;
  opacity: 0;
  overflow: hidden;
  white-space: nowrap;
  font-size: 0.82rem;
  color: var(--text);
  transition: max-width 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.35s ease;
}

.nav-trigger.is-notifying .island-notify-label {
  max-width: 220px;
  opacity: 1;
}

/* The trigger's own gap stays 0 at rest (see .nav-trigger rules above) so
   the avatar doesn't leave dead space before the divider — but once the
   notify label actually has text to show next to the avatar, it needs
   its own breathing room back. */
.sidebar.show-brand-avatar .nav-trigger.is-notifying {
  gap: 8px;
}

/* Floating top bar (was a left sidebar) */
.sidebar {
  position: fixed;
  top: 18px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 100;
  width: auto;
  height: 68px;
  flex-direction: row;
  align-items: center;
  --island-gap: 18px;
  gap: var(--island-gap);
  --island-pad: 40px;
  /* Left padding starts smaller than the right — the trigger has nothing
     in it yet (bio section, avatar not flown in), so full padding there
     would just read as dead space. It matches the right side again the
     moment the trigger has something to show (see the rules below). */
  --island-pad-left: 22px;
  padding: 0 var(--island-pad) 0 var(--island-pad-left);
  border-radius: 999px;
  background: linear-gradient(160deg, var(--glass-bg-strong), var(--glass-bg));
  border: 1px solid var(--glass-border);
  backdrop-filter: blur(var(--glass-blur)) saturate(160%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(160%);
  /* Inset highlight only — no drop shadow. The island floats over content
     purely via the glass blur/border, no cast-shadow beneath it, on both
     desktop and mobile (this rule is shared by both; the 860px media query
     below never overrides box-shadow). */
  box-shadow: inset 0 1px 0 var(--glass-highlight);
  display: flex;
  transition: gap 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), padding 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
    transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Dynamic-island feel: idle bar is compact, hovering (or focusing into it)
   expands it to reveal the section links */
.sidebar:hover,
.sidebar:focus-within {
  --island-gap: 26px;
  --island-pad: 44px;
  transform: translateX(-50%) scale(1.02);
}

/* Once the trigger actually has something in it — the avatar has flown
   in, or it's being hovered directly and Home/Works are showing — its
   side matches the other one again. */
.sidebar.show-brand-avatar,
.sidebar:has(.nav-trigger:hover),
.sidebar:has(.nav-trigger:focus-within) {
  --island-pad-left: var(--island-pad);
}

.social-list {
  position: relative;
  display: flex;
  flex-direction: row;
  /* Matches the island's own between-group gap (see .sidebar) instead of
     a smaller fixed value, so GitHub/Email read as evenly spaced with the
     rest of the bar rather than crowded together. Overridden back down for
     the mobile fullscreen row below. */
  gap: var(--island-gap);
}

/* Slightly larger than the mobile-dropdown size (still governed by the
   inline width/height on each <svg>) — desktop has the room, and the
   larger glyphs read better against the wider gap above. */
@media (min-width: 861px) {
  .social-item svg {
    width: 20px;
    height: 20px;
  }
}

.social-item {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px;
  margin: -6px;
  border-radius: 10px;
  text-decoration: none;
  color: var(--text-muted);
  font-size: 0.95rem;
  transition: color 0.15s ease;
}

.social-item:hover {
  color: var(--text);
}

/* Avatar "flyer": a temporary clone that travels from the hero avatar's
   on-screen spot to its resting place in the dynamic island (see the FLIP
   transform in script.js), then fades out once the real in-island avatar
   takes over. Sits above the island itself so the flight reads clearly. */
#avatar-flyer {
  position: fixed;
  top: 0;
  left: 0;
  border-radius: 50%;
  object-fit: cover;
  z-index: 150;
  opacity: 0;
  pointer-events: none;
}

/* Liquid-glass tooltip, shared/positioned by script.js. A single real
   element (not ::after) so it can be measured and clamped to the
   viewport — no more tooltips running off-screen near either edge. */
#tooltip-bubble {
  position: fixed;
  top: 0;
  left: 0;
  padding: 6px 12px;
  border-radius: 999px;
  background: var(--bg-card);
  border: 1px solid var(--glass-border);
  backdrop-filter: blur(var(--glass-blur)) saturate(160%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(160%);
  box-shadow: inset 0 1px 0 var(--glass-highlight), 0 10px 30px rgba(0, 0, 0, 0.35);
  color: var(--text);
  font-size: 0.78rem;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transform: translateY(4px);
  transition: opacity 0.25s ease, transform 0.25s ease;
  z-index: 300;
}

#tooltip-bubble.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Avatar easter egg: a bigger, more playful, punchier tooltip */
#tooltip-bubble.is-punchy {
  font-size: 1.05rem;
  padding: 12px 22px;
  border: 2px solid var(--accent);
  transform: translateY(4px) scale(0.6);
  transition: opacity 0.3s ease, transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}

#tooltip-bubble.is-punchy.is-visible {
  transform: translateY(0) scale(1);
}

.social-item span,
.ai-item span,
.theme-btn span {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.divider {
  width: 1px;
  height: 24px;
  background: var(--glass-border);
  margin: 0;
}

.ai-item {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: 10px;
  color: var(--text-muted);
  background: none;
  cursor: pointer;
  padding: 0;
  text-decoration: none;
  transition: color 0.15s ease;
}

#ask-claude {
  color: var(--accent);
}

.ai-item:hover {
  color: var(--text);
}

.ask-ai-list {
  position: relative;
  display: flex;
  flex-direction: row;
  gap: 10px;
}

/* IT/EN language switcher and light/dark theme switcher: small glass
   pills of their own, sitting in the dynamic island next to the other
   button groups. Same pill/button shape for both — only the icon-only
   vs. text-only content differs. */
.lang-toggle,
.theme-toggle {
  position: relative;
  display: flex;
  gap: 2px;
  padding: 3px;
  border-radius: 999px;
  background: var(--glass-bg);
  border: 1px solid var(--glass-border);
  box-shadow: inset 0 1px 0 var(--glass-highlight);
  flex-shrink: 0;
}

/* Liquid-glass platter that tracks the hovered/focused button and glides
   between them (see script.js: setupHoverPill) instead of each button
   getting its own static highlight — sits behind the button labels. */
.toggle-pill {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 999px;
  background: linear-gradient(160deg, var(--glass-bg-strong), var(--glass-bg));
  border: 1px solid var(--glass-border);
  backdrop-filter: blur(var(--glass-blur)) saturate(160%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(160%);
  box-shadow: inset 0 1px 0 var(--glass-highlight);
  opacity: 0;
  pointer-events: none;
  z-index: 0;
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
    height 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease, box-shadow 0.2s ease;
}

/* While a finger is actively dragging the pill (see setupHoverPill's touch
   handling in script.js), it free-floats and must sit exactly under the
   pointer on every move — the bouncy transform/width/height transition
   above would otherwise make it lag behind the finger, so drop those and
   keep the glass's own highlight, with no separate glow. */
.toggle-pill.is-dragging {
  transition: box-shadow 0.2s ease;
  box-shadow: inset 0 1px 0 var(--glass-highlight);
}

.lang-btn,
.theme-btn {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid transparent;
  background: none;
  color: var(--text-muted);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  padding: 5px 10px;
  border-radius: 999px;
  cursor: pointer;
  transition: color 0.15s ease;
}

.theme-btn {
  padding: 6px;
}

.lang-btn:hover,
.theme-btn:hover {
  color: var(--text);
}

.lang-btn.is-active,
.theme-btn.is-active {
  color: var(--accent);
}

.theme-btn svg {
  flex-shrink: 0;
}

/* Hamburger toggle: mobile-only affordance that opens the floating bar
   (see the @media block below); irrelevant on desktop, where hover already
   reveals everything, so it stays hidden there. */
.nav-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  color: var(--text-muted);
  cursor: pointer;
}

.nav-toggle .bar {
  transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
  transform-origin: 9px 9px;
}

/* Mobile-only docking spot for the flying hero avatar (see the 860px
   media query below and script.js) — irrelevant on desktop, where the
   avatar docks in .sidebar-brand-avatar inside the island itself. */
.mobile-brand-avatar {
  display: none;
}

/* Main content */
.content {
  flex: 1;
  min-width: 0;
  padding: 116px 32px 96px;
}

.intro {
  display: grid;
  grid-template-columns: 72px 1fr;
  column-gap: 18px;
  row-gap: 6px;
  max-width: 720px;
  margin: 0 auto;
  text-align: left;
}

.intro-heading {
  grid-column: 2;
  grid-row: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
}

.rotating-text {
  display: inline-block;
  transition: color 0.2s ease;
}

.rotating-text::after {
  content: "";
  display: inline-block;
  width: 2px;
  height: 0.9em;
  margin-left: 3px;
  background: currentColor;
  vertical-align: -0.1em;
  animation: caret-blink 1s steps(1) infinite;
}

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

.intro-text {
  grid-column: 2;
  grid-row: 2;
}

/* Easter egg: wiggle + a little tooltip on hover, and a fidget-spinner
   drag (see script.js) — grab it and give it a flick. */
.avatar-wrap {
  grid-column: 1;
  grid-row: 1 / span 2;
  align-self: start;
  display: inline-block;
  line-height: 0;
  cursor: grab;
  touch-action: none;
}

.avatar-wrap.is-spinning {
  cursor: grabbing;
}

.avatar {
  display: block;
  border-radius: 50%;
  flex-shrink: 0;
  transition: opacity 0.2s ease;
}

/* Hidden for as long as the avatar's flying/docked copy is elsewhere (see
   the flyToIsland/flyFromIsland pair in script.js) so it doesn't sit here
   visible in the bio while a copy of it is also mid-flight or parked in
   the dynamic island — it fades back in only once the flyer lands. */
.avatar.is-away {
  opacity: 0;
}

.avatar-wrap:hover .avatar {
  animation: avatar-wiggle 0.6s ease;
}

@keyframes avatar-wiggle {
  0%, 100% {
    transform: rotate(0deg) scale(1);
  }
  20% {
    transform: rotate(-10deg) scale(1.06);
  }
  40% {
    transform: rotate(9deg) scale(1.06);
  }
  60% {
    transform: rotate(-6deg) scale(1.03);
  }
  80% {
    transform: rotate(4deg) scale(1.02);
  }
}

.intro-heading h1 {
  font-size: 22px;
  margin: 0;
  font-family: "IBM Plex Serif", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
}

/* Subtitle sits directly under the title, on its own single line — the
   rotating word stays inline with "I'm" instead of wrapping onto a new
   one, only it carries its own accent color. */
.intro-subtitle {
  font-family: "IBM Plex Serif", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
  font-weight: 500;
  font-size: 20px;
  line-height: 1.3;
  min-height: 1.3em;
  margin: 0;
  color: var(--text);
  white-space: nowrap;
}

.name-accent {
  color: var(--accent);
}

.intro-text p {
  color: var(--text-muted);
  line-height: 1.75;
  font-size: 1rem;
  margin: 0 0 20px;
}

.facts {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.facts li {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--text-muted);
  font-size: 0.95rem;
}

.flag-it {
  vertical-align: -2px;
  margin-left: 6px;
  border-radius: 2px;
  box-shadow: 0 0 0 1px var(--glass-border);
}

.facts li svg {
  flex-shrink: 0;
  color: var(--text-faint);
}

/* Certifications: the extra courses (accounting/typing) stay hidden behind
   a small chevron by default, so the row reads as short as the others —
   click reveals them as their own lines underneath, stacked vertically
   rather than run into the summary line. align-items: flex-start
   (overriding .facts li's default center) because the icon needs to sit at
   the summary line's height once that extra list appears below it. */
.facts li.fact-certs {
  align-items: flex-start;
}

.fact-certs > svg {
  margin-top: 2px;
}

/* flex: 1 so this stretches across the row's full remaining width (next to
   the leading icon) — otherwise it would only be as wide as its own text,
   and margin-left: auto on the toggle below would have no room to push it
   over to the row's right edge. */
.fact-certs-body {
  flex: 1;
  min-width: 0;
}

.fact-certs-summary {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: nowrap;
}

/* Liquid-glass chip (same tokens as the rest of the site's glass surfaces)
   instead of a bare icon, so it reads as a proper tappable control.
   margin-left: auto docks it at the row's right edge instead of sitting
   right after the text. */
.fact-certs-toggle {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 0;
  margin-left: auto;
  border-radius: 50%;
  background: var(--glass-bg);
  border: 1px solid var(--glass-border);
  box-shadow: inset 0 1px 0 var(--glass-highlight);
  backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  color: var(--accent);
  cursor: pointer;
}

.fact-certs-toggle:hover {
  background: var(--glass-bg-strong);
}

.fact-certs-toggle svg {
  transition: transform 0.25s ease;
}

.fact-certs-toggle[aria-expanded="true"] svg {
  transform: rotate(180deg);
}

.fact-certs-more[hidden] {
  display: none;
}

/* Each entry here is its own separate certification, but packed together
   as plain text with no marker and only a 4px gap, they used to read as
   one run-on line. list-style/::marker don't render on flex children (the
   list is a flex column for the vertical stacking), so the bullet is a
   manual ::before instead of relying on that. */
.fact-certs-more {
  list-style: none;
  margin: 8px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: 0.88rem;
  color: var(--text-muted);
}

.fact-certs-more li {
  display: flex;
  align-items: baseline;
  gap: 8px;
}

.fact-certs-more li::before {
  content: "•";
  color: var(--text-faint);
}

.facts a {
  text-decoration: underline;
  color: var(--text-muted);
}

/* Work section */
.work {
  margin: 96px auto 0;
  max-width: 720px;
}

.work h2 {
  font-size: 1.7rem;
  max-width: 720px;
  text-align: left;
}

.work-sub {
  color: var(--text-muted);
  margin: 0 0 32px;
  max-width: 720px;
  text-align: left;
}

footer.work-sub {
  position: relative;
  z-index: 1;
  max-width: none;
  margin: -70px 0 0;
  text-align: center;
}

footer.work-sub p {
  font-size: 0.78rem;
  color: var(--accent);
}

/* News section: cards are refreshed daily by scripts/fetch-school-news.mjs
   (via .github/workflows/news-sync.yml) into school-news.json, and rendered
   client-side by script.js — nothing here is server-rendered. Glass
   surfaces per the site's liquid-glass convention for new UI elements. */
.news {
  margin: 96px auto 0;
  max-width: 720px;
}

.news-heading {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 12px;
}

.news-heading h2 {
  margin: 0;
  font-size: 1.7rem;
}

/* "Last updated" timestamp, right next to the "News" heading (small gap
   above, plus baseline alignment so it sits on the heading's text line
   instead of floating at its vertical center). Reflects the feed's own
   generatedAt (see script.js). */
.news-updated {
  color: var(--text-faint);
  font-size: 0.78rem;
}

.news-heading-actions {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 10px;
}

/* Password gate shown to everyone but Diego, asked again on every visit
   (see script.js). Same glass-panel treatment as the rest of the site's
   cards/modals. */
.news-locked {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 12px;
  max-width: 380px;
  margin: 8px auto 20px;
  padding: 22px 24px;
  border-radius: 14px;
  background: var(--glass-bg);
  border: 1px solid var(--glass-border);
  backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
  box-shadow: inset 0 1px 0 var(--glass-highlight);
}

/* [hidden] alone would be enough, but it's only as specific as this class
   rule — since author styles win cascade ties against the UA stylesheet,
   the explicit "display: flex" above would otherwise keep this visible
   even with the hidden attribute set (see script.js: reveal()). */
.news-locked[hidden] {
  display: none;
}

.news-locked-text {
  margin: 0;
  color: var(--text-muted);
}

.news-unlock-form {
  display: flex;
  gap: 8px;
  width: 100%;
}

.news-unlock-input {
  flex: 1;
  min-width: 0;
  padding: 9px 16px;
  border-radius: 999px;
  border: 1px solid var(--glass-border);
  background: var(--glass-bg-strong);
  color: var(--text);
  font-size: 0.9rem;
}

.news-unlock-input:focus {
  outline: none;
  border-color: var(--accent);
}

@keyframes news-unlock-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-6px); }
  40% { transform: translateX(5px); }
  60% { transform: translateX(-4px); }
  80% { transform: translateX(3px); }
}

.news-unlock-input.is-shaking {
  animation: news-unlock-shake 0.4s ease;
  border-color: #e5484d;
}

.news-unlock-error {
  margin: 0;
  color: #e5484d;
  font-size: 0.85rem;
}

/* Card-stack vs. plain-list view switch — icon-only, same glass pill shape
   as the theme toggle. */
.news-view-toggle {
  position: relative;
  display: flex;
  gap: 2px;
  padding: 3px;
  border-radius: 999px;
  background: var(--glass-bg);
  border: 1px solid var(--glass-border);
  box-shadow: inset 0 1px 0 var(--glass-highlight);
  flex-shrink: 0;
}

.news-view-btn {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid transparent;
  background: none;
  color: var(--text-muted);
  padding: 6px;
  border-radius: 999px;
  cursor: pointer;
  transition: color 0.15s ease;
}

.news-view-btn:hover {
  color: var(--text);
}

.news-view-btn.is-active {
  color: var(--accent);
}

.news-sub {
  color: var(--text-muted);
  margin: 0 0 8px;
  max-width: 720px;
  text-align: left;
}

/* Card stack (Apple Wallet/Tinder-style): only the front card sits in the
   flow, everything else is absolutely positioned behind it and offset with
   a translate + scale per depth (see [data-slot] below). script.js reshuffles
   which card is front on Prev/Next — the depth transition is what reads as
   "flip to the next card". Caps the section to a handful of stories (see
   CARD_COUNT in script.js) rather than an ever-scrolling feed. */
.news-stack {
  position: relative;
  width: 100%;
  max-width: 360px; /* a bit wider than the Works section's 310px cards */
  margin: 8px auto 20px;
  min-height: 230px;
  /* Matches the card transform's duration/easing (see .news-item below) so
     the container's height eases in step with the cards swapping depth
     instead of snapping to the new front card's height instantly — that
     mismatch was what read as the stack "jumping" on Prev/Next. */
  transition: height 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}

.news-item {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  /* Same flat surface as the Works cards (see .card) — not glass, so the
     two sections' panels read as identical backgrounds. */
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 20px 22px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow: hidden;
  /* A floor, not a cap — long titles (never truncated, see .news-item-title)
     still grow the card taller if needed. --news-card-height is the tallest
     card the *current, full* dataset actually needs (measured in script.js:
     measureMaxCardHeight), so every card in list view's scrolling row ends
     up the same height instead of a ragged line. */
  min-height: var(--news-card-height, 200px);
  transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s ease;
}

/* Depth in the deck, relative to whichever card is currently "read" (slot 0).
   Only the front card is interactive — the ones peeking behind it are
   preview-only until Prev/Next (or a dot) brings them forward. Its hover
   effect matches the Works cards exactly (see .cards .card:hover) — same
   scale, same bouncy easing — so the two sections feel identical. */
.news-item[data-slot="0"] {
  transform: translateY(0) scale(1);
  opacity: 1;
  z-index: 3;
}

.news-item[data-slot="0"]:hover {
  transform: translateY(0) scale(1.06);
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.news-item[data-slot="1"] {
  transform: translateY(14px) scale(0.96);
  opacity: 0.7;
  z-index: 2;
  pointer-events: none;
}

.news-item[data-slot="2"] {
  transform: translateY(26px) scale(0.92);
  opacity: 0.4;
  z-index: 1;
  pointer-events: none;
}

.news-item[data-slot="rest"] {
  transform: translateY(26px) scale(0.9);
  opacity: 0;
  z-index: 0;
  pointer-events: none;
}

/* List view: identical horizontal-scroll treatment to the Works section's
   .cards row (see script.js: setupScrollArrows, reused here on the same
   #news-list element) instead of the depth stack — breaks out to the full
   viewport width, scrolled by drag/wheel/the prev-next arrows. Cards here
   never get a [data-slot], so only this override is needed; it wins over
   the base .news-item rule on specificity alone. */
.news-stack.is-list {
  display: flex;
  gap: 20px;
  position: relative;
  left: 50%;
  width: 100vw;
  max-width: none; /* overrides the base .news-stack's 360px cap */
  transform: translateX(-50%);
  overflow-x: auto;
  /* Extra vertical padding vs. .cards' 8px — the hover-scale below needs
     headroom so a magnified card doesn't get clipped by the row's box. */
  padding: 20px 48px;
  mask-image: linear-gradient(to right, transparent, black 56px, black calc(100% - 56px), transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, black 56px, black calc(100% - 56px), transparent);
  scrollbar-width: none;
  height: auto !important;
  min-height: 0;
}

.news-stack.is-list::-webkit-scrollbar {
  display: none;
}

.news-stack.is-list .news-item {
  position: static;
  flex: 0 0 310px; /* same card width as the Works section (see .cards .card) */
}

/* Identical to .cards .card:hover on Works — same scale, same z-index
   boost — so list view's hover feels exactly like the Works row. (Once
   suspected as the cause of a site-wide stutter that turned out to be the
   custom cursor dot instead — see the disabled @media (pointer: fine)
   block below and script.js.) */
.news-stack.is-list .news-item:hover {
  transform: scale(1.06);
  z-index: 1;
}

/* Wraps the actual story cards inside #news-list (see script.js) so
   render() can wipe/rebuild just this on every re-render without also
   destroying the prev/next scroll-arrow buttons, which are appended
   directly to #news-list itself. display:contents keeps it invisible to
   layout — .news-item children still act as direct flex children of
   .news-stack, exactly as if this wrapper weren't there. */
.news-items {
  display: contents;
}

/* Read/unread marker — a colored left edge, not a hide-it vote (see
   script.js: readStatusButton). Comes after the [data-slot] rules so it
   wins regardless of the card's depth. */
.news-item.is-read {
  border-left: 4px solid #22c55e;
}

.news-item.is-unread-marked {
  border-left: 4px solid #ef4444;
}

.news-item.is-skeleton {
  background: linear-gradient(90deg, var(--glass-bg) 25%, var(--glass-bg-strong) 50%, var(--glass-bg) 75%);
  background-size: 200% 100%;
  animation: news-shimmer 1.6s linear infinite;
  min-height: 210px;
}

.news-item-category {
  align-self: flex-start;
  font-size: 0.7rem;
  color: var(--text-faint);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 2px 10px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.news-item-title {
  margin: 0;
  font-size: 1rem;
  line-height: 1.35;
}

.news-item-title a {
  color: var(--text);
  text-decoration: none;
}

.news-item-title a:hover {
  text-decoration: underline;
}

.news-item-actions {
  display: flex;
  gap: 8px;
  /* Pushes the Read/Unread buttons to the bottom of the card regardless of
     how tall the title/meta above them ends up (titles are no longer
     clamped — see .news-item-title), so they line up across a row of
     cards with different amounts of text. */
  margin-top: auto;
  padding-top: 2px;
}

.news-read-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 999px;
  border: 1px solid var(--glass-border);
  background: var(--glass-bg);
  color: var(--text-muted);
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

.news-read-btn:hover {
  background: var(--glass-bg-strong);
  color: var(--text);
}

.news-read-btn[data-read="read"].is-active {
  color: #4ade80;
  border-color: rgba(74, 222, 128, 0.5);
  background: rgba(74, 222, 128, 0.14);
}

.news-read-btn[data-read="unread"].is-active {
  color: #f87171;
  border-color: rgba(248, 113, 113, 0.5);
  background: rgba(248, 113, 113, 0.14);
}

.news-empty,
.news-error {
  color: var(--text-muted);
  font-size: 0.9rem;
  text-align: center;
  padding: 28px 0;
}

@keyframes news-shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

@media (max-width: 860px) {
  .news-heading {
    flex-wrap: wrap;
  }

  .news-item {
    padding: 12px 14px;
  }
}

/* Cards break out to the full viewport width (regardless of any ancestor's
   max-width). Scrolled by hand — drag, swipe, wheel, or the prev/next
   arrows below — no visible scrollbar (see below), the fade mask at each
   edge plus those arrows are the only position/affordance cues. */
.cards {
  display: flex;
  gap: 20px;
  position: relative;
  left: 50%;
  width: 100vw;
  transform: translateX(-50%);
  overflow-x: auto;
  /* Extra vertical padding vs. the old 8px — the hover-scale below needs
     headroom so a magnified card doesn't get clipped by the row's box. */
  padding: 20px 48px;
  mask-image: linear-gradient(to right, transparent, black 56px, black calc(100% - 56px), transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, black 56px, black calc(100% - 56px), transparent);
  scrollbar-width: none;
}

.cards::-webkit-scrollbar {
  display: none;
}

.cards .card {
  flex: 0 0 310px;
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Re-enabled on Works only (see .news-stack.is-list .news-item:hover
   above, still disabled, for the original suspicion) — the real cause
   turned out to be the custom cursor dot (see style.css/script.js's
   disabled @media (pointer: fine) block), so this was cleared. Kept off
   for News for now since that wasn't asked back yet. */
.cards .card:hover {
  transform: scale(1.06);
  z-index: 1;
}

/* Prev/next scroll hint for a horizontally-scrolling row — injected by
   setupScrollArrows() in script.js, shared by the Works cards row and the
   News list view. Sticky within the row's own scrollport so it stays put
   at the edge as you scroll, past the fade mask's transparent zone (mask
   reaches full opacity ~56px in — see .cards/.news-stack.is-list) so it
   never renders faded out. Hidden (not just dimmed) once there's nothing
   further in that direction, via .is-hidden toggled from script.js. */
.row-scroll-arrow {
  position: sticky;
  align-self: center;
  flex: 0 0 auto;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--glass-border);
  background: var(--glass-bg-strong);
  backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.4);
  box-shadow: inset 0 1px 0 var(--glass-highlight), 0 4px 14px rgba(0, 0, 0, 0.3);
  color: var(--text);
  padding: 0;
  opacity: 0.85;
  transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease;
}

.row-scroll-arrow:hover {
  opacity: 1;
  transform: scale(1.08);
}

.row-scroll-arrow.is-hidden {
  opacity: 0;
  pointer-events: none;
}

.row-scroll-arrow--prev {
  left: 12px;
}

.row-scroll-arrow--next {
  right: 12px;
}

.cards .card-preview {
  width: 72px;
  height: 72px;
  border-radius: 50%;
  overflow: hidden;
  margin: 14px auto 0;
}

.cards .card-preview .preview-art {
  max-width: 62%;
}

.card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 14px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  position: relative; /* containing block for the stretched-link overlay below */
  cursor: pointer;
}

/* Makes the whole tile open the project subpage, not just the title text
   — the title link (always projects/<slug>.html, unlike the preview link
   below which sometimes points straight to a live demo instead) stretches
   over the entire card via ::after. .card-preview and .card-links are
   lifted above it with z-index so they keep opening their own distinct
   destinations (preview art / live demo / GitHub) instead of being
   swallowed by the overlay. */
.card-body h3 a::after {
  content: "";
  position: absolute;
  inset: 0;
}

.card-preview {
  height: 110px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  z-index: 1;
  cursor: pointer;
  transition: filter 0.15s ease;
}

.card-preview:hover {
  filter: brightness(1.12);
}

.preview-tbdm {
  background: linear-gradient(135deg, #22c37d 0%, #0a6b4f 100%);
}

.preview-dfr {
  background: linear-gradient(135deg, #ff9a3d 0%, #e0417a 100%);
}

.preview-personalizedmenu {
  background: linear-gradient(135deg, #c026d3 0%, #f59e0b 100%);
}

.preview-machinelearningexam {
  background: linear-gradient(135deg, #06b6d4 0%, #4338ca 100%);
}

.preview-appweb2024 {
  background: linear-gradient(135deg, #ef4444 0%, #f97316 100%);
}

.preview-idsproject {
  background: linear-gradient(135deg, #29c5e6 0%, #4338ca 100%);
}

.preview-armuseum {
  background: linear-gradient(135deg, #ef4444 0%, #f97316 100%);
}

.preview-evox {
  background: linear-gradient(135deg, #10b981 0%, #0e7490 100%);
}

.preview-ianus {
  background: linear-gradient(135deg, #d946ef 0%, #4f46e5 100%);
}

.preview-art {
  max-width: 80%;
  height: auto;
}

/* README-sourced cover photo, used instead of the generated line-art icon
   when a project's GitHub README has its own cover image. */
.preview-photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The EVO-X cover art has its title lettering placed in the lower half of
   the frame, not centered — the default center crop clipped it on the
   short, wide project-hero banner. */
.preview-evox .preview-photo {
  object-position: center 70%;
}

/* Shared treatment for "app icon" cover images — a square app/executable
   icon (rounded-square, transparent corners) rather than a wide cover
   photo, used by PairBeam, EasiestShutdown, and any repo generate-project.mjs
   auto-detects an icon file for (see findRepoIconFile). A flat dark
   background close to how these icons themselves are usually shaded, not a
   gradient — a gradient can't line up pixel-for-pixel with an icon's own
   baked-in shading, so it'd still show as a visible seam.
   On the project page's wide hero, the icon is padded/contained so the
   whole thing stays visible instead of being cropped to a thin strip. On
   the round card thumbnail there's no room for that letterboxing, so it's
   cover-cropped edge-to-edge instead — the circular mask crops the icon's
   square corners away entirely, leaving a clean circular crop rather than
   a square visibly floating on the background. */
.icon-cover {
  background: #16161a;
}

.icon-cover .preview-photo {
  object-fit: contain;
}

.project-hero.icon-cover .preview-photo {
  padding: 28px;
}

.card-preview.icon-cover .preview-photo {
  object-fit: cover;
}

.card-preview.has-photo {
  overflow: hidden;
}

.card-body {
  padding: 10px 14px 12px;
  display: flex;
  flex-direction: column;
  flex: 1;
}

.card-body h3 {
  margin: 0 0 8px;
  font-size: 1.05rem;
}

.card-body h3 a {
  color: var(--text);
  text-decoration: none;
}

.card-body h3 a:hover {
  text-decoration: underline;
}

.card-body p {
  margin: 0 0 12px;
  color: var(--text-muted);
  font-size: 0.85rem;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  line-clamp: 2;
  overflow: hidden;
  text-overflow: ellipsis;
}

.card-tags {
  display: flex;
  gap: 8px;
  margin-bottom: 10px;
}

.card-tags span {
  font-size: 0.75rem;
  color: var(--text-faint);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 2px 10px;
}

.card-links {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: auto;
  padding-top: 10px;
  position: relative;
  z-index: 1;
}

/* Project detail pages */
.project-page {
  max-width: 720px;
  margin: 0 auto;
}

.project-hero {
  height: 200px;
  border-radius: 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 28px;
  overflow: hidden;
}

.project-page h1 {
  font-size: 1.9rem;
}

.project-meta {
  color: var(--text-faint);
  font-size: 0.85rem;
  margin: 0 0 24px;
}

.project-page > p {
  color: var(--text-muted);
  line-height: 1.65;
  margin: 0 0 20px;
}

.project-page h2 {
  font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  font-size: 1.15rem;
  margin: 32px 0 12px;
}

.project-page ul {
  margin: 0 0 20px;
  padding-left: 20px;
  color: var(--text-muted);
  line-height: 1.7;
}

.project-page ul li strong {
  color: var(--text);
}

.project-links {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 32px;
  padding-top: 24px;
  border-top: 1px solid var(--border);
}

/* Liquid glass buttons: shared by every "Official site" / "Releases" /
   GitHub action link across cards and project pages */
.btn-glass,
.btn-github {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  border-radius: 999px;
  background: var(--glass-bg);
  border: 1px solid var(--glass-border);
  backdrop-filter: blur(var(--glass-blur)) saturate(1.6);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.6);
  box-shadow: inset 0 1px 0 var(--glass-highlight);
  text-decoration: none !important;
  color: var(--text);
  font-size: 0.9rem;
  transition: background 0.15s ease;
}

.btn-glass:hover,
.btn-github:hover {
  background: var(--glass-bg-strong);
}

.btn-github svg {
  flex-shrink: 0;
}

/* Works cards: icon-only action buttons — the text label is still there
   for screen readers (visually-hidden, same technique as .social-item
   span), just not shown, so the card footer stays compact */
.card-links .btn-glass,
.card-links .btn-github {
  position: relative;
  padding: 8px;
  font-size: 0.8rem;
  gap: 0;
}

.card-links .btn-glass span,
.card-links .btn-github span {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.card-links .btn-github svg {
  width: 15px;
  height: 15px;
}

/* Responsive */
@media (max-width: 860px) {
  /* align-items:flex-start (base rule) means .page's flex child would
     otherwise shrink-to-fit in column mode instead of filling the width,
     which let .content (and its 100vw-breakout .cards) overflow the
     viewport horizontally — stretch it back to full width on mobile. */
  .page {
    flex-direction: column;
    align-items: stretch;
  }

  /* Touch already scrolls these rows directly (swipe/drag) — the desktop
     prev/next affordance (see .row-scroll-arrow, setupScrollArrows()) is
     redundant here and just sits on top of the cards. Placed in this
     (later) mobile block on purpose: the base .row-scroll-arrow rule
     that sets display:flex sits between the two @media(max-width:860px)
     blocks in this file, so an override in the earlier block would lose
     the cascade to it despite matching. */
  .row-scroll-arrow {
    display: none;
  }

  /* Single source of truth for the idle bar's height and its top/edge
     offsets — the open panel's rows, and the two independent fixed buttons
     flanking it (.mobile-brand-avatar, .nav-toggle), all read these same
     variables so they're always exactly as tall/offset as each other, never
     something coincidentally close. Declared on :root (rather than
     .sidebar) so the two independent buttons can read them too — they
     deliberately live outside .sidebar (see their own rules below). */
  :root {
    /* 44px matches Safari's own bottom toolbar buttons (back/reload/tabs) —
       also Apple's HIG minimum tap target — so the island, avatar and
       hamburger read at the same scale as the browser chrome around them
       instead of visibly outsizing it. */
    --mobile-pill-h: 44px;
    /* 34px — matches Safari's own back/tabs toolbar buttons, measured at
       ~34.5pt from the screen edge, so the avatar's left margin lines up
       with the back button below it and the hamburger's right margin lines
       up with the tabs button, instead of sitting roughly twice as close to
       the edge as Safari's own chrome. */
    --mobile-edge-gap: 34px;
    --mobile-top-gap: 12px;
  }

  /* Idle, the island is a small pill holding just the GitHub/Email
     shortcuts — denser, more neutral-gray glass than the desktop version
     (see --mobile-chrome-* above), matching Safari's own toolbar chrome
     since it now sits right alongside it over page content most of the
     time. The hamburger (.nav-toggle) and the avatar (.mobile-brand-avatar)
     are their own separate fixed buttons flanking it left/right, not part
     of this pill — see both further below. Tapping the hamburger springs
     open a fullscreen panel (.sidebar-panel), not a sideways-expanding
     pill. */
  .sidebar {
    top: calc(var(--mobile-top-gap) + env(safe-area-inset-top));
    /* Centered via left/right + auto margins instead of the desktop
       left:50%/transform:translateX(-50%) trick — deliberately so .sidebar
       never carries a transform on mobile, which would otherwise hijack the
       containing block for .sidebar-panel's fullscreen position:fixed
       below (any transformed ancestor becomes that containing block, per
       spec) every time this pill gains :focus-within (e.g. a button inside
       the open panel gets focus), not just on the very first tap. */
    left: 0;
    right: 0;
    margin-inline: auto;
    transform: none;
    /* Explicit, not auto — deterministic and easy to reason about. 78px =
       3px+3px padding + 72px of two 34px quick-link icons (one 4px gap
       between them). */
    width: 78px;
    height: var(--mobile-pill-h);
    padding: 0 3px;
    justify-content: center;
    overflow: visible;
    background: linear-gradient(160deg, var(--mobile-chrome-from), var(--mobile-chrome-to));
    border-color: var(--mobile-chrome-border);
  }

  /* Desktop's hover-preview bounce (.sidebar:hover/:focus-within ->
     scale(1.02), see above) is a hover-reveal affordance that doesn't apply
     here — flattened to nothing so it can never reintroduce the transform
     the comment above explains why .sidebar must avoid on mobile. */
  .sidebar:hover,
  .sidebar:focus-within {
    transform: none;
  }

  .quick-link {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border-radius: 999px;
    color: var(--mobile-chrome-fg);
    text-decoration: none;
    transition: color 0.15s ease, background 0.2s ease;
  }

  /* 18px matches the icon size used everywhere else on the site
     (.section-link-icon, .nav-toggle-bars) and Safari's own toolbar glyphs,
     rather than the larger 20px this used to run at. */
  .quick-link svg {
    width: 18px;
    height: 18px;
  }

  .quick-link:active {
    color: var(--text);
    background: var(--glass-bg-strong);
  }

  .quick-links {
    display: flex;
    gap: 4px;
  }

  /* GitHub/Email shortcuts are only useful on the idle pill — once the
     fullscreen panel is open the same links already live in .social-list
     inside it, so hide the duplicates: leaves less visual noise behind the
     open panel and keeps them out of the tab order while invisible. */
  .sidebar.is-open .quick-links {
    display: none;
  }

  /* backdrop-filter (like transform) establishes a containing block for any
     position:fixed descendant, per spec — .sidebar-panel below relies on
     resolving position:fixed against the *viewport*, not .sidebar's own
     tiny pill box, to actually go fullscreen. .sidebar's own backdrop-filter
     is otherwise needed for the idle pill's glass look, but while the
     fullscreen panel is up the idle pill has nothing left showing anyway
     (.quick-links just hidden above) and sits behind the now-opaque
     fullscreen panel — so dropping it for that whole stretch is invisible
     in practice. Driven by .panel-active rather than .is-open directly (see
     script.js's setOpen): .is-open flips off the instant a close starts, but
     .sidebar-panel's own fade-out transition still needs a further ~0.28s
     with its fixed positioning resolved against the viewport — if
     backdrop-filter came back that instantly, .sidebar would re-trap it
     back onto the tiny idle-pill box mid-fade and the close would just
     snap invisibly instead of animating. .panel-active instead stays on
     .sidebar for that whole close transition, only dropped once
     .sidebar-panel's own transitionend fires. */
  .sidebar.panel-active {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* Hamburger: its own fixed glass button, mirroring .mobile-brand-avatar
     on the opposite edge — same top offset and size, so the two read as a
     matched pair with the idle pill sitting equidistant between them (see
     .mobile-brand-avatar's own comment below for why both live outside
     .sidebar in the DOM). Tapping it opens .sidebar-panel fullscreen (see
     below); the bars draw together into two stacked lines via .is-open and
     back apart via the same transition on close (see the base .nav-toggle
     .bar rule above) — deliberately not an X, so the icon still reads as
     "menu" (collapsed), not "error/cancel". Uses the same --mobile-chrome-*
     tokens as the idle island pill (see its own comment above), so this and
     the pill it flanks read as one matched pair — same airy glass as the
     rest of the site, not a separate denser chrome. */
  .nav-toggle {
    display: flex;
    position: fixed;
    top: calc(var(--mobile-top-gap) + env(safe-area-inset-top));
    right: var(--mobile-edge-gap);
    width: var(--mobile-pill-h);
    height: var(--mobile-pill-h);
    align-items: center;
    justify-content: center;
    border-radius: 999px;
    background: linear-gradient(160deg, var(--mobile-chrome-from), var(--mobile-chrome-to));
    border: 1px solid var(--mobile-chrome-border);
    color: var(--mobile-chrome-fg);
    backdrop-filter: blur(var(--glass-blur)) saturate(160%);
    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(160%);
    box-shadow: inset 0 1px 0 var(--glass-highlight);
    /* Above .sidebar-panel's own z-index (190) so it stays usable as the
       fullscreen panel's close affordance for as long as it's open. */
    z-index: 200;
    transition: transform 0.15s ease;
  }

  .nav-toggle:active {
    transform: scale(0.92);
  }

  /* Converge into two parallel horizontal lines (not a full X — see the
     comment on .nav-toggle above) — top and bottom bars each move halfway
     toward the now-hidden middle bar's row instead of meeting it, so they
     land a few px apart rather than merging into one line. */
  .nav-toggle.is-open .bar-top {
    transform: translate(0, 2px);
  }

  .nav-toggle.is-open .bar-mid {
    opacity: 0;
  }

  .nav-toggle.is-open .bar-bot {
    transform: translate(0, -2px);
  }

  /* Desktop's in-island avatar slot has no equivalent on mobile — the
     avatar docks in .mobile-brand-avatar below instead, so this stays
     hidden and the island pill is just the quick-link shortcuts. */
  .sidebar-brand {
    display: none;
  }

  /* Mobile-only docking spot for the flying hero avatar (see script.js):
     fixed to the top-left corner, independent of the island pill so the
     hamburger bars never have to make room for it — same height as the
     idle island and the hamburger (--mobile-pill-h) so all three read as
     a matched trio, the avatar image itself filling the box edge-to-edge
     rather than sitting glass-ringed and shrunk inside it (no
     background/border/backdrop-filter here — just the circular image, see
     .mobile-brand-avatar-img below). Hidden until .show-brand-avatar
     lands — same trigger as desktop's .sidebar-brand-avatar, matched here
     via a sibling selector since this button lives outside .sidebar (its
     own position: fixed would otherwise be captured by .sidebar's
     transform, which turns it into the containing block for fixed
     descendants). Tapping it scrolls straight to the top of the page —
     not just to #about, which (thanks to the fixed island sitting over
     it) would otherwise land a little short of the very top. */
  .mobile-brand-avatar {
    display: flex;
    position: fixed;
    top: calc(var(--mobile-top-gap) + env(safe-area-inset-top));
    left: var(--mobile-edge-gap);
    width: var(--mobile-pill-h);
    height: var(--mobile-pill-h);
    align-items: center;
    justify-content: center;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease, transform 0.15s ease;
    z-index: 100;
  }

  .sidebar.show-brand-avatar ~ .mobile-brand-avatar {
    opacity: 1;
    pointer-events: auto;
  }

  /* The fullscreen panel opening covers the avatar anyway (it sits above
     it, z-index 190 vs 100) — this just keeps it out of the tab order too
     while it's invisible underneath, same reasoning as .quick-links above.
     Same specificity as the show-brand-avatar rule above, so source order
     (this comes after) is what lets it win while both match at once. */
  .sidebar.is-open ~ .mobile-brand-avatar {
    opacity: 0;
    pointer-events: none;
  }

  .mobile-brand-avatar:active {
    transform: scale(0.92);
  }

  /* Fills .mobile-brand-avatar's full box (--mobile-pill-h) — matching
     the idle island pill and the hamburger button exactly, instead of
     sitting smaller inside a glass-ringed circle. */
  .mobile-brand-avatar-img {
    width: var(--mobile-pill-h);
    height: var(--mobile-pill-h);
    border-radius: 50%;
    object-fit: cover;
  }

  /* The fullscreen panel: a plain cross-fade (no clip-path reveal) — the
     panel itself just fades in/out in place, and its own rows fade/slide in
     with a staggered stepped delay (see the section-link/divider/switch-row
     rules below), matching Apple's own mobile Safari nav-menu overlay
     (fades in place, top-aligned list, items appear top-to-bottom one after
     another) rather than growing out of the hamburger button. Content
     starts right under the fixed top bar (same offset formula as .content's
     own padding-top below) instead of being vertically centered, again to
     match that same reference. Stays nested inside .sidebar in the DOM
     (desktop's display:contents flattening trick for this element depends
     on that), but position:fixed here still resolves against the viewport
     rather than .sidebar's own box, because .sidebar itself never carries a
     transform on mobile (see its own rule above) — so there's no
     transformed ancestor to hijack it. Deliberately more opaque than the
     idle pill: it now holds real text over the page it's covering, not
     just a glass tint. */
  .sidebar-panel {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    gap: 16px;
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    max-width: none;
    padding: 24px;
    padding-top: calc(var(--mobile-top-gap) + var(--mobile-pill-h) + 28px + env(safe-area-inset-top));
    padding-bottom: calc(24px + env(safe-area-inset-bottom));
    border-radius: 0;
    border: none;
    background: linear-gradient(160deg, var(--panel-solid-from), var(--panel-solid-to));
    backdrop-filter: blur(var(--glass-blur)) saturate(160%);
    -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(160%);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    overflow-y: auto;
    z-index: 190;
    transition: opacity 0.28s ease, visibility 0.28s;
  }

  .sidebar-panel.is-open {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
  }

  /* Each row fades/slides up into place once the panel opens, one after
     another (see the transition-delay stack below) — same staggered-reveal
     motion as Apple's own mobile nav menu. Closed/base state applies with
     no delay so the whole panel's own opacity fade (above) is what covers
     the close, instead of reverse-staggering back out. */
  .sidebar-panel .section-link,
  .sidebar-panel > .divider,
  .switch-row > * {
    opacity: 0;
    transform: translateY(10px);
    transition: opacity 0.25s ease, transform 0.25s ease;
  }

  .sidebar-panel.is-open .section-link,
  .sidebar-panel.is-open > .divider,
  .sidebar-panel.is-open .switch-row > * {
    opacity: 1;
    transform: translateY(0);
  }

  .sidebar-panel.is-open .section-links .section-link:nth-child(1) {
    transition-delay: 0.06s;
  }

  .sidebar-panel.is-open .section-links .section-link:nth-child(2) {
    transition-delay: 0.11s;
  }

  .sidebar-panel.is-open .section-links .section-link:nth-child(3) {
    transition-delay: 0.16s;
  }

  .sidebar-panel.is-open > .divider {
    transition-delay: 0.21s;
  }

  .sidebar-panel.is-open .switch-row > *:nth-child(1) {
    transition-delay: 0.26s;
  }

  .sidebar-panel.is-open .switch-row > *:nth-child(2) {
    transition-delay: 0.31s;
  }

  /* .nav-trigger just stacks its own children vertically (the notify
     label above the section-links row). order:1 places it — Home/Works/News
     — first in the fullscreen panel's visual stacking, ahead of the
     theme/lang switch (see .switch-row and the divider order overrides
     below), independent of .sidebar-panel's actual DOM order, which
     desktop's display:contents flattening depends on and this media query
     never touches. */
  .nav-trigger {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 8px;
    max-width: none;
    opacity: 1;
    visibility: visible;
    overflow: visible;
    /* Undo the desktop-only "cancel the dead gap" trick — meaningless
       (and actively wrong) once it's a column instead of a row sitting
       next to a divider. */
    margin-right: 0;
    order: 1;
  }

  /* GitHub/Email and Claude/ChatGPT become their own row of buttons in a
     glass capsule — same shape as the theme/lang switch (see .switch-row
     below) — instead of a tall list of full-width rows. Home/Works/News
     (.section-links) deliberately stays out of this: see its own plain
     vertical list further below. */
  .social-list,
  .ask-ai-list {
    display: flex;
    flex-direction: row;
    align-items: stretch;
    gap: 2px;
    height: var(--mobile-pill-h);
    padding: 0 3px;
    width: 100%;
    max-width: none;
    opacity: 1;
    visibility: visible;
    overflow: visible;
    border-radius: 999px;
    background: var(--glass-bg);
    border: 1px solid var(--glass-border);
    box-shadow: inset 0 1px 0 var(--glass-highlight);
  }

  /* Home/Works/News: plain clickable words stacked vertically, not a glass
     capsule row like GitHub/Email or Claude/ChatGPT above — reuses
     desktop's own plain-text .section-link look (color + hover, see the
     base rule up top) instead of the icon-button treatment those other
     groups get on mobile. max-width/opacity/overflow are desktop's
     hover-to-reveal gating (see the base .section-links rule) — always on
     here since the fullscreen panel has no hover state to gate behind. */
  .section-links {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    height: auto;
    padding: 4px;
    width: 100%;
    max-width: none;
    opacity: 1;
    visibility: visible;
    overflow: visible;
  }

  /* No sliding glass highlight behind the current/hovered word either —
     see setupHoverPill in script.js, which still runs unconditionally for
     desktop's row layout. It would technically still track the right item
     stacked vertically (getBoundingClientRect works either way), but a
     glass platter behind one word is exactly the "pill" look this list is
     meant to avoid. */
  .section-links .toggle-pill {
    display: none;
  }

  /* Same serif used by the h1/h2 titles these links actually jump to (see
     the base h1, h2 rule up top) — reads as "Home"/"Works"/"News" naming
     real page titles, not just body-text nav chrome. */
  .section-link {
    width: 100%;
    padding: 14px 8px;
    border-radius: 10px;
    font-family: "IBM Plex Serif", Georgia, "Iowan Old Style", "Palatino Linotype", serif;
    font-size: 1.3rem;
    text-align: left;
    transition: color 0.2s ease, background 0.2s ease;
  }

  .section-link:active {
    background: var(--glass-bg-strong);
  }

  /* GitHub/Email is dropped from the fullscreen panel entirely — the idle
     pill (.quick-links) already covers those two links, so the panel only
     needs Home/Works/News plus the theme/lang switches. divider-a sat
     directly between Home/Works/News and this row, so it goes too (no
     trailing rule left dangling with nothing after it). Desktop is
     untouched: .social-list still shows GitHub/Email on hover there, this
     display:none is scoped to the same 860px query as everything else
     here. */
  .social-list,
  .divider-a {
    display: none;
  }

  /* Horizontal rule between Home/Works/News and the theme/lang switch,
     instead of the desktop's vertical divider bars. Ordered to sit right
     after Home/Works/News (order:1) and before the switch row (order:3) —
     see the divider-b class in index.html. */
  .sidebar-panel > .divider {
    width: 100%;
    height: 1px;
    margin: 0;
  }

  .divider-b {
    order: 2;
  }

  /* Compact buttons, evenly sharing their row's width — same tap-target
     sizing as the theme/lang switch buttons. */
  .social-item,
  .ai-item {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 1;
    width: auto;
    height: auto;
    gap: 6px;
    padding: 0 10px;
    margin: 0;
    border-radius: 999px;
    font-size: 0.9rem;
  }

  /* Match the social icons' size so every row reads as one consistent
     set of icon buttons instead of the AI icons dominating (they're 28px
     for the old desktop-sized square button). */
  .ai-item img,
  .ai-item svg {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
  }

  /* Theme and language switch: narrow, content-width pills stacked one
     under the other below Home/Works/News, each matching --mobile-pill-h —
     the same height as the idle Dynamic Island pill/avatar/hamburger up
     top, but not stretched to the panel's full width. order:3 puts this
     group last in the panel's stacking, right after Home/Works/News
     (order:1) — see .nav-trigger's own comment above. GitHub/Email
     (.social-list) no longer appears in this panel at all. */
  .switch-row {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 8px;
    width: 100%;
    margin-top: 0;
    order: 3;
  }

  .lang-toggle,
  .theme-toggle {
    width: auto;
    height: var(--mobile-pill-h);
    padding: 0 4px;
  }

  .lang-btn,
  .theme-btn {
    flex: 0 0 auto;
    padding: 0 10px;
  }

  .lang-btn {
    font-size: 0.82rem;
  }

  .theme-btn svg {
    width: 18px;
    height: 18px;
  }

  /* Locks background scrolling while the fullscreen mobile menu is open
     (see script.js's setOpen) — scoped to this breakpoint like everything
     else here, so the class is a harmless no-op on desktop even though the
     JS sets it unconditionally. */
  html.mobile-menu-open,
  html.mobile-menu-open body {
    overflow: hidden;
  }

  .content {
    /* 28px of breathing room below the fixed top bar, on top of its own
       offset + height — kept as a calc off the same variables (see the
       :root block above) so it stays correct if --mobile-pill-h ever
       changes again instead of silently drifting out of sync. Left/right
       reuse --mobile-edge-gap too (was a literal 20px) so the page's own
       text/heading edges line up with the avatar and hamburger above them
       instead of sitting at a narrower margin. */
    padding: calc(var(--mobile-top-gap) + var(--mobile-pill-h) + 28px + env(safe-area-inset-top)) var(--mobile-edge-gap) 64px;
  }

  /* No hero avatar on mobile (see .avatar-wrap below) -- the bio switches
     to a plain single-column block so the heading/text run full-width
     instead of leaving room for a column that's no longer there. The
     top-left island avatar (.mobile-brand-avatar) is always visible
     instead, see script.js. */
  .intro {
    display: block;
  }

  .avatar-wrap {
    display: none;
  }

  .work {
    margin-top: 56px;
  }

  /* Works/News rows become one-card-at-a-time carousels on mobile: each
     tile is sized to most of the viewport and scroll-snaps dead center,
     so a swipe always settles on exactly one riquadro instead of leaving
     it stopped mid-way between two. --carousel-card is shared by the
     padding (sized so the *first/last* card can still reach center) and
     the card's own flex-basis, so they can't drift out of sync. */
  .cards {
    --carousel-card: min(82vw, 340px);
    padding: 8px calc((100vw - var(--carousel-card)) / 2) 22px;
    scroll-snap-type: x mandatory;
    mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
    -webkit-mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
  }

  .cards .card {
    flex-basis: var(--carousel-card);
    scroll-snap-align: center;
  }

  .news-stack {
    max-width: 100%;
  }

  .news-stack.is-list {
    --carousel-card: min(82vw, 340px);
    padding: 8px calc((100vw - var(--carousel-card)) / 2) 22px;
    scroll-snap-type: x mandatory;
    mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
    -webkit-mask-image: linear-gradient(to right, transparent, black 20px, black calc(100% - 20px), transparent);
  }

  .news-stack.is-list .news-item {
    flex-basis: var(--carousel-card);
    scroll-snap-align: center;
  }

  .cards .card-preview {
    width: 64px;
    height: 64px;
    margin: 12px auto 0;
  }

  .card-body {
    padding: 8px 12px 10px;
  }

  /* .content's bottom padding is smaller here than on desktop, so the
     same pull-up as desktop would sit too tight against the cards. */
  footer.work-sub {
    margin-top: -40px;
  }
}

