/* ============================================================
   Muhammad Ibrahim — portfolio
   Tokens: warm paper bg, warm ink, three project accents.
   Type: Rubik (local variable font).
   ============================================================ */

/* Local font faces */
@font-face {
  font-family: 'Rubik';
  /* woff2 is ~69% smaller than the raw TTF; the TTF stays as a fallback. */
  src: url('../assets/fonts/Rubik-VariableFont_wght.woff2') format('woff2'),
       url('../assets/fonts/Rubik-VariableFont_wght.ttf') format('truetype-variations');
  font-weight: 100 900;
  font-display: swap;
}

:root {
  /* Light theme default */
  --bg: #f5efe6;
  --card-bg: #ede5d8;
  --ink: #1a1814;
  --muted: #6b6660;
  --faint: #9c9790;
  --line: #ddd8d0;
  --surface: #e8e2d8;
  --accent: #1a1814;
  --accent-soft: #1a181414;
  --overlay-bg: rgba(245, 239, 230, 0.88);
  /* How strongly the hover swirl reads behind a screenshot. Paper needs a
     lighter touch than a dark card: the same image at full strength turns
     into a poster on cream and a whisper on near-black. */
  --swirl-strength: 0.5;
  /* Patterns are drawn bright, to glow on a dark card. On paper that same
     ribbon is the paper, so light takes the pattern's hue down below the
     surface it sits on — the swirl keeps its colour and swaps which side of
     the background it reads from. */
  --swirl-tone: brightness(0.4) saturate(1.2);
  /* Status dot on a live project — the one hue the warm palette lacks. */
  --live: #4a7c59;
  /* The View Live button. Declared here and in none of the theme blocks below,
     so it is the one colour on the page that does not move: the amber the
     contribution scale already reaches for, dark enough for near-black type
     and clear of both the paper and the near-black card behind it. */
  --cta: #cfaf76;
  --cta-ink: #17130f;
  /* Segmented control: a recessed track with the selected chip lifted off it.
     Light inverts the usual direction — the chip is the page's own paper,
     sitting on a track a shade deeper than it. */
  --tab-track: #e8e2d8;
  --tab-chip: #f5efe6;
  --gh-0: #e7e0d4;
  --gh-1: #d0b177;
  --gh-2: #bc8f42;
  --gh-3: #9a6f26;
  --gh-4: #6f4f14;
  /* Brand marks draw themselves; these are the three whose own colour does not
     survive one of the two themes — React's cyan vanishes into paper, pandas'
     navy and SQLite's midnight blue vanish into the dark card. Each pair is
     the brand's own light/dark variant, not an invention. */
  --logo-react: #087ea4;
  --logo-pandas: #150458;
  --logo-sqlite: #003b57;

  --font-sans: 'Rubik', -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SFMono-Regular", Menlo, monospace;

  --pad: clamp(20px, 5vw, 64px);
  --content-max: 1240px;
  --content-width: min(var(--content-max), calc(100vw - (var(--pad) * 2)));
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}

/* Explicit Light theme */
:root[data-theme="light"] {
  --bg: #f5efe6;
  --card-bg: #ede5d8;
  --ink: #1a1814;
  --muted: #6b6660;
  --faint: #9c9790;
  --line: #ddd8d0;
  --surface: #e8e2d8;
  --accent: #1a1814;
  --accent-soft: #1a181414;
  --overlay-bg: rgba(245, 239, 230, 0.88);
  --swirl-strength: 0.5;
  --swirl-tone: brightness(0.4) saturate(1.2);
  --live: #4a7c59;
  --tab-track: #e8e2d8;
  --tab-chip: #f5efe6;
  --gh-0: #e7e0d4;
  --gh-1: #d0b177;
  --gh-2: #bc8f42;
  --gh-3: #9a6f26;
  --gh-4: #6f4f14;
  /* Brand marks draw themselves; these are the three whose own colour does not
     survive one of the two themes — React's cyan vanishes into paper, pandas'
     navy and SQLite's midnight blue vanish into the dark card. Each pair is
     the brand's own light/dark variant, not an invention. */
  --logo-react: #087ea4;
  --logo-pandas: #150458;
  --logo-sqlite: #003b57;
  color-scheme: light;
}

/* Explicit Dark theme */
:root[data-theme="dark"] {
  --bg: #141210;
  --card-bg: #1e1b18;
  --ink: #e8e2d9;
  --muted: #9a9590;
  --faint: #6b6560;
  --line: #2a2622;
  --surface: #1e1b18;
  --accent: #e8e2d9;
  --accent-soft: #e8e2d922;
  --overlay-bg: rgba(20, 18, 16, 0.82);
  --swirl-strength: 0.8;
  --swirl-tone: none;
  --live: #6fbf8a;
  --tab-track: #191612;
  --tab-chip: #2a2622;
  --gh-0: #221e1a;
  --gh-1: #4a3c24;
  --gh-2: #7d6533;
  --gh-3: #b38f45;
  --gh-4: #e0b45f;
  --logo-react: #61dafb;
  --logo-pandas: #c6bfea;
  --logo-sqlite: #4fa3c7;
  color-scheme: dark;
}

/* Dark theme (system preference) */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #141210;
    --card-bg: #1e1b18;
    --ink: #e8e2d9;
    --muted: #9a9590;
    --faint: #6b6560;
    --line: #2a2622;
    --surface: #1e1b18;
    --accent: #e8e2d9;
    --accent-soft: #e8e2d922;
    --overlay-bg: rgba(20, 18, 16, 0.82);
    --swirl-strength: 0.8;
    --swirl-tone: none;
    --live: #6fbf8a;
    --tab-track: #191612;
    --tab-chip: #2a2622;
    --gh-0: #221e1a;
    --gh-1: #4a3c24;
    --gh-2: #7d6533;
    --gh-3: #b38f45;
    --gh-4: #e0b45f;
    --logo-react: #61dafb;
    --logo-pandas: #c6bfea;
    --logo-sqlite: #4fa3c7;
    color-scheme: dark;
  }
}

* {
  box-sizing: border-box;
}

