: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);
  --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);
  --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;
}

/* 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%);
  box-shadow: inset 0 1px 0 var(--glass-highlight), 0 10px 30px rgba(0, 0, 0, 0.35);
  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/LinkedIn/Email read as evenly spaced
     with the rest of the bar rather than crowded together. Overridden
     back down for the mobile dropdown 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);
}

.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-news.mjs (via
   .github/workflows/news-sync.yml) into 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 currently
   active feed's own generatedAt (see script.js), so it changes when
   Tech/Scuola is switched. */
.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;
}

/* Tech/Scuola switch: same glass pill/gliding-highlight shape as the
   theme and language toggles (see script.js: setupHoverPill). */
.news-mode-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-mode-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.78rem;
  font-weight: 600;
  padding: 6px 14px;
  border-radius: 999px;
  cursor: pointer;
  transition: color 0.15s ease;
}

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

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

/* Small dot badge: there are unseen items in that feed (see script.js:
   diffAndRemember/reflectNewBadges). Clears once that button is clicked. */
.news-mode-btn.has-new::after {
  content: "";
  position: absolute;
  top: 4px;
  right: 6px;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
}

/* 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-favicon {
  width: 18px;
  height: 18px;
  border-radius: 4px;
  flex-shrink: 0;
}

.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-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  color: var(--text-muted);
  font-size: 0.82rem;
}

.news-item-meta a {
  color: inherit;
  text-decoration: none;
}

.news-item-meta a:hover {
  color: var(--text);
  text-decoration: underline;
}

.news-item-dot {
  color: var(--text-faint);
}

.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 — the open panel's
     switch/pill rows (theme, lang, section-links, social, ask-ai) inside
     .sidebar, and the independent .mobile-brand-avatar button outside it,
     all read this same variable so they're always exactly as tall as the
     idle island, never something coincidentally close. Declared on :root
     (rather than .sidebar) so .mobile-brand-avatar can read it too — it
     deliberately lives outside .sidebar (see its own rule below). */
  :root {
    --mobile-pill-h: 52px;
  }

  /* Idle, the island is a small pill — hamburger + GitHub/Email shortcuts
     — noticeably lighter/more see-through than the desktop glass, since
     it now sits over page content most of the time. Tapping the hamburger
     springs open a vertical dropdown panel below it (.sidebar-panel), no
     more sideways-expanding pill or horizontal scrolling. */
  .sidebar {
    top: calc(12px + env(safe-area-inset-top));
    /* Explicit, not auto — deterministic and easy to reuse in the is-open
       rule below instead of leaving it to be measured. 176px = 3px+3px
       padding + 36px toggle + 2px gap + 132px of three 40px quick-link
       icons (6px gaps between them). */
    width: 176px;
    height: var(--mobile-pill-h);
    padding: 0 3px;
    gap: 2px;
    justify-content: center;
    overflow: visible;
    background: linear-gradient(160deg, var(--island-idle-from), var(--island-idle-to));
    border-color: var(--island-idle-border);
  }

  /* The desktop hover-preview rule above (.sidebar:hover/:focus-within ->
     scale(1.02)) also matches here: tapping .nav-toggle gives it real focus,
     and that focus is never released again on later taps (nothing calls
     .blur()), so :focus-within becomes permanently true from the first tap
     onward. That means the scale(1.02) transform is only ever *animated in*
     once — on that very first tap — via the springy overshoot easing
     shared with gap/padding/transform above; every later open/close finds
     the scale already applied and just snaps, no transition to play. That
     one-time bounce (scaling the whole pill, and with it the dropdown panel
     nested inside it) is exactly the "shifts left, then re-centers, only
     the first time" glitch. Not a look mobile ever wanted anyway — the
     dropdown panel is the actual mobile "expand" affordance — so flatten
     the transform back to plain centering here. */
  .sidebar:hover,
  .sidebar:focus-within {
    transform: translateX(-50%);
  }

  .nav-toggle,
  .quick-link {
    display: inline-flex;
    flex-shrink: 0;
  }

  /* Mobile menu button: always the plain hamburger, scroll position and
     .show-brand-avatar no longer touch it — the avatar now docks in its
     own top-left button instead (.mobile-brand-avatar below), so the bars
     stay exactly where they are the whole time. Opening the dropdown
     panel (.is-open) still animates them into an X via the existing
     .bar-top/-mid/-bot rules below. */
  .nav-toggle {
    position: relative;
    width: 36px;
    height: 36px;
  }

  .nav-toggle-bars {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  /* GitHub/LinkedIn/Email shortcuts: slightly larger and more spread out
     than the hamburger next to them (mobile-only touch-up — desktop's own
     .quick-link sizing above is untouched). */
  .quick-link {
    width: 40px;
    height: 40px;
  }

  .quick-link svg {
    width: 20px;
    height: 20px;
  }

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

  /* GitHub/LinkedIn/Email shortcuts are only useful on the idle pill —
     once expanded, the same three links already live in .social-list
     inside the dropdown, so hide the duplicates to leave room for the
     hamburger alone. */
  .sidebar.is-open .quick-links {
    display: none;
  }

  /* Matches the toggle-only content once .quick-links is hidden above:
     3px+3px padding + 36px toggle, no gap (single child). Explicit for the
     same reason as the closed 156px width — since .sidebar is centered via
     left: 50% + translateX(-50%) (inherited, no override here), that -50%
     re-resolves against this width, landing the now-lone toggle exactly on
     the viewport's horizontal center, with the dropdown panel (itself
     centered on .sidebar) centered right along with it. Idle, the toggle
     sits left of center as part of the toggle+icons group — that asymmetry
     is the intended idle-pill look, not something to fix. */
  .sidebar.is-open {
    width: 42px;
  }

  .quick-link {
    align-items: center;
    justify-content: center;
    border-radius: 999px;
    color: var(--text-muted);
    text-decoration: none;
    transition: color 0.15s ease, background 0.2s ease;
  }

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

  .sidebar.is-open .nav-toggle .bar-top {
    transform: translate(0, 4px) rotate(45deg);
  }

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

  .sidebar.is-open .nav-toggle .bar-bot {
    transform: translate(0, -4px) rotate(-45deg);
  }

  /* Desktop's in-island avatar slot has no equivalent inside the mobile
     dropdown — the avatar docks in .mobile-brand-avatar below instead, so
     this stays hidden and the trigger here is just the hamburger. */
  .sidebar-brand {
    display: none;
  }

  /* Mobile-only docking spot for the flying hero avatar (see script.js):
     a glass button 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 (--mobile-pill-h) so the two read as
     a matched pair. 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(12px + env(safe-area-inset-top));
    left: 16px;
    width: var(--mobile-pill-h);
    height: var(--mobile-pill-h);
    align-items: center;
    justify-content: center;
    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), 0 6px 20px rgba(0, 0, 0, 0.3);
    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;
  }

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

  .mobile-brand-avatar-img {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    object-fit: cover;
  }

  /* The dropdown panel itself: collapsed and hidden by default, springs
     open below the trigger once .sidebar.is-open is set. Deliberately
     darker/more opaque than the idle pill — it holds real text over
     varied page content, so it needs real contrast, not just a glass
     tint. */
  .sidebar-panel {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    position: absolute;
    top: calc(100% + 10px);
    left: 50%;
    width: 224px;
    max-width: calc(100vw - 32px);
    padding: 10px;
    border-radius: 20px;
    background: linear-gradient(160deg, var(--panel-solid-from), var(--panel-solid-to));
    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.45);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transform-origin: top center;
    transform: translateX(-50%) translateY(-6px) scale(0.96);
    transition: opacity 0.22s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), visibility 0.3s;
  }

  .sidebar.is-open .sidebar-panel {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
    transform: translateX(-50%) translateY(0) scale(1);
  }

  /* .nav-trigger just stacks its own children vertically (the notify
     label above the row of section icons). */
  .nav-trigger {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    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;
  }

  /* Home/Works/News, GitHub/Email and Claude/ChatGPT each become their own
     row of icon-only buttons in a glass capsule — same shape as the
     theme/lang switch (see .switch-row below) — instead of a tall list of
     full-width text rows. */
  .section-links,
  .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);
  }

  /* Horizontal rules between groups, instead of the desktop's vertical
     divider bars. */
  .sidebar-panel > .divider {
    width: 100%;
    height: 1px;
    margin: 4px 0;
  }

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

  /* Home/Works/News icons are hidden by default (see the base
     .section-link-icon rule) — this is the one place they're shown,
     replacing the text label that stays on screen everywhere else. */
  .section-link-icon {
    display: block;
  }

  /* Text labels become screen-reader-only here, the same treatment the
     social/ask-AI buttons already carry by default (see the base
     .social-item/.ai-item span rule) — the icon is the whole point of
     the row now. */
  .section-link span {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }

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

  /* Real estate is tight on a phone screen, so the two pills sit side by
     side in one row instead of stacking, and both grow to share the full
     width evenly — bigger buttons are easier to hit with a thumb. */
  .switch-row {
    display: flex;
    flex-direction: row;
    align-items: stretch;
    gap: 10px;
    height: var(--mobile-pill-h);
    width: 100%;
    margin-top: 2px;
  }

  .lang-toggle,
  .theme-toggle {
    flex: 1;
    padding: 0 3px;
  }

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

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

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

  .content {
    padding: calc(92px + env(safe-area-inset-top)) 20px 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;
  }
}