html {
  background: var(--bg);
  color-scheme: light;
  scroll-behavior: smooth;
}

body {
  margin: 0;
  /* Keep content clear of notches and rounded display corners. */
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-sans);
  font-weight: 300;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
  -webkit-tap-highlight-color: transparent;
}

/* ---------- theme toggle & clouds ---------- */

.theme-toggle {
  /* Fixed, not absolute — on a long mobile page the toggle should stay
     reachable instead of scrolling away at the top. */
  position: fixed;
  top: calc(clamp(16px, 3vw, 24px) + env(safe-area-inset-top));
  right: calc(var(--pad) + env(safe-area-inset-right));
  z-index: 70;
  width: 46px;
  height: 46px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  display: grid;
  place-items: center;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.12));
  transition: transform 0.2s cubic-bezier(0.34, 1.1, 0.64, 1);
}

.theme-toggle:active {
  transform: scale(0.94);
}

.theme-toggle svg {
  width: 42px;
  height: 42px;
  display: block;
  grid-area: 1 / 1;
  transition: opacity 180ms ease, transform 320ms cubic-bezier(0.23, 1, 0.32, 1);
}

.theme-toggle .icon-moon {
  opacity: 0;
  transform: scale(0.6) rotate(-60deg);
}

:root[data-theme="dark"] .theme-toggle .icon-sun {
  opacity: 0;
  transform: scale(0.6) rotate(60deg);
}

:root[data-theme="dark"] .theme-toggle .icon-moon {
  opacity: 1;
  transform: none;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .icon-sun {
    opacity: 0;
    transform: scale(0.6) rotate(60deg);
  }

  :root:not([data-theme="light"]) .theme-toggle .icon-moon {
    opacity: 1;
    transform: none;
  }
}

/* ---------- layout ---------- */

main,
.site-footer {
  width: min(100%, var(--content-width));
  margin-inline: auto;
}

main {
  display: block;
}

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

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

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

::selection {
  background: var(--ink);
  color: var(--bg);
}

/* ---------- staggered entrance animation ---------- */

@keyframes fadeSlideIn {
  from {
    opacity: 0;
    transform: translateY(18px);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.stagger {
  opacity: 0;
  animation: fadeSlideIn 0.7s var(--ease) forwards;
}

.stagger-1 {
  animation-delay: 0.05s;
}

.stagger-2 {
  animation-delay: 0.15s;
}

.stagger-3 {
  animation-delay: 0.28s;
}

.stagger-4 {
  animation-delay: 0.40s;
}

.stagger-5 {
  animation-delay: 0.52s;
}

/* ---------- top progress line ---------- */

.progress {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: var(--line);
  z-index: 60;
}

.progress span {
  display: block;
  height: 100%;
  width: 0%;
  background: var(--ink);
  transition: width 0.1s linear;
}

/* ---------- shared type ---------- */

.work-heading {
  font-size: clamp(2.1rem, 5vw, 3.4rem);
  font-weight: 400;
  letter-spacing: -0.02em;
  line-height: 1.05;
  margin: 0;
}

.work-heading em {
  font-style: normal;
  color: var(--muted);
}

/* ---------- intro ---------- */

/* Two columns that close ranks: the name on the left, set as large as its
   column allows, and the cat beside it, bottom-aligned so the name's baseline
   and the image's lower edge land on one line. Underneath, the lead sits under
   the name it explains and the skills sit under the cat.

   The section is a size container so every measurement below is a share of
   the column the name actually has, not of the viewport: --content-width
   caps at 1240px and a vw-based size keeps climbing past it. */
.intro {
  container-type: inline-size;
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
  grid-template-areas:
    "title image"
    "lead  tags";
  column-gap: 56px;
  row-gap: 40px;
  padding: calc(var(--pad) + 24px) var(--pad) calc(var(--pad) + 16px);

  /* "Muhammad" at 750 is 5.55em wide. The left column is 1.2/2.2 of the row
     less its gutter, so 8.6cqi puts the word just inside it at every width
     this layout runs at; 6rem is where it stops growing. */
  --name-size: min(6rem, 8.6cqi);
}

.intro-title {
  grid-area: title;
  align-self: end;
}

.intro-name {
  font-size: var(--name-size);
  font-weight: 300;
  letter-spacing: -0.035em;
  line-height: 0.92;
  margin: 0;
}

.intro-name > span {
  display: block;
  transition: font-weight 0.7s var(--ease);
}

/* The greeting is the heading's own first phrase, at a third of its size and
   in the muted ink the section headings use for their second half. */
.intro-greeting {
  font-size: 0.36em;
  letter-spacing: -0.01em;
  line-height: 1.2;
  color: var(--muted);
  margin-bottom: 0.4em;
}

/* One name, both ends of Rubik's weight axis: the first name at 750, the
   surname at the face's lightest 300. On load they arrive swapped and trade
   places, so the name settles into its contrast rather than appearing in it.
   backwards fill, not both: once the animation ends the declared weights take
   over again, which is what lets the hover swap below transition at all. */
.intro-first {
  font-weight: 750;
  animation: name-settle-heavy 1.4s var(--ease) 0.25s backwards;
}

.intro-last {
  font-weight: 300;
  animation: name-settle-light 1.4s var(--ease) 0.25s backwards;
}

@keyframes name-settle-heavy {
  from {
    font-weight: 300;
  }
}

@keyframes name-settle-light {
  from {
    font-weight: 750;
  }
}

/* The image has no height of its own here: it stretches to the title's row
   and is inset from both ends so it spans exactly the lettering. The name's
   line box runs 0.12em past its baseline, so the lower edge lands on the
   baseline; the greeting's capitals start 0.085em below the row's top, so the
   upper edge lands on them. */
.intro-image-wrapper {
  grid-area: image;
  align-self: stretch;
  width: 100%;
  margin-top: calc(var(--name-size) * 0.085);
  margin-bottom: calc(var(--name-size) * 0.12);
  overflow: hidden;
  border-radius: 4px;
  cursor: pointer;
  position: relative;
}

/* Out of flow so the gif's natural size cannot push the row taller than the
   name it is meant to match. */
.intro-image {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  width: 115%;
  height: 115%;
  object-fit: cover;
  object-position: center;
  transition: transform 360ms var(--ease);
  transform-origin: center center;
}

.intro-image.zoomed {
  transform: scale(1.08);
}

/* Click counter on cat image */
.click-counter {
  position: absolute;
  bottom: 6px;
  right: 8px;
  font-family: var(--font-mono);
  font-size: 0.58rem;
  color: var(--bg);
  background: var(--ink);
  padding: 2px 6px;
  border-radius: 3px;
  opacity: 0;
  transition: opacity 0.3s var(--ease);
  pointer-events: none;
  white-space: nowrap;
  z-index: 2;
}

.click-counter.visible {
  opacity: 0.75;
}

.intro-kicker {
  font-family: var(--font-mono);
  font-size: clamp(0.9rem, 1.4vw, 1.05rem);
  color: var(--muted);
  margin: 0 0 30px;
}

.intro-lead {
  grid-area: lead;
  font-size: clamp(1.05rem, 1.6vw, 1.3rem);
  line-height: 1.6;
  color: var(--ink);
  max-width: 36em;
  margin: 0;
  text-wrap: pretty;
}

.intro-tags {
  grid-area: tags;
  align-self: start;
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  /* Drops the pill row onto the lead's first line rather than its top edge. */
  padding-top: 0.3rem;
}

.intro-tags li {
  font-family: var(--font-mono);
  font-size: 0.74rem;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 7px 14px;
  transition: border-color 0.25s var(--ease), color 0.25s var(--ease);
}

.scroll-cue {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.03em;
  color: var(--muted);
  width: fit-content;
  transition: color 0.2s var(--ease);
}

.scroll-cue span {
  width: 22px;
  height: 1px;
  background: currentColor;
  position: relative;
  overflow: visible;
}

.scroll-cue span::after {
  content: "";
  position: absolute;
  right: 0;
  top: -3px;
  width: 6px;
  height: 6px;
  border-right: 1px solid currentColor;
  border-bottom: 1px solid currentColor;
  transform: rotate(45deg);
  animation: nudge 1.6s var(--ease) infinite;
}

@keyframes nudge {

  0%,
  100% {
    transform: translateX(0) rotate(45deg);
  }

  50% {
    transform: translateX(4px) rotate(45deg);
  }
}

/* ---------- work section ---------- */

.work {
  padding: 0px 0 60px;
}

.work-head {
  padding: 0 var(--pad);
  margin: 0 0 44px;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
  flex-wrap: wrap;
}

/* ---------- work tabs ---------- */

/* renderTabs() leaves this empty when the data holds only one kind of project,
   so the whole control disappears rather than sitting there as a lone tab. */
.work-tabs:empty {
  display: none;
}

/* The heading's 44px breathing room is meant to sit against the grid. With a
   tab row in between, that much air orphans the tabs from their heading. */
.work-head:has(+ .work-tabs:not(:empty)) {
  margin-bottom: 18px;
}

/* A segmented control: one track holding the whole set, with the active tab
   marked by a chip that slides between them. The track hugs its tabs rather
   than spanning the column, and the gutter moves to the margin so its left
   edge still lines up with the cards below. */
.work-tabs {
  --track-pad: 4px;
  display: flex;
  align-items: stretch;
  gap: 2px;
  width: max-content;
  margin: 0 var(--pad) 26px;
  padding: var(--track-pad);
  position: relative;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--tab-track);
  /* Wrapping would break the chip: it is positioned along one axis only. The
     kinds are a fixed list, so three tabs is the ceiling by construction. */
  flex-wrap: nowrap;
}

.work-tab {
  position: relative;
  z-index: 1;                     /* above the chip that slides underneath */
  display: inline-flex;
  align-items: center;
  min-height: 34px;
  padding: 8px 16px;
  border: none;
  border-radius: 999px;
  background: none;
  cursor: pointer;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  /* --faint measures 2.54:1 on the light paper, so it is not usable for a
     control's own label; --muted clears 4.5:1 in both themes. */
  color: var(--muted);
  transition: color 0.2s var(--ease);
}

/* A chip sized for a cursor is under the 44px touch floor, so the control
   grows where the pointer is a finger rather than everywhere. */
@media (pointer: coarse) {
  .work-tab {
    min-height: 44px;
  }
}

.work-tab[aria-pressed="true"] {
  color: var(--ink);
}

/* One shared chip that travels between tabs instead of a per-tab background
   that blinks out here and in over there.

   translateX places it and --ind-w gives it the active tab's width, both
   written by moveIndicator(). The width is a real width rather than a scaleX:
   a scaled box would drag its corner radius and its shadow out of shape with
   it, which is exactly what a pill cannot afford. The element is absolutely
   positioned, so resizing it lays out nothing but itself. */
.work-tabs-indicator {
  position: absolute;
  /* Inset to the track's padding so the chip covers the tab and no more. */
  top: var(--track-pad);
  bottom: var(--track-pad);
  left: 0;
  z-index: 0;
  width: var(--ind-w, 0px);
  border-radius: 999px;
  background: var(--tab-chip);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.09);
  transform: translateX(var(--ind-x, 0px));
  transition: transform 0.26s var(--ease), width 0.26s var(--ease);
  view-transition-name: work-tab-indicator;
  pointer-events: none;
}

/* First paint should not slide in from the left edge. */
.work-tabs-indicator.is-initial {
  transition: none;
}

/* The chip animates itself, and hands over to the view transition only while
   one is actually running — morph() sets this class for exactly that window.

   Both at once is what cannot happen: a CSS transition still mid-flight when
   the browser snapshots the new state is captured at its old position, so the
   group morphs from old to old and the chip appears not to move, then jumps
   when the pseudo tree is torn down. Gating on the class rather than on
   @supports also covers the case the API is present but the transition is
   skipped — the chip is then animated by the only mechanism left. */
.work-tabs.is-morphing .work-tabs-indicator {
  transition: none;
}

/* A flat chip has nothing to cross-fade, so suppressing the default fade
   leaves a clean slide driven only by the group's own morph.

   block-size is the load-bearing line. Snapshots default to `block-size: auto`,
   which keeps each image's own aspect ratio: stretch a 90px-wide chip and a
   70px-wide one to the same animating group width and they come out at
   different heights, so the slide shows two chips of different sizes stacked
   instead of one. Filling the group box makes them coincide. */
::view-transition-old(work-tab-indicator),
::view-transition-new(work-tab-indicator) {
  animation: none;
  block-size: 100%;
  object-fit: fill;
}

/* Filtered-out cards leave the grid entirely so the rows reflow. Cards under
   the fold leave it the same way — to the grid the two are the same thing. */
.project-card.is-hidden,
.project-card.is-folded {
  display: none;
}

/* ---------- show more ---------- */

.work-more {
  display: flex;
  justify-content: center;
  padding: 20px var(--pad) 0;
}

/* Empty before the data lands; hidden by applyFold() once there is nothing
   left under the fold to reveal. Either way the row of padding goes too. */
.work-more:empty,
.work-more[hidden] {
  display: none;
}

.work-more-btn {
  padding: 11px 22px;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
}

.work-more-btn:active {
  color: var(--ink);
  border-color: var(--ink);
}

/* ---------- tab switch choreography ----------

   The filter runs inside document.startViewTransition(), so the browser
   snapshots every named element before and after the class toggle and tweens
   between the two on the compositor. Nothing here runs per-frame JavaScript,
   and no card is ever measured or positioned by hand.

   Cards that survive a filter are morphed from their old grid slot to their new
   one automatically; only the ones entering and leaving need describing. */

::view-transition-group(*) {
  animation-duration: 260ms;
  animation-timing-function: var(--ease);
}

/* The root pair keeps its default cross-fade. Suppressing it used to be free —
   nothing outside the grid changed on a tab switch — but the tab labels shift
   colour and the show-more button changes its text and can leave altogether.
   With both root snapshots held at full opacity those changes render twice,
   one state ghosted over the other, for the length of the transition. */

@keyframes card-leave {
  to {
    opacity: 0;
    transform: scale(0.97);
  }
}

@keyframes card-enter {
  from {
    opacity: 0;
    transform: scale(0.97);
  }
}

/* :only-child is what separates the two cases. A card present in both states
   has an old AND a new image in its group, so neither is an only child and the
   group's own transform morph carries it to its new grid slot untouched. A card
   that is only leaving (or only arriving) has a group of one — that is the one
   to fade. Without this guard every surviving card also shrinks 3% mid-move. */
::view-transition-old(.project-card-snapshot):only-child {
  animation: card-leave 180ms var(--ease) forwards;
}

::view-transition-new(.project-card-snapshot):only-child {
  /* Enters a touch slower than it leaves, so the incoming set settles last. */
  animation: card-enter 240ms var(--ease) backwards;
}

/* The export ships as a Tailwind component used inside
   `grid gap-7 sm:grid-cols-2 lg:grid-cols-3`; the gap is its gap-7, but the
   third column is dropped — two cards to a row give the screenshot and the
   description enough width to be worth showing at all. */
.projects-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 28px;
  padding: 0 var(--pad) 8px;
}

@media (min-width: 640px) {
  .projects-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* ---------- project cards ----------

   A port of export/ProjectCard.jsx. Every dimension, duration and easing curve
   is the component's; only the palette is swapped — the hard-coded browns and
   the orange accent become the site's theme tokens, so the cards follow the
   light/dark toggle instead of forcing a dark card onto a paper page.
   Per-card values (--project-accent, --project-glow, --project-pattern,
   --project-pattern-zoom) are set inline by renderProjects().

   The v1 cards this replaced are archived in legacy/project-cards-v1/. */

.project-card {
  /* Each card carries a unique view-transition-name (set at render time); this
     shared class is what the ::view-transition-* rules above select on, so the
     enter/leave choreography is written once rather than per card. */
  view-transition-class: project-card-snapshot;
  display: flex;
  flex-direction: column;
  min-height: 520px;
  min-width: 0;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--card-bg);
  transition: border-color 0.4s var(--ease), transform 0.16s var(--ease);
}

/* Touch press feedback — mobile has no hover to signal "this is tappable". */
.project-card:has(.project-open:active) {
  border-color: var(--project-accent, var(--ink));
  transform: scale(0.985);
}

/* ---------- preview ---------- */

.project-preview {
  position: relative;
  height: 200px;
  flex-shrink: 0;
  overflow: hidden;
  border-bottom: 1px solid var(--line);
  background: var(--surface);
  cursor: pointer;
}

/* Ambient wash behind the screenshot. Sits under the shot rather than on it,
   so what comes up on hover is the space around the frame.

   The wash box only fades and feathers; its child carries the artwork. The
   feather is deliberately gentle: the shot covers the middle of the preview,
   so the wash is read through the strip around it, and a mask that emptied
   the edges would erase the only part of it anyone sees. It just falls off
   toward the corners so the pattern has some depth in the frame. */
.project-preview-wash {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  opacity: 0;
  transition: opacity 620ms;
  -webkit-mask-image: radial-gradient(120% 115% at 50% 34%, #000 40%, rgba(0, 0, 0, 0.4) 100%);
          mask-image: radial-gradient(120% 115% at 50% 34%, #000 40%, rgba(0, 0, 0, 0.4) 100%);
}

/* The pre-pattern glow, and still the wash on any browser that cannot mask by
   luminance — a card should lose the swirl, not the hover state. */
.project-preview-swirl {
  /* How close the card sits to the pattern, from js/config.js or a project's
     own patternZoom. Floored at 1 — that is the point where the pattern just
     covers the preview, and anything under it pulls the wash off the edges. */
  --swirl-zoom: max(1, var(--project-pattern-zoom, 1));
  position: absolute;
  inset: -30%;
  filter: blur(34px);
  background: radial-gradient(60% 60% at 50% 40%, var(--project-glow, transparent), transparent 70%);
}

/* The swirl itself: a generated PNG (scripts/swirl.py), named per project by
   "pattern" in projects.json and otherwise by config.projectPattern.

   The same image is both the paint and the mask, read as luminance: the bright
   ribbons keep their generated colour, the near-black plate they sit on falls
   away to nothing. So a pattern drops onto any card without having to match
   the theme — and the colour it washes the preview with is chosen once, in the
   script that drew it, rather than negotiated here.

   Sized to cover and zoomed by transform rather than by background-size: what
   the preview crops out of a pattern depends on how wide the card is, and a
   scale on top of cover is the one way to say "closer in" that means the same
   thing in a one- and a two-column grid. */
@supports (mask-mode: luminance) {
  .project-preview-swirl {
    inset: 0;
    opacity: var(--swirl-strength, 0.7);
    /* Filtered before it is masked, so this tones the ribbons themselves and
       leaves which pixels survive to the untouched image. */
    filter: var(--swirl-tone, none);
    background: var(--project-pattern, none) center / cover no-repeat;
    mask-image: var(--project-pattern, none);
    mask-mode: luminance;
    mask-size: cover;
    mask-position: center;
    /* Settles back to rest as the wash comes up, so the pattern drifts in
       under the shot instead of switching on. Matches the shot's own curve.
       The drift is a zoom in, so it never uncovers the preview on its way. */
    transform: scale(calc(var(--swirl-zoom) * 1.07));
    transition: transform 900ms cubic-bezier(0.16, 0.84, 0.28, 1);
    will-change: transform;
  }
}

/* The screenshot is cropped by the preview box and runs past its bottom edge,
   so it reads as a window onto a taller page. */
.project-shot {
  position: absolute;
  top: 24px;
  right: 22px;
  bottom: -18px;
  left: 22px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-bottom: none;
  border-radius: 8px 8px 0 0;
  box-shadow: 0 10px 24px -18px rgba(0, 0, 0, 0.6);
  transition: transform 620ms cubic-bezier(0.16, 0.84, 0.28, 1),
              box-shadow 620ms cubic-bezier(0.16, 0.84, 0.28, 1);
  will-change: transform;
}

.project-shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top;
}

/* Stand-in for a project with no screenshot yet. */
.project-shot-empty {
  display: flex;
  width: 100%;
  height: 100%;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  color: var(--faint);
  background: repeating-linear-gradient(135deg,
      var(--surface) 0 8px,
      var(--card-bg) 8px 16px);
}

/* ---------- body ---------- */

.project-body {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
  padding: 20px 22px 22px;
}

/* The component is inert — every card here also opens the detail modal, so the
   text block is the button and the action links stay outside it. */
.project-open {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 14px;
  min-width: 0;
  cursor: pointer;
}

.project-title-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.project-title {
  margin: 0;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -0.015em;
  line-height: 1.2;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.project-status {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-shrink: 0;
}

.project-status-dot {
  width: 7px;
  height: 7px;
  border-radius: 999px;
  background: var(--live);
  animation: status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

@keyframes status-pulse {
  50% {
    opacity: 0.5;
  }
}

.project-status-label {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--muted);
}

.project-status-mark {
  flex-shrink: 0;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--project-accent, var(--muted));
}

.project-tagline {
  margin: 0;
  font-size: 14px;
  font-weight: 500;
  color: var(--project-accent, var(--muted));
  overflow-wrap: anywhere;
}

.project-desc {
  margin: 0;
  font-size: 14px;
  line-height: 1.625;
  color: var(--muted);
  text-wrap: pretty;
  overflow-wrap: anywhere;
}

/* ---------- tags & actions ---------- */

.project-foot {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* The footer's social buttons at card scale: same squircle, same ring on the
   page's own paper, one step off the card behind it. Each tool is drawn in
   its own colours, so the chip is only the frame — the accent below is what
   the concept glyphs (Full Stack, DOM, Browser APIs) fall back to, since
   those have no logo of their own. */
.project-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.project-tag {
  position: relative;
  width: 32px;
  height: 32px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--bg);
  color: var(--project-accent, var(--muted));
  /* A concept glyph is nothing but its mark, so the accent cannot be left to
     land where it likes: the olive and the amber both sit under 3:1 on paper.
     Pulling every accent a step towards --ink — whichever end of the scale
     that is in this theme — keeps the project's colour and puts the mark back
     above the floor. Browsers without color-mix keep the line above. */
  color: color-mix(in oklab, var(--project-accent, var(--muted)) 72%, var(--ink));
  transition: border-color 0.4s var(--ease);
}

.project-tag svg {
  display: block;
  width: 18px;
  height: 18px;
}

/* The name, on hover. A chip carries no label of its own, and the native
   title tooltip is both slow to appear and unstyleable. Anchored to the
   chip's left edge rather than centred: the card clips its overflow, and a
   pooled label ("Shopify, E-commerce, Online Store") centred on the first
   chip would run off the card's left side. */
.project-tag[data-label]::after {
  content: attr(data-label);
  position: absolute;
  z-index: 3;
  bottom: calc(100% + 8px);
  left: -9px;
  padding: 5px 9px;
  border-radius: 7px;
  background: var(--ink);
  color: var(--bg);
  font-family: var(--font-mono);
  font-size: 10.5px;
  line-height: 1.35;
  letter-spacing: 0.02em;
  white-space: nowrap;
  opacity: 0;
  transform: translateY(3px);
  pointer-events: none;
  transition: opacity 0.18s var(--ease), transform 0.18s var(--ease);
}

/* A project with no stack has only its bare tags, and a tag has no glyph —
   the chip stretches into a labelled pill rather than sitting empty. */
.project-tag.is-label {
  width: auto;
  padding: 0 12px;
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.02em;
  white-space: nowrap;
  color: var(--muted);
}

.project-actions {
  display: grid;
  grid-template-columns: 1fr;
  gap: 10px;
}

.project-actions.is-pair {
  grid-template-columns: 1fr 1fr;
}

.project-action {
  padding: 10px;
  border-radius: 7px;
  font-size: 13px;
  line-height: 1.2;
  text-align: center;
  text-decoration: none;
  transition: background 0.2s var(--ease), border-color 0.2s var(--ease),
              color 0.2s var(--ease);
}

/* The export's solid orange button, kept as one fixed colour. Filling it with
   the project's own accent made the primary action a different colour on every
   card, and a light accent turned it into a button that all but vanished on
   paper — the accent still carries the tagline, where it is a colour rather
   than a surface something has to be legible on. */
.project-action-live {
  font-weight: 600;
  background: var(--cta);
  color: var(--cta-ink);
  border: 1px solid transparent;
}

.project-action-live:active {
  filter: brightness(1.12);
}

.project-action-repo {
  font-weight: 500;
  border: 1px solid var(--line);
  color: var(--muted);
}

.project-action-repo:active {
  border-color: var(--ink);
  color: var(--ink);
}

.noscript-note {
  padding: 0 var(--pad);
  font-size: 0.9rem;
  color: var(--muted);
}

/* ---------- footer ---------- */

.site-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 26px var(--pad) calc(34px + env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--faint);
  flex-wrap: wrap;
}

.footer-brand {
  margin: 0;
}

.footer-clock {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--faint);
  letter-spacing: 0.02em;
  transition: color 0.2s var(--ease);
}

.social-links {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.social-link {
  width: 44px;
  height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 12px;
  border: 1px solid var(--line);
  color: var(--muted);
  background: var(--bg);
  transition: background 0.2s var(--ease), color 0.2s var(--ease), border-color 0.2s var(--ease), transform 0.2s var(--ease);
}

.social-link svg {
  width: 20px;
  height: 20px;
  fill: currentColor;
}

.social-link:focus-visible,
.social-link:active {
  background: var(--ink);
  color: var(--bg);
  border-color: var(--ink);
}

/* ---------- project detail modal ---------- */

.modal-overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  background: rgba(0, 0, 0, 0.45);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s var(--ease), visibility 0.3s var(--ease);
  padding: var(--pad);
}

@media (prefers-color-scheme: dark) {
  .modal-overlay {
    background: rgba(0, 0, 0, 0.6);
  }
}

.modal-overlay.open {
  opacity: 1;
  visibility: visible;
}

.modal-content {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 4px;
  width: 100%;
  max-width: 860px;
  max-height: 85dvh;
  overflow: hidden;
  padding: 24px;
  position: relative;
  transform: translateY(12px);
  transition: transform 0.3s var(--ease);
}

.modal-overlay.open .modal-content {
  transform: translateY(0);
}

/* Modal header */
.modal-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  /* margin-bottom: 20px; */
  padding-bottom: 14px;
  border-bottom: 1px solid var(--line);
}

.modal-header-left {
  flex: 1;
  min-width: 0;
}

.modal-title {
  font-size: clamp(1.3rem, 2.5vw, 1.7rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.1;
  margin: 0 0 4px;
}

.modal-tagline {
  font-size: 0.84rem;
  color: var(--muted);
  margin: 0 0 8px;
}

.modal-meta {
  display: flex;
  align-items: center;
  gap: 12px;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--faint);
}

.modal-meta-dot {
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--faint);
}

/* Header actions (GitHub + close) */
.modal-header-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

.modal-link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--muted);
  padding: 7px 12px;
  border: 1px solid var(--line);
  border-radius: 8px;
  transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
  white-space: nowrap;
}

.modal-link:active {
  color: var(--ink);
  border-color: var(--ink);
}

.modal-link svg {
  width: 14px;
  height: 14px;
  fill: currentColor;
}

.modal-link-demo svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.modal-close {
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line);
  border-radius: 8px;
  color: var(--muted);
  background: var(--surface);
  cursor: pointer;
  flex-shrink: 0;
  font-size: 1.1rem;
  line-height: 1;
  transition: color 0.2s var(--ease), border-color 0.2s var(--ease);
}

.modal-close:active {
  color: var(--ink);
  border-color: var(--ink);
}

/* ---------- bento grid ---------- */

.bento-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr auto;
  grid-template-areas:
    "album    info"
    "stack    stack";
  gap: 12px;
  height: calc(85dvh - 110px);
  max-height: 520px;
}

.bento-cell {
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 16px;
  background: var(--card-bg);
  min-width: 0;
  overflow: hidden;
}

.bento-label {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--faint);
  margin: 0 0 10px;
}

.bento-text {
  font-size: 1rem;
  letter-spacing: 0.02rem;
  line-height: 1.5;
  color: var(--ink);
  margin: 0;
}

/* ---------- photo album cell ---------- */

.bento-album {
  grid-area: album;
  display: flex;
  flex-direction: column;
  padding: 14px;
}

.album-stack {
  position: relative;
  flex: 1;
  min-height: 0;
}

.album-photo {
  position: absolute;
  top: 50%;
  left: 50%;
  max-width: calc(100% - 48px);
  max-height: calc(100% - 48px);
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: 4px;
  cursor: pointer;
  transition: transform 0.4s var(--ease), opacity 0.4s var(--ease), box-shadow 0.4s var(--ease);
  will-change: transform;
}

/* Back photos peek out */
.album-photo[data-pos="1"] {
  transform: translate(calc(-50% + 18px), calc(-50% + 18px)) rotate(3.5deg);
  opacity: 0.6;
  z-index: 1;
}

.album-photo[data-pos="2"] {
  transform: translate(calc(-50% + 36px), calc(-50% + 36px)) rotate(7deg);
  opacity: 0.35;
  z-index: 0;
}

/* Front photo */
.album-photo[data-pos="0"] {
  transform: translate(-50%, -50%) rotate(0deg);
  opacity: 1;
  z-index: 2;
  box-shadow: 0 4px 16px -4px rgba(0, 0, 0, 0.12);
}

.album-hint {
  font-family: var(--font-mono);
  font-size: 0.58rem;
  color: var(--faint);
  text-align: center;
  margin: 8px 0 0;
  letter-spacing: 0.04em;
}

/* Both are mobile-only affordances; the desktop modal keeps the card stack. */
.sheet-handle,
.album-dots {
  display: none;
}

/* ---------- lightbox ---------- */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 200;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s var(--ease);
}

.lightbox.open {
  opacity: 1;
  pointer-events: auto;
  cursor: zoom-out;
}

.lightbox img {
  max-width: 90vw;
  max-height: 90dvh;
  object-fit: contain;
  border-radius: 4px;
  box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.2);
  transform: scale(0.95);
  transition: transform 0.4s var(--ease);
}

.lightbox.open img {
  transform: scale(1);
}

/* Tap-anywhere still closes, but it is invisible — and there is no ESC on a
   phone, which was the only other way out. */
.lightbox-close {
  position: absolute;
  top: calc(16px + env(safe-area-inset-top));
  right: calc(16px + env(safe-area-inset-right));
  z-index: 2;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--bg);
  color: var(--muted);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
}

.lightbox-close:active {
  color: var(--ink);
  border-color: var(--ink);
}

/* ---------- info cell (problem + solution stacked) ---------- */

.bento-info {
  grid-area: info;
  display: flex;
  flex-direction: column;
  gap: 12px;
  overflow-y: auto;
}

/* Thin scrollbar for info cell */
.bento-info::-webkit-scrollbar {
  width: 3px;
}

.bento-info::-webkit-scrollbar-track {
  background: transparent;
}

.bento-info::-webkit-scrollbar-thumb {
  background: var(--line);
  border-radius: 3px;
}

.bento-info-section {
  padding-bottom: 12px;
}

.bento-info-section+.bento-info-section {
  border-top: 1px solid var(--line);
  padding-top: 12px;
}

/* Stack cell */
.bento-stack {
  grid-area: stack;
  padding: 14px 16px;
}

.stack-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.stack-item {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 11px;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.02em;
  color: var(--muted);
  background: var(--bg);
}

.stack-icon {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  color: var(--project-accent, var(--muted));
  color: color-mix(in oklab, var(--project-accent, var(--muted)) 72%, var(--ink));
}

.stack-icon svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* ---------- bento responsive ---------- */

@media (max-width: 640px) {
  .bento-grid {
    grid-template-columns: 1fr;
    grid-template-rows: 270px auto auto;
    grid-template-areas:
      "album"
      "info"
      "stack";
    max-height: none;
    height: auto;
  }

  /* Bottom sheet. A centered dialog wastes a narrow viewport and parks its
     controls mid-screen; a sheet anchors them where the thumb already is. */
  .modal-overlay {
    align-items: flex-end;
    padding: 0;
  }

  .modal-content {
    width: 100%;
    max-width: none;
    max-height: 92dvh;
    padding: 0 16px calc(16px + env(safe-area-inset-bottom));
    border-radius: 18px 18px 0 0;
    border-bottom: none;
    overflow-y: auto;
    overscroll-behavior: contain;
    transform: translateY(100%);
    transition: transform 0.3s var(--ease);
  }

  .modal-overlay.open .modal-content {
    transform: translateY(0);
  }

  /* Title and close stay pinned while the body scrolls under them. */
  .modal-sticky-head {
    position: sticky;
    top: 0;
    z-index: 5;
    background: var(--bg);
  }

  .sheet-handle {
    display: block;
    padding: 10px 0 8px;
    touch-action: none;
  }

  .sheet-handle::before {
    content: "";
    display: block;
    width: 40px;
    height: 4px;
    margin: 0 auto;
    border-radius: 999px;
    background: var(--line);
  }

  .modal-link span {
    display: none;
  }

  /* The modal is the only scroll container on mobile — a capped, separately
     scrolling info cell hid "My Solution" entirely with no affordance. */
  .bento-info {
    max-height: none;
    overflow: visible;
  }

  /* Album: snap carousel instead of the absolutely-positioned card stack,
     which collapsed to an undiscoverable tap target at this size. */
  .album-stack {
    display: flex;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
  }

  .album-stack::-webkit-scrollbar {
    display: none;
  }

  .album-stack .album-photo[data-pos] {
    position: static;
    flex: 0 0 100%;
    scroll-snap-align: center;
    width: 100%;
    height: 100%;
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    transform: none;
    opacity: 1;
    box-shadow: none;
  }

  .album-dots {
    display: flex;
    justify-content: center;
    gap: 2px;
    margin-top: 4px;
  }

  .album-dot {
    width: 26px;
    height: 26px;
    padding: 0;
    display: grid;
    place-items: center;
    background: none;
    border: none;
    cursor: pointer;
  }

  .album-dot::before {
    content: "";
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--line);
    transition: background 0.2s var(--ease), transform 0.2s var(--ease);
  }

  .album-dot.active::before {
    background: var(--muted);
    transform: scale(1.35);
  }
}

/* ---------- github contributions ---------- */

/* The section ships hidden and is only revealed once real data has rendered,
   so a dead upstream leaves no trace rather than an empty frame or an error. */
.contrib[hidden] {
  display: none;
}

.contrib {
  padding: 0 0 72px;
}

.contrib-head {
  padding: 0 var(--pad);
  margin: 0 0 22px;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
}

.contrib-heading {
  font-size: clamp(1.6rem, 3.4vw, 2.4rem);
  font-weight: 400;
  letter-spacing: -0.02em;
  line-height: 1.05;
  margin: 0;
}

.contrib-heading em {
  font-style: normal;
  color: var(--muted);
}

.contrib-total {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
}

.contrib-chart {
  padding: 0 var(--pad);
}

/* One SVG rather than ~370 divs: the layout engine places a single box, and
   the cells cost nothing but paint. Sized in px by the renderer so the squares
   stay crisp instead of being scaled to fractional sizes. */
.contrib-svg {
  display: block;
  max-width: 100%;
  height: auto;
  overflow: visible;
}

.contrib-cell {
  /* Painted with a hairline of the page ground so adjacent squares never merge
     into a block at the darker end of the ramp. */
  stroke: var(--bg);
  stroke-width: 1px;
  rx: 2px;
}

.contrib-axis {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.04em;
  fill: var(--muted);
}

.contrib-legend {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 12px 0 0;
  font-family: var(--font-mono);
  font-size: 0.58rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.contrib-legend-swatch {
  width: 11px;
  height: 11px;
  border-radius: 2px;
}

@media (max-width: 640px) {
  .contrib {
    padding-bottom: 52px;
  }

  .contrib-legend {
    font-size: 0.66rem;
  }
}

/* ---------- konami easter egg ---------- */

@keyframes wobble {

  0%,
  100% {
    transform: rotate(0deg);
  }

  20% {
    transform: rotate(-2deg);
  }

  40% {
    transform: rotate(2deg);
  }

  60% {
    transform: rotate(-1.5deg);
  }

  80% {
    transform: rotate(1deg);
  }
}

.konami-wobble {
  animation: wobble 0.6s var(--ease);
}

/* ---------- hover states ----------
   Gated behind a real pointer. Touch browsers emulate :hover on tap and then
   leave it stuck until you tap elsewhere, so unguarded hover rules read as
   bugs on a phone. Press feedback lives in :active rules above. */

@media (hover: hover) and (pointer: fine) {
  .work-tab:hover {
    color: var(--ink);
  }

  .work-more-btn:hover {
    color: var(--ink);
    border-color: var(--ink);
  }

  .theme-toggle:hover {
    transform: scale(1.12);
  }

  .intro-image-wrapper:hover .intro-image {
    transform: scale(1.08);
  }

  .intro-tags li:hover {
    border-color: var(--ink);
    color: var(--ink);
  }

  /* Pointing at the name trades the weights back. Two-column layout only: in
     the stacked one the cat sits in the surname's line and a heavier surname
     would run into it. */
  @media (min-width: 901px) {
    .intro-name:hover .intro-first {
      font-weight: 300;
    }

    .intro-name:hover .intro-last {
      font-weight: 750;
    }
  }

  .scroll-cue:hover {
    color: var(--ink);
  }

  /* The component's whole hover state — the border lifts, the wash behind the
     screenshot comes up, and the shot itself rises and grows a shadow. All of
     it is CSS on the card; nothing here runs per frame. */
  .project-card:hover {
    border-color: var(--faint);
  }

  .project-card:hover .project-preview-wash {
    opacity: 1;
  }

  .project-card:hover .project-preview-swirl {
    transform: scale(var(--swirl-zoom));
  }

  .project-card:hover .project-shot {
    transform: translateY(-9px) scale(1.04);
    box-shadow: 0 24px 48px -18px rgba(0, 0, 0, 0.85);
  }

  .project-card:hover .project-tag {
    border-color: var(--faint);
  }

  .project-tag:hover {
    border-color: var(--ink);
  }

  .project-tag:hover::after {
    opacity: 1;
    transform: translateY(0);
  }

  .project-action-live:hover {
    filter: brightness(1.12);
  }

  .project-action-repo:hover {
    border-color: var(--ink);
    color: var(--ink);
  }

  .footer-clock:hover {
    color: var(--muted);
  }

  .social-link:hover {
    background: var(--ink);
    color: var(--bg);
    border-color: var(--ink);
    transform: translateY(-2px);
  }

  .modal-link:hover,
  .modal-close:hover {
    color: var(--ink);
    border-color: var(--ink);
  }

  .album-photo:hover {
    opacity: 0.85;
  }

  .album-photo[data-pos="0"]:hover {
    opacity: 1;
  }
}

/* ---------- responsive ---------- */

/* Tablets, foldables and large phones in landscape — the old stylesheet
   jumped straight from the desktop grid to a single column. */
@media (max-width: 900px) {
  /* One column. The name takes the full width, and the cat moves into the
     surname's line: "Ibrahim" at 300 is 3.155em, so the image takes what is
     left of the row after it and a gutter, and stands on the baseline just
     past ascender height — a word-sized picture finishing the line instead
     of a block floating beside two. */
  .intro {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "title"
      "lead"
      "tags";
    row-gap: 28px;
    /* "Muhammad" at 750 fills 92% of the column at 16.5cqi. */
    --name-size: min(6rem, 16.5cqi);
  }

  .intro-image-wrapper {
    grid-area: title;
    justify-self: end;
    align-self: end;
    width: calc(100% - var(--name-size) * 3.45);
    height: calc(var(--name-size) * 0.8);
    margin-top: 0;
  }

  /* A strip this wide crops hard, so the frame is centred on the gif rather
     than carrying the desktop overscan that pushes it right and down. */
  .intro-image {
    width: 100%;
    height: 100%;
    object-position: 50% 45%;
  }

  /* At 750 the surname is half an em wider and would run into the image on
     its way down, so here only the first name makes the entrance. */
  .intro-last {
    animation: none;
  }
}

@media (max-width: 640px) {
  /* --content-width already subtracts a --pad gutter from each side, and then
     every section adds var(--pad) of its own padding on top. On desktop that
     double inset is invisible; on a 320px phone it burned 40px per side and
     left only 240px of usable width. Let the sections own the gutter. */
  :root {
    --content-width: 100%;
  }

  .site-nav {
    display: none;
  }

  .work-head {
    align-items: flex-start;
    margin-bottom: 28px;
  }

  /* Clears the fixed theme toggle, which sits over the top-right corner of
     the name's column on a phone. */
  .intro {
    row-gap: 24px;
    padding-top: calc(var(--pad) + 46px + env(safe-area-inset-top));
  }

  /* Sub-0.7rem type lands around 9-10px on a phone — under the legibility
     floor. Nudge the smallest labels back up. */
  .bento-label,
  .work-tab {
    font-size: 0.72rem;
  }

  /* The labels step up a size here; without the counts three of them still
     sit on one track at 320px with the padding intact. */
  .work-tab {
    padding: 9px 12px;
  }

  .work-tabs {
    margin-bottom: 22px;
  }

  .album-hint,
  .click-counter {
    font-size: 0.66rem;
  }

  .stack-item {
    font-size: 0.74rem;
    padding: 7px 12px;
  }

  .project-tag.is-label {
    font-size: 0.7rem;
  }

  .footer-clock {
    display: none;
  }
}

/* ---------- reduced motion ---------- */

@media (prefers-reduced-motion: reduce) {

  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }

  .stagger {
    opacity: 1;
  }

  .project-card {
    opacity: 1;
    transform: none;
  }

  .project-preview-swirl {
    transform: scale(var(--swirl-zoom));
  }
}