@import url('https://fonts.googleapis.com/css2?family=Fira+Sans:wght@400;500&family=Fira+Sans+Condensed:wght@600&display=swap');

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

/* The soft pink, in ONE place. It was written twice — #f9d0e2 on the Skills pills and
   #fbd2e2 on the header band — two units apart, invisible side by side but two values to
   keep in step. The pills' number won because they were here first. */
:root {
  --pink-soft: #f9d0e2;
}

/* Kill the browser's tap highlight. WebKit/Chromium paint a translucent grey block over
   ANY tappable element the moment a finger (or a mouse press) lands on it — before the
   click even fires. It is a rectangle around the element's box, so it squares off the
   round pills and flashes grey across artwork on the cards. Nothing in this design asks
   for it, and it is not the same thing as :focus (keyboard focus rings are untouched, so
   accessibility is unaffected). The property inherits, so declaring it once on <html>
   covers every link, button and click target on every page. */
html {
  -webkit-tap-highlight-color: transparent;
  /* Reserve the scrollbar's width WHETHER OR NOT a scrollbar is showing. Without this the
     centred content sits ~15px further right on any page (or moment) with no scrollbar —
     and the homepage builds its cards from JS, so it IS scrollbar-less for the first
     paint: click the logo and it visibly slid right, then snapped back once the cards
     pushed the page tall. Same mechanics sank the view-transition pins earlier (see the
     Continuity block). One property, and the logo stands still across every navigation.
     Browsers without scrollbar-gutter simply keep today's shift — nothing breaks. */
  scrollbar-gutter: stable;
}

/* ===== Continuity between pages =====
   This site is a plain multi-page site: every click throws the document away and builds a
   new one, so the header — logo animation included — was visibly torn down and rebuilt on
   every navigation. The alternative, intercepting clicks and swapping only the middle of
   the page, would mean giving up two things this project deliberately keeps: no build
   step, and pages that open by double-click from disk (fetch is blocked on file://).

   View transitions get the same LOOK without that trade: the browser snapshots the old
   page, snapshots the new one, and cross-fades between them, so the rebuild happens
   behind a soft blend instead of a flash. The document underneath still reloads; it just
   stops being visible doing so.

   DELIBERATELY NO view-transition-name pins (tried 2026-08-01, rolled back the same day).
   Pinning the header/logo/footer morphs them between their old and new positions — and
   those positions differ by the scrollbar width whenever a long page and a short page
   meet, so the logo visibly slid SIDEWAYS on every transition. Her verdict: looks
   muddled. The plain cross-fade has nothing to slide. If pins ever come back, the layout
   shift has to go first (scrollbar-gutter: stable) — do not re-add the names alone.

   Unsupported browsers (Firefox, at the time of writing) ignore the block and navigate
   exactly as before — nothing to detect, nothing to fall back to.
   To undo all of this, delete this block. */
@view-transition {
  navigation: auto;
}

/* Respect the OS "reduce motion" setting: people who ask for less movement get the plain
   instant navigation back, not a cross-fade. */
@media (prefers-reduced-motion: reduce) {
  @view-transition { navigation: none; }
}

/* Sticky footer. On a page too short to fill the window the footer sits on the
   bottom edge instead of floating mid-screen; on a long page it just follows the
   content as before. Every page is body > header + main + footer, so making the body
   a column and letting <main> absorb the slack is the whole mechanism — no fixed
   positioning, so the footer never covers content.
   Both min-heights are deliberate: 100dvh comes second so browsers that support it
   win, because on mobile 100vh counts the strip behind the collapsing address bar
   and would leave the footer hanging just below the fold. */
body {
  background: #f1f1f1;
  font-family: 'Fira Sans', sans-serif;
  color: #2b2b2b;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* `width: 100%` is load-bearing, not belt-and-braces. .header and .content centre
   themselves with `margin-left/right: auto`, and auto margins on a flex item's cross
   axis SUPPRESS the default stretch: the item shrinks to its content and centres
   instead of filling the row. That collapsed the header from 1440px to 606px the
   moment the body became a flex column. Restating the width restores plain block
   behaviour — 100% of the row, then capped by each element's own max-width. */
body > header,
body > main,
body > footer {
  width: 100%;
}

/* `1 0 auto`, not `1`: the shorthand's basis of 0 would ignore main's own height and
   let a long page's content be squashed. Growing from its natural height is what
   pushes the footer down only when there IS spare room. */
body > main {
  flex: 1 0 auto;
}

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

/* =========================================================
   MOBILE (по умолчанию, для экранов до 768px)
   ========================================================= */

/* The coloured band. Full-bleed on purpose: it takes NO width cap and NO side padding —
   both live on .header inside it, so the colour runs edge to edge while the logo and nav
   stay on the same column as the page content. Same split as .footer / .footer-inner. */
.header-band {
  background: var(--pink-soft);
}

/* The red loses contrast on pink: #af3744 measures 4.39 against --pink-soft where it had 5.38
   against the page grey — under the 4.5 that 18px text needs. Darkened to 4.95, and scoped to
   the band so the site keeps ONE red everywhere it sits on grey. */
.header-band .nav-lang-code,
.header-band .nav-lang-caret {
  color: #a1333f;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 4, not the 16 it was: the logo is the tallest thing in here, so the vertical padding is
     the only lever on the band's thickness (16 made it 112px, and as a COLOURED slab that
     read as too much). 4 lands the band at 88 on desktop / 72 on phones and still looks like
     air, because the logo's artwork stops ~10px short of its own box — the optical gap is
     ~14. Side padding is overridden per breakpoint by the shared cap rules below. */
  padding: 4px 32px;
  transition: padding 0.6s cubic-bezier(0.4, 0, 0.2, 1), max-width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.logo {
  /* The logo is a LINK home now (the strongest web convention there is) — block so the
     circle keeps its box on an <a>, and a gentle swell on hover says "clickable" in the
     same touch language as the media tiles. */
  display: block;
  /* The logo is the tallest thing in the header (68 against the nav row's 50), so it is
     what sets the header height — every pixel it grows pushes the whole page down by the
     same amount. 64/80 is one deliberate step up from 56/68. */
  width: 64px;
  height: 64px;
  /* NO ring and NO circular crop — the logo draws its own red contour, in both states.
     There used to be `border: 1px solid #af3744; border-radius: 999px; overflow: hidden`
     here: a picture frame for the SQUARE photo the static logo once was, taken back off by
     a [data-animated] override as soon as the animation replaced it. Now icons/logo.png IS
     the animation's rest frame, contour and all, so the frame only ever double-ringed it on
     the fallback path — a red hairline sitting just outside the drawn one. */
  flex-shrink: 0;
  /* transform is no longer animated here — the hover swell is gone (see below); width and
     height still ease because the logo changes size at the 768 breakpoint. */
  transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1), height 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* NO hover swell on the logo — the animation IS the hover affordance now.
   scale(1.06) was written for a static picture, where a gentle grow said "clickable" in
   the same language as the media tiles. With the animation it does active harm: the logo
   becomes 84.8px instead of 80, so every stroke tuned to a whole pixel (1.00 and 2.00)
   lands on 1.06 and 2.12, and the browser scales an already-rendered layer rather than
   redrawing the vector. The result was a logo that went BLURRY exactly while it animated.
   A character that stands up and looks at you is a stronger "this is interactive" signal
   than 6% of growth ever was. */

/* Press feedback: a quick dip — squash to 90% and straight back, her call ("уменьшился и
   вернулся"). This LOOKS like it violates the no-scaling rule above, but the rule is about
   SUSTAINED scaled states: the hover swell held the logo at 84.8px for seconds, fractional
   strokes sitting still long enough to read as blur. A 200ms one-shot only passes THROUGH
   fractional sizes while in motion — where the eye cannot judge stroke crispness — and
   lands back on scale 1, where every stroke is whole again. That is also why this is a
   keyframed one-shot on a JS-toggled class and NOT a transform held on :active — :active
   would pin the scaled (blurry) state for as long as the button is held. The class is
   applied on pointerdown and removed on animationend (wireLogoLink), so the dip always
   completes no matter how fast the press was. If this ever reads blurry on a real screen,
   the fallback is the translateY(1px) nudge this replaced. */
/* THIS EXACT SHAPE IS THE ONE SHE APPROVED — 0.9 at 35%, 0.2s, symmetric. Two later
   variants were tried the same day and rolled back on her word, so know the dead ends
   before "improving" it again:
     - deeper with a HOLD at 0.8 through the navigation window (the idea was that the
       next page painting a full-size logo would read as the release) — in practice the
       80%→100% size jump at the cut read WORSE than the pose seam it was meant to mask;
     - faster/deeper one-shot (0.85 in 0.16s) — replaced by this rollback before she
       ever saw it, untested.
   The residual pose seam at the cut (character mid-duck → hidden on the next page) is
   the accepted trade: her verdict on this state was "хорошо работает... скачок не очень
   противный, почти ок". The gesture mostly completes before a typical navigation
   (~150-300ms) tears the page down. */
@keyframes logo-press {
  0%   { transform: scale(1); }
  35%  { transform: scale(0.9); }
  100% { transform: scale(1); }
}
.logo.is-pressed { animation: logo-press 0.2s ease; }
@media (prefers-reduced-motion: reduce) {
  .logo.is-pressed { animation: none; }
}

.logo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.nav {
  display: flex;
  align-items: center;
  gap: 4px;
}

.nav-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 8px;
  font-size: 18px;
  cursor: pointer;
  transition: font-size 0.6s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s ease-in-out;
}

/* The underline lives as its own ::after element PRECISELY so its appearance can be
   animated (the author's original intent for this markup). At rest the bar is scaled to
   zero width; on hover it GROWS out from the centre (scaleX 0 → 1) — transform only, so
   nothing around it ever shifts. Dark, not green (green hover was tried and retired
   2026-07-21 with the rest of the green text links): the whole site now speaks one
   text-link language — black type + a dark centre-out line, same as the card titles and
   project tags. Green belongs to button-shaped controls only. */
.nav-item::after {
  content: "";
  display: block;
  height: 2px;
  width: 100%;
  background: #2b2b2b;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform 0.25s ease;
}

/* The :not() guards matter: a bare `.nav-item:hover::after` (0-2-0) would OUTRANK
   `.nav-item--lang::after` (0-1-0) and grow a DARK bar under the red En on hover. */
.nav-item:not(.nav-item--active):not(.nav-item--lang):hover::after {
  transform: scaleX(1);
}

/* Active and En wear their bars statically. A draw-in animation on page load was tried
   and removed: right after the hover already drew a line under the same item, the page
   drawing it AGAIN read as the same gesture twice — the page change itself is the
   feedback. The animated growth belongs to the hover hint only. */
.nav-item--active::after {
  background: #2b2b2b;
  transform: scaleX(1);
}

/* Active vs hover used to draw the SAME dark line, so pointing at another page left TWO
   items underlined — you couldn't tell "where I am" from "where I'm pointing". Fix: the
   underline follows the pointer. When another PAGE link is hovered, the current page hands
   its line over (its ::after recedes), so exactly one dark line shows; it slides back the
   moment the pointer leaves. `:has()` scopes this to real page links — hovering the active
   item itself, or the red "En" language toggle, leaves the current-page line in place. */
.nav:has(.nav-item:not(.nav-item--active):not(.nav-item--lang):hover) .nav-item--active::after {
  transform: scaleX(0);
}

/* Language switcher — a compact DROPDOWN. Not a page link, so no underline (that's reserved for
   the current PAGE). The header shows ONLY the current language + a caret; the others live in a
   small menu that opens on tap. This keeps the header narrow enough for phones, where EN/FI/BY
   in a row overflowed. Built in JS (i18n.js) from I18N_LANGS, so it scales to any number. */
.nav-lang {
  position: relative;
  font-size: 18px;
}
.nav-lang-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: none;
  border: 0;
  font: inherit;
  /* The trigger always shows the CURRENT language, so it wears the brand red — the same mark
     the current row gets inside the open menu. One consistent rule: red = "you are here". */
  color: #af3744;
  cursor: pointer;
  line-height: 1;
  /* Extra 6px at the bottom mirrors the space a page link reserves under its text for the
     underline (4px gap + 2px line), so the code sits on the same baseline as Works/About. */
  padding: 8px 8px 14px;
}
.nav-lang-caret {
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid currentColor;   /* currentColor = the red trigger text, so caret matches the letter */
  transition: transform 0.2s ease;
}
.nav-lang.is-open .nav-lang-caret { transform: rotate(180deg); }
/* The menu: a small flat panel. Square corners on purpose — this project reserves rounding for
   the 999px pills/badges and is otherwise rectangular. */
.nav-lang-menu {
  position: absolute;
  top: 100%;
  right: 4px;
  margin: 0;
  padding: 4px;
  min-width: 64px;
  list-style: none;
  background: #e6e2e3;   /* same warm grey as the footer — the site has no white surfaces */
  display: none;
  z-index: 100;
}
.nav-lang.is-open .nav-lang-menu { display: block; }
.nav-lang-option {
  padding: 6px 14px;
  color: #2b2b2b;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.15s ease, color 0.15s ease;
}
/* Match the site's own convention — hovering a language UNDERLINES (like the nav), not a
   menu-style background highlight. The menu lists only switch-to languages, so every row reacts. */
.nav-lang-option:hover { text-decoration: underline; }

.content {
  max-width: 586px;
  margin: 0 auto;
  padding: 32px 32px 60px;
  transition: padding 0.6s cubic-bezier(0.4, 0, 0.2, 1), max-width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.hero {
  margin-bottom: 32px;
}

.hero-title {
  font-family: 'Fira Sans Condensed', sans-serif;
  font-weight: 600;
  font-size: 40px;
  color: #b33a48;
  margin-bottom: 20px;
  transition: font-size 0.6s cubic-bezier(0.4, 0, 0.2, 1), margin-bottom 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.hero-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

/* ===== Homepage intro (hero) =========================================
   Small greeting, slogan with one red accent, quiet role line, the action pair
   (Contact / CV), and the chestnut mascot. Designed in test-hero.html across two
   sessions (2026-07-22 and 2026-07-31) and promoted here; that page stays as the
   sandbox and still carries the rejected variants.

   THREE BANDS, and they are not arbitrary:
   - up to 767  : text on its own line, then buttons + mascot beside each other
   - 768-1001   : the same shape, desktop type sizes
   - from 1002  : all three in one row — this is also where the cards go 2-up, so
                  the hero switches together with the rest of the page
   The pair never breaks apart: the mascot belongs with the CTA it stands next to,
   and every arrangement that separated them was tried and rejected. */
/* ONE hairline, at the bottom, the footer's exact rule (1px #c9c5c6) and the width of the
   content column like .footer-line. There was a matching one on top for a while; the pink
   band took its job — an edge of colour separates harder than a grey hair, and the two of
   them 12px apart read as a doubled rule.
   The padding below it is what the line needs to exist: a rule drawn tight against the type
   reads as an underline of the last word rather than an edge of the block. */
.intro {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-start;
  gap: 20px 16px;
  /* Bottom padding is SMALLER than the gap to "My Works" below the rule (24 against 32) —
     deliberately, and this is the whole point of the line's position. A rule belongs to
     whichever side it sits closer to; with more air above it than below, it stopped closing
     the hero and started underlining the next heading. */
  padding: 4px 0 24px;
  border-bottom: 1px solid #c9c5c6;
  /* Rides up into .content's shared 32px top padding, leaving 28 from the band to the first
     line of type. That padding is right for a page that opens with a heading; the hero opens
     under a colour edge and wants to sit closer to it. Hero-only, so About and the project
     pages keep the 32 they share. */
  margin-top: -4px;
}

/* Full-basis text is what forces the break: the pair lands on the next line. */
.intro-text {
  flex-basis: 100%;
  order: 0;
  min-width: 0;
}

.intro-hi {
  font-size: 16px;
  color: #646464;
  margin-bottom: 12px;
}

/* 32 on phones, 40 from 768 — and the phone value is a measurement, not a taste.
   The slogan is written as TWO lines (the <br> in the markup is the design). At
   40px its lines measure 334 and 337 against a 311px column at 375, so BOTH wrap
   and the intended two lines become four — 179px instead of 72. At 32 they are
   268 and 269, which still holds at 360. It deliberately does NOT step up to 48
   at any width: red "My Works" sits at 64 right below, and two red blocks one
   tier apart read as one confused block with neither winning. */
.intro-slogan {
  font-family: 'Fira Sans Condensed', sans-serif;
  font-weight: 600;
  font-size: 32px;
  line-height: 1.12;
  color: #2b2b2b;
  margin-bottom: 16px;
}

.intro-slogan .accent { color: #b33a48; }

/* Role line — quiet grey buffer between the slogan's red accent and the red
   "My Works" below, so the two red display blocks don't merge. Person-form words
   ("designer", not "Design") so it never echoes the filter pills. */
.intro-role {
  font-size: 16px;
  color: #646464;
  margin-top: 14px;
}

/* The button pair. One filled primary (Contact) + one outlined secondary (CV);
   both share one width, each icon+label group centred in its button. */
.intro-side {
  order: 1;
  flex: 0 0 auto;
  width: fit-content;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 12px;
}

/* Phone-sized controls. The desktop 18px/22px/12-20 pair is 181x48 and eats 58%
   of a 311px line — and since the mascot takes whatever is left, the buttons are
   what decide how big the drawing can be. At 157x44 the split is even and the
   mascot grows from 114 to 138 at 375.
   44px height is the FLOOR, not a preference: the Apple HIG minimum and WCAG
   2.5.5 (AAA). Do not trim further. The 12px between them (8 elsewhere) is the
   other half of the same idea — the guidelines put a floor under target spacing
   and no ceiling, and buttons sitting AT the minimum want the extra air. */
.intro-side .cv-button,
.intro-side .btn-outline {
  font-size: 16px;
  padding: 11px 16px;
  gap: 8px;
}

.intro-side .cv-button img,
.intro-side .btn-outline img {
  width: 20px;
  height: 20px;
}

/* The mascot takes the rest of the line. Fluid on purpose: phones run 320-430 and
   a fixed size would either overflow the narrow ones or leave a hole on the wide
   ones (123px at 360, 138 at 375, 193 at 430). */
.intro-mascot {
  order: 2;
  flex: 1 1 0;
  aspect-ratio: 1 / 1;
  max-width: 200px;
  align-self: center;
}

.intro-mascot > * {
  display: block;
  width: 100%;
  height: 100%;
}

.intro-side .cv-button {
  margin-bottom: 0;
  justify-content: center;
  width: auto;
  border: 1px solid transparent;   /* mirrors the outline's border → same inner geometry */
}

.btn-outline {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;   /* 2px tighter than the green button's 12 — the download glyph carries
                  more inner air than the envelope, so the two gaps READ equal */
  font-size: 18px;
  font-weight: 500;
  padding: 12px 20px;
  border-radius: 999px;
  border: 1px solid #2b2b2b;
  color: #2b2b2b;
  transition: transform 0.15s ease;
}

.btn-outline:hover { transform: scale(1.05); }

/* The icons ship in brand red — blacken to match the outline button's dark text
   (same trick as the About socials). The green button needs nothing: the global
   .cv-button img rule already turns its icon white. */
.btn-outline img {
  width: 22px;
  height: 22px;
  filter: brightness(0);
  opacity: 0.83;
}

/* 24 while the hero is a two-line block; 40 once it becomes a single row (see the
   1002 block). The distance that separates groups in a row starts pulling a
   stacked block apart instead. */
/* 28 is an OPTICAL number, not a symmetric one. The gap above the hero (band → "Hello") and
   the gap below the rule (→ "My Works") are meant to read equal, but "My Works" is set at
   40-64px against the greeting's 16, and a big size carries far more empty air inside its own
   line box — 11px of it at 64, 7 at 40, against 2 at 16. So equal CSS numbers look unequal:
   at 40 here the lower gap read 51 against 38. 28 + that internal air lands both at ~38.
   Same value in every band, which is why there is no override in the 1002 block. */
.intro + .hero { margin-top: 28px; }

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.tag {
  font-family: 'Fira Sans', sans-serif;
  font-size: 14px;
  padding: 7px 14px;
  border-radius: 999px;
  border: 1px solid #2b2b2b;
  background: transparent;
  cursor: pointer;
  color: #2b2b2b;
  transition: font-size 0.6s cubic-bezier(0.4, 0, 0.2, 1), padding 0.6s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s ease-in-out, color 0.2s ease-in-out, border-color 0.2s ease-in-out, transform 0.15s ease;
}

/* Hover only on the real filter buttons (the visual editor reuses .tag for plain
   non-clickable labels, and those must not react). The pill keeps its colours and
   SWELLS instead — the same touch language as the card thumbs and the CV button
   (transform only, so the row never reflows). The green recolour was retired
   2026-07-22 as too faint to read as a hover. */
button.tag:hover {
  transform: scale(1.06);
}

.tag--active {
  background: #498257;
  border-color: transparent;
  color: #fff;
  font-weight: 500;
}

/* Cards: mobile = one column, BIG images. Gaps sit on the site's 16px module
   (the same unit the mosaic and section grids use). */
.cards {
  display: flex;
  flex-direction: column;
  gap: 32px;
  transition: gap 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.card {
  display: flex;
  flex-direction: column;
  gap: 16px;
  width: 100%;
  transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* No placeholder fill. The cell used to sit on #d9d9d9 until the cover arrived, which
   painted a grid of grey slabs across the page on every load and read as clutter rather
   than as "loading". Transparent — not #f1f1f1 — so it simply IS the page: one less
   colour to keep in step if the background ever changes, the same reasoning as keeping
   the soft pink in one place. The box still holds its square, so nothing reflows. */
.card-thumb {
  width: 100%;
  aspect-ratio: 1 / 1;
  background: transparent;
  overflow: hidden;
}

.card-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Hover (2026-07-21, unified with the project-page media): the WHOLE thumb swells
   gently toward the cursor — the same 1.02 tile-swell every media cell on the project
   pages uses, so hovering artwork feels identical across the site. (An inner
   crop-zoom was tried first and replaced for consistency: project cells have no crop
   frame to zoom inside of.) ±4px at card size — stays inside the 16px gaps; transform
   only, the grid never reflows. Pairs with the title's underline bar below. */
.card-thumb {
  transition: transform 0.25s ease;
}

.card:hover .card-thumb {
  transform: scale(1.02);
}

/* No bottom padding: the old value reserved room for the 2-line titles/descriptions the
   narrow 249px cards produced. At a third-of-the-row width the text sits on one line, so
   the vertical air between rows is just the grid's own 32px row gap — nothing extra. */
.card-info {
  padding-bottom: 0;
}

/* Hover: the title stays BLACK and an underline bar GROWS in from the centre — the
   exact gesture the header nav uses (its ::after bar), picked over green/red recolours,
   which both read too faint on the grid (tried live 2026-07-21). inline-block makes the
   bar hug the text width, like the nav items; the ::after always occupies its 2+4px, so
   nothing shifts on hover — it only scales from 0. */
.card-title {
  display: inline-block;
  font-size: 24px;
  font-weight: 500;
  /* #383838 — a purely OPTICAL softening (halfway between the chrome's #2b2b2b and
     #444, which read as too obviously lighter): content headings lose their inky edge
     without registering as a different colour. Keep in sync with the ::after bar below
     and .section-title on project pages. */
  color: #383838;
  /* No own margin: the ::after bar below reserves the whole title→desc gap. When the
     bar carried 4px margin ON TOP of this 4px, the gap grew 4 → 10px, the description
     drifted away and the card stopped reading as one unit. */
  margin-bottom: 0;
}

.card-title::after {
  content: "";
  display: block;
  height: 2px;
  width: 100%;
  /* The bar reads as an UNDERLINE of the title, not a divider: it hugs the text (2px,
     same feel as the nav underline) and keeps its own air below (4px) so on hover it
     never touches the description. Total title→desc gap is a constant 8px — compact
     enough that the card reads as one block, and nothing shifts when the bar appears. */
  margin-top: 2px;
  margin-bottom: 4px;
  background: #383838;   /* matches the softened title — a darker bar under softer text reads as a mistake */
  transform: scaleX(0);
  transform-origin: center;
  transition: transform 0.25s ease;
}

.card:hover .card-title::after {
  transform: scaleX(1);
}

.card-desc {
  font-size: 16px;
  color: #646464;
  /* 24px, the same as every other 16px text on the site (details values, mobile
     description). It was 1.3 (20.8px) — a one-off with no reason behind it. */
  line-height: 24px;
}

/* Empty state — a filter that matches nothing must SAY so, not show a silent void.
   Centred, quiet, with a one-click way back to everything. The anim box is the Lottie
   slot for the builder-lemon animation: zero size while empty, so today the state is
   just text + button; the animation drops in later with no layout change here. */
.cards-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: 48px 0 64px;
}
.cards-empty[hidden] { display: none; }
.cards-empty-anim { width: min(280px, 100%); }
.cards-empty-anim:empty { display: none; }
.cards-empty-title {
  font-size: 24px;
  font-weight: 500;
  margin: 16px 0 8px;
}
.cards-empty-text {
  font-size: 16px;
  line-height: 24px;
  color: #646464;
  margin-bottom: 24px;
}

/* Footer.
   The grey band is deliberately full-bleed: .footer itself takes no width cap and
   no side padding, so its background always reaches both edges of the window. All
   the page-container behaviour — the 987/1143/1440/1737 caps and the 32/80/150 side
   padding — sits on .footer-inner instead, with exactly the numbers .header and
   .content use, so the text inside still lines up with the rest of the page.
   That inner wrapper is why the caps are not applied to .footer-top/.footer-line/
   .footer-bottom individually: <hr> draws its border across its whole border box,
   padding included, so a padded .footer-line would stretch the rule 2x32px wider
   than the text above it. Inside a padded wrapper it stays inset, as before. */
.footer {
  background: #e6e2e3;
}

.footer-inner {
  padding: 32px;
}

.footer-top {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.footer-cta {
  display: flex;
  flex-direction: column;
  gap: 16px;
  width: 100%;
}

.footer-heading {
  font-size: 24px;
  font-weight: 500;
}

.footer-email {
  display: flex;
  align-items: center;
  gap: 8px;
}

.footer-email a {
  font-size: 16px;
  word-break: break-all;
}

.mail-icon {
  width: 28px;
  height: 28px;
  flex-shrink: 0;
}

.footer-socials {
  display: flex;
  align-items: center;
  gap: 16px;
}

.footer-socials img {
  width: 32px;
  height: 32px;
}

.footer-line {
  border: none;
  border-top: 1px solid #c9c5c6;
  margin: 24px 0;
}

.footer-bottom p {
  font-size: 14px;
  color: #646464;
}

/* =========================================================
   TABLET (от 768px) — карточки уже в сетке, меньше
   ========================================================= */
@media (min-width: 768px) {
  /* 60, not 80: on a 768 tablet 80+80 ate 21% of the screen and the artwork looked
     modest. 60 gives the content +40px while the margins still read as generous.
     (Same number on .project-content / .about-content / .error-content below.) */
  .header,
  .content,
  .footer-inner {
    padding-left: 60px;
    padding-right: 60px;
  }

  .content {
    max-width: none;
  }

  /* 80 is the settled size — 100 was tried and read as too big for the header.
     The consequence lives in the ANIMATION, not here: a stroke of S units in the 200
     composition lands on screen at S x 80 / 200 = S x 0.4 pixels. So whole pixels need
     S = 2.5 / 5 / 7.5 / 10 for 1 / 2 / 3 / 4 px. The character's 4 gives 1.6 — the
     fractional width that cannot sit on the pixel grid. */
  .logo {
    width: 80px;
    height: 80px;
  }

  .nav-item {
    font-size: 24px;
  }

  .nav-lang {
    font-size: 24px;
  }

  /* Intro, 768-1001: same shape as the phone (text on its own line, buttons and
     mascot beside each other under it) but at desktop type sizes and with the
     mascot back to a fixed 200. */
  .intro {
    gap: 24px;
    padding-top: 8px;
  }

  .intro-slogan {
    font-size: 40px;
  }

  .intro-side {
    gap: 8px;
  }

  .intro-side .cv-button,
  .intro-side .btn-outline {
    font-size: 18px;
    padding: 12px 20px;
  }

  .intro-side .cv-button { gap: 8px; }
  .intro-side .btn-outline { gap: 10px; }

  .intro-side .cv-button img,
  .intro-side .btn-outline img {
    width: 22px;
    height: 22px;
  }

  .intro-mascot {
    flex: 0 0 auto;
    width: 200px;
    height: 200px;
  }

  .hero-title {
    font-size: 64px;
  }

  .tag {
    font-size: 16px;
    padding: 8px 12px;
  }

  /* Cards: a fluid grid on the mosaic module — a card is a SHARE of the row (1/2 here,
     1/3 from 1002px, 1/4 only on the widest screens), not a fixed 249px block. Squares
     get noticeably bigger (~370px at the 1440 cap) and the 16px column gap makes the
     thumbnails read as one field, the same rhythm as the project-page mosaic. Fluid
     tracks also mean the container caps below no longer need recalculating when the
     card size changes. */
  .cards {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 32px 16px;
  }

  /* (.related-cards .card keeps its fixed 249px from the base rules — same size at
     every width, see the related-section block.) */

  .footer-top {
    flex-direction: row;
    align-items: flex-start;
    justify-content: space-between;
  }

  .footer-cta {
    width: 450px;
  }

  .footer-socials {
    align-items: flex-end;
    height: 101px;
  }
}

/* =========================================================
   3 КАРТОЧКИ В РЯД (кап 987px) — тэги встают в ряд с заголовком
   и контейнер ограничен по ширине ровно 3 карточек.

   ПОЧЕМУ ПОРОГ 1002, А НЕ 987 (то же для 1455 и 1752 ниже):
   медиазапрос в браузере меряет ширину окна ВМЕСТЕ с полосой
   прокрутки, а раскладка получает ширину БЕЗ неё (~15px). Если
   порог равен капу, то в окне 987..1002 запрос уже сработал, но
   до капа реально не хватает — контейнер становится резиновым и
   перестаёт быть точным кратным карточке: навигация и тэги уезжают
   вправо от последней карточки в ряду. Порог = кап + 15 убирает
   это окно: кап включается только когда он целиком помещается.
   ========================================================= */
@media (min-width: 1002px) {
  .header,
  .content,
  .footer-inner,
  .project-content {
    max-width: 987px;
    margin-left: auto;
    margin-right: auto;
  }

  .cards {
    grid-template-columns: repeat(3, 1fr);
  }

  /* The gap is a FLOOR, not decoration: space-between alone let the title and the
     filter pills collide once the words got longer. Measured at 1280 — English
     leaves 116px between them, Finnish 155, and Belarusian only 7 ("Мае працы" is
     wider AND every pill is wider), which reads as the two colliding.
     `gap: 16px 48px` guarantees 48 between them, and flex-wrap lets the pills drop
     to their own line in the language where even that does not fit, instead of
     squeezing. Translated UI grows — English is the SHORTEST of the three here, so
     any width tuned on it has no margin left for the others. */
  /* flex-end, not center: side by side, the pills are meant to SIT ON the title's baseline —
     the bottom of a pill level with the bottom of the "r". Centring put them 3px above it,
     which reads as almost-but-not-quite aligned. */
  .hero-row {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 16px 48px;
  }

  .hero-title {
    margin-bottom: 0;
  }

  .tags {
    flex-wrap: nowrap;
    /* Bottoms are level by flex-end; this lifts the pills off the title's DESCENDER space
       back up to the baseline. 17 = the h2's box bottom minus its baseline at 64/normal in
       Fira Sans Condensed (measured, not guessed) — re-measure it if the title size changes.
       When the row WRAPS (the long BY title) the 17 falls below the pills instead, which
       lands the pill-to-cards gap at exactly the 49 it is in English. Checked, not assumed. */
    margin-bottom: 17px;
  }

  /* Intro becomes ONE row here: text left, then the mascot and the button pair
     together on the right. It is tight by design — text 337 + mascot + buttons 181
     + gaps against 867 of inner width at the narrow end of this band — which is
     why the whole thing stacks below 1002 instead of squeezing.

     The mascot's four margins each do a separate job, none of them cosmetic:
     - margin-left: auto — pushes it and the buttons into ONE group. With plain
       space-between all three spread evenly (68/68) and the eye gets three
       landing points instead of two. Grouping only reads if the inner gap is
       clearly smaller than the outer: it is 24 against 161.
     - margin-right: -24px — tightens that inner gap from the row's own 48.
     - negative top/bottom — the drawing is taller than the text, so without them
       it sets the row height and the hero grows a band of empty air above and
       below. Bleeding it past the row hands the height back to the text. The
       values are capped by the header: pull more and the mascot climbs into the
       nav (at these it clears 14px above and below).
     Watch the drawing, not the box: the ink stops ~10px short of the box edge, so
     the optical gaps are ~10px larger than the numbers. */
  .intro {
    flex-wrap: nowrap;
    justify-content: space-between;
    gap: 48px;
    /* -32 cancels .content's 32 entirely: the block's box starts flush with the band, and the
       24 below is what you actually see between the colour edge and the first line. Flush is
       safe because the mascot's bleed (-24) is exactly that padding — it reaches the band's
       edge and stops, it never climbs into the pink. */
    margin-top: -32px;
    /* Both values have a FLOOR here, and it is the mascot: it bleeds -24 above the row and
       -28 below it, so padding under those numbers pushes the chestnut into the pink band or
       across the rule. 36 leaves it 12px of box above (~22 optically, the ink stops short);
       32 leaves 4 below (~14). That 32 is also what puts the rule closer to the hero than to
       "My Works" — see the base rule. */
    padding: 36px 0 32px;
  }

  .intro-text {
    flex-basis: auto;
  }

  .intro-mascot {
    order: 0;
    /* Lifted 8px: -24/-28 became -32/-20. The SUM of the two bleeds is unchanged (52), which
       is what matters — it is what keeps the chestnut from setting the row height (200-52=148
       against the text's 156). So this is a pure vertical shift, nothing else on the row
       moves. Clearances after it: 8px of box to the pink band above, 16 to the rule below —
       and ~10 more of each optically, since the drawing stops short of its own edge. */
    margin: -32px -24px -20px auto;
  }

  .intro-side {
    order: 0;
  }

}

/* =========================================================
   DESKTOP (от 1200px) — те же 3 в ряд, но с более крупными полями,
   контейнер по-прежнему ограничен ровно 3 карточками
   ========================================================= */
@media (min-width: 1200px) {
  /* 120, not 150: this band is the SMALL-LAPTOP band (1280x800 lands here), and
     150+150 against the 1143 cap left the content only 843px — a third of the screen
     was margin. 120 gives the artwork +60px. The full 150 comes back at 1455+ (below),
     where the 1440 cap has room to spare — wide screens look exactly as before. */
  .header,
  .content,
  .footer-inner,
  .project-content {
    max-width: 1143px;
    padding-left: 120px;
    padding-right: 120px;
  }

  .nav {
    gap: 16px;
  }
}

/* =========================================================
   4 КАРТОЧКИ В РЯД (кап 1440px) — как в оригинальном дизайне 1440px.
   Порог 1455 = 1440 + полоса прокрутки, см. пояснение выше.
   ========================================================= */
@media (min-width: 1455px) {
  .header,
  .content,
  .footer-inner,
  .project-content {
    max-width: 1440px;
    /* Full 150 margins return here: the 1440 cap has room to spare, and wide screens
       must look exactly as they did before the small-laptop band dropped to 120. */
    padding-left: 150px;
    padding-right: 150px;
  }
}

/* =========================================================
   САМЫЕ ШИРОКИЕ ЭКРАНЫ (кап 1737px, порог 1752 = кап + полоса прокрутки).
   Исторически капы 1440/1737 выводились из фикс-карточки 249px
   (4*249+3*48+300 = 1440; 5*249+4*48+300 = 1737). Карточки теперь
   резиновые (доли ряда, см. .cards), так что от той арифметики капы
   больше не зависят — они сохранены как шаг роста страницы и точка,
   где сетка переходит с 3 на 4 карточки в ряду.
   ========================================================= */
@media (min-width: 1752px) {
  .header,
  .content,
  .footer-inner,
  .project-content {
    max-width: 1737px;
  }

  .cards {
    grid-template-columns: repeat(4, 1fr);
  }
}

/* =========================================================
   ABOUT PAGE
   ========================================================= */

.about-content {
  max-width: 586px;
  margin: 0 auto;
  padding: 32px 32px 60px;
  display: flex;
  flex-direction: column;
  gap: 40px;
  transition: padding 0.6s cubic-bezier(0.4, 0, 0.2, 1), max-width 0.6s cubic-bezier(0.4, 0, 0.2, 1), gap 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.about-title {
  font-family: 'Fira Sans Condensed', sans-serif;
  font-weight: 600;
  font-size: 40px;
  color: #b33a48;
  margin-bottom: 48px;
  transition: font-size 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

.about-bio {
  display: flex;
  flex-direction: column;
  gap: 16px;
  font-size: 16px;
  line-height: 1.5;
  margin-bottom: 24px;
}

.about-bio .accent {
  color: #af3744;
}

/* Filled green pill, not the old outline: green is the site's established ACTION
   colour (active filter pills, tag links, the "More details" toggle), so the page's
   main call-to-action wears it too. Red was considered and rejected — right under the
   red "About Me" title a red button competes with the heading instead of inviting a
   click. Hover deepens the green, matching how the other green controls behave. */
.cv-button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  width: fit-content;
  padding: 12px 20px;
  border: none;
  border-radius: 999px;
  font-size: 18px;
  font-weight: 500;
  color: #fff;
  background: #498257;
  margin-bottom: 24px;
  /* transform is visual-only (no reflow), so the hover grow can't shift the layout —
     the same trick the video play badge uses. Origin at the left edge keeps the button
     glued to the text column's left line while it grows. */
  transform-origin: left center;
  transition: background 0.2s ease-in-out, transform 0.15s ease;
}

.cv-button:hover {
  background: #2f5a3b;
  transform: scale(1.05);
}

.cv-button img {
  width: 22px;
  height: 22px;
  /* The download glyph is drawn in a dark/brand fill — force it white to sit on the
     green fill (brightness(0) blackens every fill, invert(1) flips it to white). */
  filter: brightness(0) invert(1);
}

/* The Contact/CV pair on About (tuned for the parked homepage hero, promoted here):
   stacked, both exactly the wider button's content width, icon+label groups centred.
   Same geometry rules as the hero's .intro-side — kept separate so either page can
   change without dragging the other along. */
.about-actions {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  width: fit-content;
  gap: 8px;
  /* One even rhythm down the whole contact stack: CV → Contact → e-mail → socials all
     16px apart, so nothing reads as grouped or orphaned — just a calm, regular column. */
  gap: 16px;
  margin-bottom: 16px;
}

.about-actions .cv-button {
  margin-bottom: 0;
  justify-content: center;
  width: auto;
  border: 1px solid transparent;   /* mirrors the outline's border → same inner geometry */
}

.about-contact {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;   /* same even 16px rhythm down to the socials */
  font-size: 16px;
}

.about-mail {
  display: none; /* hidden on mobile, matches design */
}

/* About reuses .footer-socials for the icon row, but the FOOTER variant carries
   `height: 101px; align-items: flex-end` (aligning icons with the CTA text there) —
   on About that same rule opened a ~70px hole above the icons and shoved them away
   from the e-mail line. Neutralize it here (this rule sits later in the file, so it
   wins at equal specificity) and let the icons sit right under the contact line.
   Icons are also stepped up 32 → 40px: on a page where they're CONTENT (the main
   contact block), not footer chrome, 32px read too small. */
.about-socials {
  margin-bottom: 8px;
  height: auto;
  align-items: center;
}

.about-socials img {
  width: 40px;
  height: 40px;
  /* The SVGs are drawn in the brand red (#B33A48 fills, transparent knockouts), which
     read as decoration next to the black Skills/Tools glyphs (star.svg / pen.svg,
     #2b2b2b). brightness(0) turns every fill black; the 0.83 opacity lets the light
     page bg through just enough to land at ≈#2b2b2b instead of harsh pure black.
     Scoped to About — the footer keeps the red originals. */
  filter: brightness(0);
  opacity: 0.83;
  transition: filter 0.2s ease-in-out, opacity 0.2s ease-in-out, transform 0.15s ease;
}

/* Hover brings back the icon's own brand red and grows it a touch — the same
   colour-plus-scale touch language as the CV button and the video play badge
   (transform only, so nothing around them ever shifts). 1.08 rather than the
   badge's 1.06: a 40px circle needs a bit more relative growth to be felt. */
.about-socials a:hover img {
  filter: none;
  opacity: 1;
  transform: scale(1.08);
}

.about-right {
  display: flex;
  flex-direction: column;
  gap: 48px;
}

.tag-section {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.tag-section-title {
  display: flex;
  align-items: center;
  gap: 10px;
}

.tag-section-title img {
  width: 26px;
  height: 26px;
}

.tag-section-title h3 {
  font-size: 20px;
  font-weight: 500;
}

.tag-group {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.pill {
  padding: 6px 12px;
  border-radius: 999px;
  font-size: 16px;
}

.pill--skill {
  background: var(--pink-soft);
  color: #2b2b2b;
}

/* Soft fill, NOT an outline. Sitewide grammar: an outlined pill (or a green filled one)
   with a hover = "click me" (the Works filters, the CV button); a flat soft-filled pill
   with no border and no hover = "just information". Tools used to be outlined red —
   visually identical to the clickable filters — so they begged to be clicked. Now both
   About lists follow the information style: Skills pink, Tools warm grey. */
.pill--tool {
  background: #e7e3e4;
  color: #2b2b2b;
}

/* ===== ABOUT: TABLET (from 768px) ===== */
@media (min-width: 768px) {
  .about-content {
    max-width: none;
    padding: 32px 60px 100px;
  }

  .about-title {
    font-size: 64px;
    margin-bottom: 48px;
  }

  .about-mail {
    display: block;
  }

  .tag-section-title h3 {
    font-size: 24px;
  }

  .pill {
    font-size: 18px;
    padding: 6px 12px;
  }
}

/* ===== ABOUT: same width caps as every other page (987/1143/1440/1737) =====
   About used to skip the intermediate caps and grow fluidly (the old note argued a
   text page freezing reads as a bug). In practice the opposite hurt more: switching
   Works ↔ About made the text and logo JUMP left/right, because Works content sat
   inside a centred cap while About spanned the window. Since 2026-07-20 About
   follows the shared caps exactly, so the content edge never moves between pages.
   These blocks must live HERE (after About's own 768 `max-width: none`) — the same
   source-order trap .project-content documents. */
@media (min-width: 1002px) {
  .about-content {
    max-width: 987px;
    margin-left: auto;
    margin-right: auto;
  }
}

/* ===== ABOUT: DESKTOP (from 1200px) — two columns ===== */
@media (min-width: 1200px) {
  .about-content {
    max-width: 1143px;
    margin-left: auto;
    margin-right: auto;
    flex-direction: row;
    align-items: flex-start;
    gap: 80px;
    padding: 32px 120px 100px;
  }

  .about-left {
    flex: 1;
  }

  .about-right {
    flex: 1;
    padding-top: 121px; /* aligns with hero title baseline like the design */
  }
}

@media (min-width: 1455px) {
  .about-content {
    max-width: 1440px;
    padding-left: 150px;
    padding-right: 150px;
  }
}

@media (min-width: 1752px) {
  .about-content {
    max-width: 1737px;
  }
}

/* About's header/footer need NO special rules anymore: .about-content now follows the
   same shared caps as .content (see above), so the shared .header/.footer-inner caps
   line up with it at every width. The old .about-page overrides that let them grow
   fluidly with the un-capped About text are gone with the fluid behaviour itself. */

/* =========================================================
   404 PAGE
   Structure follows the Figma mock (968:4338 / 968:4553): illustration, 404,
   heading, one line of text, button — all centred in a column. Type and button
   come from the site's own scale, not from the mock's one-off values.
   ========================================================= */

.error-content {
  max-width: 586px;
  margin: 0 auto;
  padding: 40px 32px;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* main is flex: 1 0 auto (fills the space between header and footer), so centering
     the column here places the whole illustration+text+button block in the vertical
     middle of that space. Equal top/bottom padding keeps that middle honest. */
  justify-content: center;
  gap: 32px;
  text-align: center;
  transition: padding 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Size is set in JS (see 404.html) so the element's box and the canvas inside it
   are ALWAYS the same integer pixel size.
   Do not add CSS sizing here — `height: auto` plus `aspect-ratio` left the element
   at 998x404 while the canvas stayed 998x400, and that 1% vertical stretch is
   resampled every frame, which reads as the animation stuttering. */
.error-illustration {
  width: 100%;
  display: flex;
  justify-content: center;
  /* The animation's own frame carries transparent space the layout can't see. The
     736x408 comp is shown at 368x204 and the artwork sits well inside it: 43px of
     nothing above, 42px below. Added to .error-content's 40px gap the space under
     the legs read as ~82px and left the text floating far too low.
     Both margins are pulled in so the gap LOOKS like the 40px it is everywhere else
     on the page — the top one matters now too, which it didn't when the frame hugged
     the artwork more tightly.
     Measured from the rendered canvas, so re-measure if the animation is re-exported
     or its display size changes — these numbers belong to that specific file. */
  margin-top: -43px;
  margin-bottom: -42px;
}

/* The animation is drawn at its own 300x120 and is NEVER scaled — resampling those
   raster layers is what brings the edge artefacts back, so 404.html has no resize
   logic at all. That makes it the page's job to clear 300px: with the usual 32px
   side padding it needs a 364px viewport, and phones at 320/360 fall short.
   Below that, only the illustration row escapes the padding (8px effective, which
   still clears 300 on a 320px screen) while the text keeps its normal inset.
   A max-width query in this otherwise mobile-first file is deliberate: this is a
   narrow exception to a base rule, not a step up the breakpoint ladder. */
@media (max-width: 363px) {
  .error-illustration {
    margin-left: -24px;
    margin-right: -24px;
  }
}

/* Must span the full row: the JS below measures this box to decide the player's
   size, and a shrink-to-fit div would measure the player it is about to size. */
.error-animation {
  width: 100%;
  display: flex;
  justify-content: center;
}

.error-message {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

/* Tier 1, same as the project title: Condensed 600 in the site's red. */
.error-code {
  font-family: 'Fira Sans Condensed', sans-serif;
  font-weight: 600;
  font-size: 40px;
  color: #b33a48;
}

/* Tier 2 — the "About the Project" heading style. */
.error-heading {
  font-size: 24px;
  font-weight: 500;
  color: #2b2b2b;
}

/* Tier 4 — body text, same as the project description. */
.error-text {
  font-size: 16px;
  line-height: 24px;
  color: #646464;
  max-width: 420px;
}

/* Same proportions as the CV button on the About page (12/20 padding, 2px border,
   999px radius, 18px label) so the site has one button size, not two. */
.error-button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  width: fit-content;
  padding: 12px 20px;
  border: 2px solid #2b2b2b;
  border-radius: 999px;
  font-size: 18px;
  color: #2b2b2b;
  transition: background 0.2s ease-in-out, color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}

.error-button:hover {
  background: #498257;
  border-color: transparent;
  color: #fff;
}

.error-button-arrow {
  font-size: 18px;
  line-height: 1;
}

/* =========================================================
   PROJECT DETAIL PAGE
   ========================================================= */

/* Top padding is 32px to match .content and .about-content. It used to be 24, so
   the project title started 8px higher than the titles on Works and About and the
   header-to-title gap visibly jumped when moving between pages. Keep these three
   in sync; the bottom padding may differ freely, only the top edge is shared. */
.project-content {
  max-width: 586px;
  margin: 0 auto;
  padding: 32px 32px 48px;
  display: flex;
  flex-direction: column;
  gap: 32px;
  transition: padding 0.6s cubic-bezier(0.4, 0, 0.2, 1), max-width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

/* The site's text-link voice (2026-07-21), same as the project tags: black 16/24
   regular, centre-out underline bar on hover. Was the last green Medium text link
   standing after the tags / More details cleanup. Mobile-only element (hidden from
   768px up), so the hover is mostly academic — kept for consistency. */
.back-link {
  position: relative;
  display: inline-block;
  font-size: 16px;
  line-height: 24px;
  font-weight: 400;
  color: #2b2b2b;
}

.back-link::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -3px;
  height: 2px;
  background: #2b2b2b;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform 0.25s ease;
}

.back-link:hover::after {
  transform: scaleX(1);
}

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

.project-title {
  font-family: 'Fira Sans Condensed', sans-serif;
  font-weight: 600;
  font-size: 40px;
  color: #b33a48;
}

/* Plain text, NOT the pill-shaped filter buttons from the homepage — the shared
   look made them read as a second navigation bar. Typography is tier 4 of the page
   scale (16 → 18/27, secondary grey), the same as the details values and the
   description, so they sit quietly under the title instead of competing with it. */
.project-tags {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 10px;
}

/* Lighter than the tags themselves so it separates without being read as content. */
.project-tag-sep {
  color: #b0aeae;
  user-select: none;
}

/* Size/weight of the "Client" label (16/24 → 18/27 Medium). Restyled 2026-07-21 from
   green text to the site's TEXT-LINK language: black type + the centre-out underline
   bar on hover, the same gesture the nav and the card titles use. Green now belongs to
   BUTTON-shaped controls only (filters, CV); the dot separator stays. The bar is
   absolutely positioned so hovering never shifts the title row by a pixel. */
.project-tag {
  position: relative;
  font-size: 16px;
  line-height: 24px;
  /* Regular, not Medium — the same weight the nav items use, so the tags read as quiet
     text links rather than emphasized labels. Weight stays constant on hover (a heavier
     hover weight would change the text width and make the row jitter). */
  font-weight: 400;
  color: #2b2b2b;
}

a.project-tag::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -3px;
  height: 2px;
  background: #2b2b2b;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform 0.25s ease;
}

a.project-tag:hover::after {
  transform: scaleX(1);
}

.section-subheading {
  font-size: 20px;
  font-weight: 500;
}

/* Project media sections: title + a grid or horizontal-scroll strip of
   images/videos. Data-driven from data.sections in each project's data.js. */
.project-sections {
  display: flex;
  flex-wrap: wrap;
  gap: 32px 40px;
}

.project-section {
  display: flex;
  flex-direction: column;
  gap: 16px;
  flex: 0 0 auto;
  max-width: 100%;
}

.project-section--scroll {
  flex-basis: 100%;
  gap: 24px;
}

/* Plain 'Fira Sans' inherited from body, NOT Condensed. Condensed is reserved for
   the three big page/section names only — "My Works", the About page title and the
   project name. Using it for these small captions too made the page look like it
   had two competing heading styles. Weight 500 matches .section-subheading (the
   @import only loads Fira Sans at 400/500, so 600 here would be synthesised). */
.section-title {
  font-weight: 500;
  font-size: 20px;
  color: #383838;   /* same optical softening as .card-title — see its comment */
}

/* The rigid columns grid (.section-grid/.section-column) was RETIRED 2026-07-22 —
   superseded by layout:"row". Implementation archived in _archive/columns-layout.md.
   .section-cell below STAYS: the scroll strip builds its cards from it. */
/* Transparent, not the old #d9d9d9 placeholder — same reason as .card-thumb: a project
   page is mostly media, so a grey block per cell was the loudest thing on screen while
   the images arrived. NOTE the one thing this costs: grey behind a VIDEO used to be the
   tell that the .video-cell wrapper had lost its sizing (it is transparent by design, so
   grey showing through meant the wrapper had stretched past the frame). That signal is
   gone — if video sizing is ever suspect, check the wrapper's box in devtools instead. */
.section-cell {
  display: block;
  max-width: 100%;
  background: transparent;
  overflow: hidden;
  object-fit: cover;
}

/* Width comes from the aspect ratio + the height rule below — never set directly,
   so any ratio scales consistently whether it's alone in its column or stacked.
   Each rule also stores its ratio as a plain number in --cell-ratio: video below
   needs that as a real number (it can't use the `aspect-ratio` shorthand itself). */
.section-cell[data-ratio="1-1"] { aspect-ratio: 1 / 1; --cell-ratio: 1; }
.section-cell[data-ratio="3-4"] { aspect-ratio: 3 / 4; --cell-ratio: 0.75; }
.section-cell[data-ratio="9-16"] { aspect-ratio: 9 / 16; --cell-ratio: 0.5625; }
.section-cell[data-ratio="16-9"] { aspect-ratio: 16 / 9; --cell-ratio: 1.7778; }
/* A4 paper proportions (210×297mm), the standard for print/poster work — portrait and
   landscape. Still a FIXED-ratio cell like the four above, so A4 cells crop-to-fill and
   line up on the grid; the point is only that A4 art now has a slot that matches it, so
   nothing is cropped away. */
.section-cell[data-ratio="a4"]   { aspect-ratio: 210 / 297; --cell-ratio: 0.7071; }
.section-cell[data-ratio="a4-l"] { aspect-ratio: 297 / 210; --cell-ratio: 1.4142; }
/* "original": opt OUT of the fixed-ratio grid — the image keeps its OWN proportions and
   is NEVER cropped (every rule above pairs a fixed aspect-ratio with the `object-fit:
   cover` on .section-cell; here we drop both). The cell still takes the row's HEIGHT
   from [data-rows], so it lines up vertically with its neighbours, and width follows the
   image — the same principle the video wrapper uses. `object-fit: contain` is a safety
   net for the rare case `max-width: 100%` clamps a very wide image in a narrow column:
   it letterboxes instead of distorting. Background transparent so any such gap shows
   nothing rather than the grey placeholder. Use when an image must be shown WHOLE (a
   full layout, a poster) and a crop would ruin it — at the cost of a non-uniform width
   that may not align as tidily beside fixed-ratio cells. */
.section-cell[data-ratio="original"] {
  aspect-ratio: auto;
  --cell-ratio: 1;
  width: auto;
  object-fit: contain;
  background: transparent;
}

/* A column's total height always adds up to 408px (matching a lone hero cell):
   height = (408 - (rows-1) * 16) / rows, for 1-6 stacked rows per column.
   --cell-height mirrors the same value for video's own sizing rule below. */
.section-cell[data-rows="1"] { height: 408px; --cell-height: 408px; }
.section-cell[data-rows="2"] { height: 196px; --cell-height: 196px; }
.section-cell[data-rows="3"] { height: 125px; --cell-height: 125px; }
.section-cell[data-rows="4"] { height: 90px; --cell-height: 90px; }
.section-cell[data-rows="5"] { height: 69px; --cell-height: 69px; }
.section-cell[data-rows="6"] { height: 55px; --cell-height: 55px; }

/* Horizontal scrolling stays, the scrollbar itself is hidden — there's no scrollbar
   in the design. Needs all three declarations: Firefox reads scrollbar-width, old
   Edge/IE reads -ms-overflow-style, Chrome/Safari only obey the ::-webkit-scrollbar
   rule below. The former padding-bottom existed purely to make room for the bar. */
.section-scroll {
  display: flex;
  gap: 20px;
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.section-scroll::-webkit-scrollbar {
  display: none;
}

/* Signals that the hidden-scrollbar strip is draggable. project.js adds
   .is-scrollable only when the content actually overflows, so a short strip
   that fits on screen doesn't advertise a drag that would do nothing. */
.section-scroll.is-scrollable {
  cursor: grab;
}

.section-scroll.is-dragging {
  cursor: grabbing;
}

/* ===== Scroll strip affordance: ‹ › arrows + a slim progress track ===== */
/* The scrollbar is hidden by design, so these are the strip's visible "this scrolls"
   signals (added 2026-07-21): round dark arrows over the strip's edges page it left /
   right (hidden at the respective end, and entirely when nothing overflows), and the
   green sliver under the strip shows where you are. Wired in project.js. */
.scroll-stage { position: relative; }

.scroll-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  width: 40px;
  height: 40px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: #2b2b2b;
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
  transition: transform 0.15s ease, background 0.2s ease, opacity 0.2s ease;
}
.scroll-arrow svg { width: 16px; height: 16px; display: block; }
.scroll-arrow:hover { background: #1a1a1a; transform: translateY(-50%) scale(1.06); }
.scroll-arrow.hidden { opacity: 0; pointer-events: none; }
.scroll-prev { left: 10px; }
.scroll-next { right: 10px; }

/* Progress track + "3 / 8" counter share one quiet line under the strip. The counter
   speaks the same voice as the comic pager's counter — the familiar reading pattern
   that answers "how many pages, and where am I". Hidden together when nothing scrolls. */
.scroll-meta {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 12px;
}
.scroll-meta.hidden { display: none; }
.scroll-progress {
  flex: 1;
  height: 3px;
  border-radius: 999px;
  background: #e0dcdd;
  overflow: hidden;
}
.scroll-progress span {
  display: block;
  height: 100%;
  background: #498257;
  border-radius: 999px;
}
.scroll-counter {
  font-size: 13px;
  color: #8a8a8a;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;   /* digits keep one width — no jiggle while flipping */
}

/* Otherwise the browser's native image-drag starts instead of panning the strip. */
.section-scroll img {
  user-select: none;
  -webkit-user-drag: none;
}

.section-scroll .section-cell {
  flex-shrink: 0;
  width: auto;
}

/* Strip height comes in THREE author-picked sizes (data.js `size: "s"|"m"|"l"`, editor
   «Высота ленты»), medium by default — the strip was redesigned 2026-07-21 from the old
   tiny 168-190px cells (an A4 page rendered ~134px wide, lettering unreadable) to a
   size where a comic page can actually be read in place. One uniform height for every
   ratio, like the mosaic's rhythm; cells derive width from their ratio (or their own
   proportions for "original"). */
.section-scroll { --strip-h: 440px; }
.section-scroll--s { --strip-h: 360px; }
.section-scroll--l { --strip-h: 560px; }
/* Tablets (below the 987-cap band): every strip height steps DOWN one notch. Measured
   need, not taste: at 768 the fixed 360px cards outgrew shape-journey's own hero video
   (342px) — inverting the "styleframes must be smaller than the video" hierarchy — and
   exactly 1.03 cards fit the view, so nothing peeked and the strip didn't read as
   scrollable. One notch down restores the hierarchy and lets the next card peek. */
@media (max-width: 1001px) {
  .section-scroll { --strip-h: 360px; }
  .section-scroll--s { --strip-h: 280px; }
  .section-scroll--l { --strip-h: 440px; }
}
.section-scroll .section-cell[data-ratio] {
  height: var(--strip-h);
  --cell-height: var(--strip-h);
}

/* Video lives inside a `.video-cell` wrapper (see project.js) so a play button can
   sit over the frame. The wrapper carries the .section-cell classes purely to pick up
   --cell-ratio / --cell-height from the rules above; every box-shaping property those
   rules also set (aspect-ratio, a fixed height, the grey placeholder) is overridden
   right here so the box takes the VIDEO's own proportions instead — no forced shape,
   no letterbox, no grey.

   Width is the explicit/responsive dimension (from the same height+ratio numbers) and
   height is auto, so the browser derives it from the video's real proportions as the
   width shrinks. The [data-ratio] / [data-rows] attributes in the selectors are NOT
   decorative: a bare `.video-cell` (0-1-0) loses on specificity to the generic
   `.section-cell[data-rows="N"]` / `[data-ratio="N"]` rules above (0-2-0) and the box
   snaps back to a fixed shape. Keep the attribute selectors. */
.video-cell,
.video-cell[data-ratio],
.video-cell[data-rows] {
  position: relative;
  aspect-ratio: auto;
  height: auto;
  width: calc(var(--cell-height, 408px) * var(--cell-ratio, 1.7778));
  max-width: 100%;
  background: transparent;
}

/* The grid thumbnail is a poster <img> (or, as a fallback for un-processed projects, the
   video's own first frame). Both size identically inside the wrapper. */
.video-cell > video,
.video-cell > .video-poster {
  display: block;
  width: 100%;
  height: auto;
  background: transparent;
}

/* From tablet up, video is height-driven like every other cell, so a column holding
   one lines up with its neighbours instead of setting its own height. Width follows
   the video's real proportions — still no crop, no letterbox. NOT applied on narrow
   screens: there `max-width: 100%` clamps the width, and a pinned height would squash
   the player toward a square (the original bug); the width-driven rule above owns the
   layout below this breakpoint.

   `width: fit-content`, NOT `auto`: the wrapper is a block, and `width:auto` makes it
   stretch to the FULL column width while the height-driven `<video>` inside only takes
   its own proportional width. The play badge is positioned against the wrapper, so on
   an `auto` wrapper it floated in the empty band to the RIGHT of the actual frame
   (visible as a badge hanging in space). `fit-content` shrinks the wrapper to the real
   video box, so the badge's corner is the video's corner. */
@media (min-width: 768px) {
  .video-cell[data-rows] {
    height: var(--cell-height, 408px);
    width: fit-content;
  }
  .video-cell[data-rows] > video,
  .video-cell[data-rows] > .video-poster {
    height: 100%;
    width: auto;
  }
}

/* The whole frame is clickable — a click opens the video full-screen in the lightbox
   (see project.js). The zoom-in cursor comes from `.section-cell.is-zoomable` (below the
   video rules); the hover-grow is triggered by hovering the CELL, not just the badge. The
   button stays interactive (not pointer-events:none) so keyboard focus + Enter still
   works — its click just bubbles up to the same cell handler. */

/* The play affordance is a small flat ROUND badge with a play triangle, tucked into the
   bottom-RIGHT corner — the corner every video platform uses for duration, so viewers
   read it as "video" by convention. A flat fill reads on any artwork (the earlier outline
   vanished into light or line-heavy compositions), the corner keeps clear of centred art,
   and it stays small to be unobtrusive. It's a full circle (border-radius: 999px) — the
   only radius this project uses (the CTA pills use the same), never a small rectangle
   radius — and it sits INSET from the frame by --badge-inset rather than flush, so the
   disc reads as a deliberate control floating on the artwork instead of a clipped corner.
   Fully opaque, no transparency; the dark badge uses the site's text black. It's the
   RESTING affordance — it just marks "this is a video"; hovering the cell drops a real
   player (`.video-hover`) on top that plays inline with native controls. */
.video-play {
  --badge-inset: 8px;
  position: absolute;
  right: var(--badge-inset);
  bottom: var(--badge-inset);
  width: 32px;
  height: 32px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: #2b2b2b;
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: opacity 0.2s ease, transform 0.15s ease;
}
.video-play svg { width: 13px; height: 13px; margin-left: 2px; display: block; }
.video-cell:hover .video-play { transform: scale(1.06); }

/* The ▶ badge is RETIRED on the live site (2026-07-22): videos live in their own,
   clearly-named sections now, and MOTION is the video signal — hover plays on desktop,
   the in-view autoplay pool plays on touch (see project.js). The badge survives ONLY in
   the editor preview (#sectionsEditor), where the author still needs to tell a poster
   frame from a picture at a glance. */
.project-sections:not(#sectionsEditor) .video-play { display: none; }

/* Light-badge variant (videoIcon: "light"): a flat white square with a dark triangle,
   for videos where a dark badge sits heavy — e.g. on a dark first frame. */
.video-cell--light .video-play { background: #fff; color: #2b2b2b; }

/* Red-badge variant (videoIcon: "red"): the brand accent #b33a48, for deliberately
   drawing attention to a particular video. Use sparingly — red is the site's accent
   for titles and 404, so every red badge spends some of that. */
.video-cell--red .video-play { background: #b33a48; color: #fff; }

/* Shorter cells (3-up columns, scroll strips) step the badge down to 24px = 1.5× the
   16px grid unit (the standard 32px is 2×), so both sizes stay on the system's rhythm.
   Triangle keeps its ~40% ratio to the badge. */
.video-cell[data-rows="3"] .video-play,
.section-scroll .video-cell .video-play {
  width: 24px;
  height: 24px;
}
.video-cell[data-rows="3"] .video-play svg,
.section-scroll .video-cell .video-play svg {
  width: 10px;
  height: 10px;
}

/* (The columns-on-phones reflow block left with the rigid grid — rows/mosaic/pages
   carry their own mobile behaviour. Archived in _archive/columns-layout.md.) */

.section-scroll .video-cell,
.section-scroll .video-cell[data-ratio],
.section-scroll .video-cell[data-rows] {
  width: calc(var(--cell-height, 408px) * var(--cell-ratio, 1.7778));
  height: auto;
}
.section-scroll .video-cell > video,
.section-scroll .video-cell > .video-poster {
  width: 100%;
  height: auto;
}

/* (The .section-hero block left with the columns grid — a lone video in a "row"
   section gets the same showreel treatment from the row engine: full content width,
   own proportions, 80vh cap.) */

/* ===== Mosaic sections (layout: "mosaic") ===== */
/* Square base cells: 6 columns, row height = column width (set from project.js on
   resize). Each cell spans a block matching its file's own shape — landscape 4x2,
   portrait 2x3-2x4, square 2x2, doubled for `is-featured` — so both orientations read
   equally big. `dense` packs later small blocks into earlier holes. The light edge-crop
   from cover is accepted by design; the lightbox always shows the full file. */
.section-mosaic {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 16px;
  grid-auto-flow: dense;
  width: 100%;
}

.mosaic-cell {
  position: relative;
  overflow: hidden;
  background: transparent;   /* no placeholder fill — see .card-thumb for the reasoning */
}
img.mosaic-cell,
.mosaic-cell > img,
.mosaic-cell > video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.mosaic-video:hover .video-play { transform: scale(1.06); }

/* The hover preview player (project.js drops it in on mouseenter) sits ON TOP of the
   resting poster + badge, filling the cell. Same crop mode as the poster it covers:
   `cover` in the fixed grid and the normal mosaic, `contain` for a no-crop mosaic cell.
   The `> video.video-hover` selectors are deliberately specific (class + type) so they win
   over the plain `.video-cell[data-rows] > video` sizing rules that also set width/height.

   NO opaque background and `opacity: 0` until it is actually PLAYING: a freshly-created
   <video> paints its box (a solid colour) for the ~frame before it has any picture, which
   flashed over the poster. Instead the player stays invisible — the poster shows through —
   and project.js adds `.is-ready` on the `playing` event, fading the moving frame in. The
   `playing` (not `loadeddata`) trigger is load-bearing on iOS: there muted autoplay is
   blocked, the clip stays paused, so it never fades in and the clean poster is what shows —
   no stalled first frame, no native ▶ overlay, no poster/first-frame jump. A tap plays it. */
.video-cell > video.video-hover,
.mosaic-video > video.video-hover {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: transparent;
  z-index: 2;
  opacity: 0;
  transition: opacity 0.12s ease;
}
.video-cell > video.video-hover.is-ready,
.mosaic-video > video.video-hover.is-ready {
  opacity: 1;
}
.mosaic-cell.is-nocrop > video.video-hover {
  object-fit: contain;
}

/* Native fullscreen (the ⛶ button in the player's own controls): show the WHOLE frame,
   letterboxed, so a square or vertical clip fits by HEIGHT instead of being cropped to
   fill the wide screen. In fullscreen the <video> becomes the size of the screen, and our
   resting `object-fit: cover` would keep cropping — so force `contain` while fullscreen.
   Standard and -webkit variants are kept as SEPARATE rules: one selector list mixing them
   is dropped whole by browsers that don't know one of the pseudo-classes. */
.video-cell > video.video-hover:fullscreen,
.mosaic-video > video.video-hover:fullscreen {
  object-fit: contain;
}
.video-cell > video.video-hover:-webkit-full-screen,
.mosaic-video > video.video-hover:-webkit-full-screen {
  object-fit: contain;
}

/* "Не резать" (noCrop): the file sits WHOLE inside its block — for works with a frame
   or important detail at the edges, where even the light cover-crop hurts. The leftover
   space is transparent, so it reads as page background. `object-position: left top`
   (not the default centring) anchors the artwork to the block's TOP-LEFT corner, so a
   shorter poster still tops-out level with its neighbours and hugs the grid's left
   line — the leftover air falls below/right, where it reads as natural page space
   instead of a misaligned float. */
.mosaic-cell.is-nocrop { background: transparent; }
img.mosaic-cell.is-nocrop,
.mosaic-cell.is-nocrop > img,
.mosaic-cell.is-nocrop > video {
  object-fit: contain;
  object-position: left top;
}

/* Phones: the mosaic honestly degrades to a full-width feed — every file whole, own
   aspect ratio (set as --ar by the engine), no tiny tiles. `max-height` mirrors the
   desktop height cap: even a very tall portrait stays within one glance (the small
   top/bottom crop from cover is the accepted trade-off; the lightbox has the full file). */
@media (max-width: 767px) {
  .section-mosaic { display: flex; flex-direction: column; }
  .mosaic-cell { width: 100%; aspect-ratio: var(--ar, 1); max-height: 80vh; }
}

/* ===== Row sections (layout: "row") — justified rows of one height ===== */
/* Every cell in a visual row is sized to the SAME height by the row engine (project.js);
   width follows each file's own aspect ratio, so the box IS the file's shape (nothing is
   cropped) and the row is justified to fill the width, left-aligned. */
.section-row {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  /* Left-aligned to match the rest of the site (every gallery here hugs the left edge).
     A short row of narrow items that can't fill the width leaves its leftover on the
     RIGHT — see the row engine, which drops an empty spacer frame into that gap so the
     row still reads as a full-width, deliberate composition instead of a lonely pair. */
  justify-content: flex-start;
  width: 100%;
}
.row-cell {
  position: relative;
  overflow: hidden;
  background: transparent;   /* no placeholder fill — see .card-thumb for the reasoning */
}

/* ===== The first seconds, before the engine can measure =====
   The row engine solves ONE shared height per row, so it needs the aspect ratio of EVERY
   file in the row and deliberately does nothing until it has them all. Until then the
   cells are plain flex items, and that default is what looked broken: the browser shrinks
   a flex item's WIDTH to fit the row while its height stays the file's own, and an <img>
   with no object-fit stretches its content to whatever box it is given — so pictures
   arrived visibly squashed and un-squashed a few seconds later.

   Two guards, both of which stop mattering the moment the engine writes its inline sizes:
   never let the content stretch, and cap the provisional height so a 2000px original does
   not throw the whole page open before it has been measured. The cap matches the engine's
   own target height (min(620px, 62% of the width)), so the correction is small. */
img.row-cell,
.row-cell > img,
.row-cell > video {
  object-fit: contain;
}
/* Her call, and the right one: while the row is provisional the cells are INVISIBLE, not
   merely un-stretched — the boxes still shrink and re-shuffle while files report in, and
   even an undistorted picture inside a collapsing box reads as a defect. Nothing shows
   until the engine has solved the row; then everything fades in already in place. opacity,
   not visibility/display: the cells must keep their boxes (the engine measures them and
   the load events must fire), and it transitions. The max-height keeps the reserved space
   sane while hidden, so the fade-in does not also reflow the page. */
.section-row:not(.is-laid-out) .row-cell {
  max-height: 62vh;
  opacity: 0;
}
.section-row .row-cell {
  transition: opacity 0.3s ease;
}
/* Invisible layout spacer (added by the row engine) — only reserves the leftover width of a
   row its narrow items can't fill, so the real items stay LEFT-aligned without a lonely hole
   pulling them around. Purely structural: no border, no fill, nothing to see. */
.row-spacer {
  box-sizing: border-box;
  flex: 0 0 auto;
  align-self: flex-start;
  background: transparent;
  pointer-events: none;
}
.row-cell > img,
.row-cell > video {
  width: 100%;
  height: 100%;
  /* `contain`, not `cover` — set once in the block above. Once the engine has run the box
     EQUALS the file's shape, so the two are identical; the difference only shows in the
     provisional moments before that, where cover would crop and contain does not. */
  display: block;
}
/* Phones: the row degrades to a full-width stack (like the mosaic), each file at its own
   height — the justified engine clears its inline sizes below 768px so these rules win. */
@media (max-width: 767px) {
  .section-row { flex-direction: column; }
  /* !important so a stale inline width/height left over from a desktop layout (if the
     engine's resize handler hasn't run yet) can never keep a cell narrow on a phone. */
  .section-row .row-cell { width: 100% !important; height: auto !important; }
  .section-row .row-cell > img,
  .section-row .row-cell > video { height: auto; }
}

/* ===== Comic / frames pager (layout: "pages") ===== */
/* An inline PAGER, not a stack: ONE page on screen at a reading width, ‹ › arrows and a
   "3 / 12" counter to leaf in place — a long comic or styleframe set never floods the
   layout (the author's explicit wish). The page is shown WHOLE (own aspect ratio, no
   crop); 720px is the reading cap — wide enough for lettering on an A4 page; phones use
   the full width. Clicking the page opens the lightbox at the same position and reading
   continues there full-screen. */
/* Left-aligned on desktop, NOT centred: everything on the project page hangs off the
   left content edge (title, sections, about-text), and a centred reader was the only
   element drifting off that axis. The empty right side reads as air, same as a lone
   columns section. On phones the viewer is full-width anyway. */
.section-pages {
  display: flex;
  justify-content: flex-start;
}

/* A pages section takes its NATURAL width (the viewer's, ~480-720px), not the full row —
   so a columns section placed next to it in data.js sits beside the reader (cover next
   to the comic), via the same flex-wrap pairing columns sections already use. min-width 0
   lets it shrink on narrow rows instead of overflowing. */
.project-section--pages {
  flex: 0 1 auto;
  min-width: 0;
  max-width: 100%;
}

.pages-viewer {
  position: relative;
  width: 720px;          /* the reading base; shrunk by max-width (100% + the JS 80vh cap) */
  max-width: 100%;
}

.pages-stage { cursor: zoom-in; }

.page-cell {
  display: block;
  width: 100%;
  height: auto;          /* the page's own proportions — never cropped */
  background: #e4e2e3;   /* placeholder while the page loads */
}
.page-cell > img,
.page-cell > video {
  display: block;
  width: 100%;
  height: auto;
}
.page-cell.mosaic-video { position: relative; }

/* Once the stage locks its aspect ratio to the first loaded page (see project.js), the
   viewer's height NEVER changes while leafing — the content below must not jump. Every
   page fills the locked box and letterboxes quietly (contain, transparent margins) if
   its shape differs; uniform comic pages fill it exactly. */
.pages-stage.is-locked .page-cell {
  height: 100%;
  background: transparent;
}
.pages-stage.is-locked img.page-cell,
.pages-stage.is-locked .page-cell > img,
.pages-stage.is-locked .page-cell > video {
  height: 100%;
  object-fit: contain;
}

/* Same 32px round dark controls as the lightbox — one control family sitewide. They sit
   over the page's side edges, vertically centred. */
.pages-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 32px;
  height: 32px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: #2b2b2b;
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.15s ease, background 0.2s ease;
}
.pages-nav svg { width: 14px; height: 14px; display: block; }
.pages-nav:hover { background: #1a1a1a; transform: translateY(-50%) scale(1.06); }
.pages-nav[hidden] { display: none; }
.pages-prev { left: 8px; }
.pages-next { right: 8px; }

.pages-counter {
  margin-top: 8px;
  text-align: center;
  font-size: 14px;
  color: #8a8a8a;
}
.pages-counter[hidden] { display: none; }

/* Hover cursor states what a click DOES: IMAGES open enlarged — the native magnifier
   (zoom-in). VIDEOS no longer carry `is-zoomable` at all: they play inline on hover with
   their own native controls, so the cursor stays the ordinary arrow (and the controls
   supply their own pointer). The pages-stage keeps its own zoom-in. */
.is-zoomable { cursor: zoom-in; }

/* Project media hover (2026-07-21) — the homepage card gesture carried over: the tile
   swells gently toward the cursor. WHOLE-tile scale (1.02, vs the card's inner 1.04),
   because most project cells ARE the <img> itself with no crop frame around it; 2% of a
   ~400px tile is ±4px — well inside the 16px gaps, and transform-only so the grid never
   reflows. The pages viewer stage joins in. */
.project-sections .is-zoomable,
.pages-stage {
  /* opacity rides along for the row cells' fade-in (hide-until-measured, see the row
     block). Transitions don't merge across rules — this later rule REPLACES the row
     block's transition on zoomable images, so opacity must be listed here too or the
     fade-in silently stops animating (caught live: computed transition was transform-only). */
  transition: transform 0.25s ease, opacity 0.3s ease;
}
.project-sections .is-zoomable:hover,
.pages-stage:hover {
  transform: scale(1.02);
}

/* Scroll strips join the swell too — but their container clips at the padding edge
   (overflow-x: auto forces vertical clipping), so the strip carries 8px of INVISIBLE
   padding as room for the ±4px swell, cancelled by an equal negative margin so the
   layout doesn't move a pixel. Without this the swelling tile got its edges shaved. */
.section-scroll {
  padding: 8px;
  margin: -8px;
  gap: 16px;   /* the site's module, overriding the legacy 20px in the base rule */
}

/* ===== Lightbox: the untouched original, full-screen ===== */
/* One reused overlay (built in project.js). The grid shows small "-sm" copies for crisp,
   shimmer-free pixel art; here the real original is shown as large as the viewport
   allows — fit-to-screen, no further zoom/pan. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  flex-direction: column;   /* image on top, caption line below */
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 24px;
  background: rgba(38, 26, 51, 0.9);
}
.lightbox[hidden] { display: none; }

.lightbox-stage {
  display: flex;
  max-width: 100%;
  min-height: 0;   /* lets the caption keep its room in the column */
}
/* Fit inside the padded viewport, LEAVE ROOM for the caption line (≈48px), never upscale
   past the original's own pixels, keep the aspect ratio. */
.lightbox-stage > img,
.lightbox-stage > video {
  display: block;
  max-width: 100%;
  max-height: calc(100vh - 120px);
  width: auto;
  height: auto;
  object-fit: contain;
}

/* Caption line under the image: author's free-text note (light) + page counter (grey).
   One calm text row, no pills. Width follows the image, so it reads as one unit. */
.lightbox-caption {
  width: min(1100px, 100%);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 24px;
}
.lightbox-note {
  font-size: 16px;
  font-weight: 300;
  color: #e7e2e3;
  min-width: 0;
}
.lightbox-counter {
  font-size: 14px;
  font-weight: 300;
  color: #b9b3b5;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
/* The image handles its own pinch-zoom/pan in JS, so take the browser's native touch
   gestures off it (otherwise page-zoom fights our transform). Desktop is unaffected. */
.lightbox-stage > img {
  touch-action: none;
  will-change: transform;
}

/* Close and prev/next share the video play badge's language: a 32px dark circle with a
   thin white glyph, so every control on the site reads as one family in size, weight and
   colour (they were oversized, mismatched-thickness text glyphs before). */
.lightbox-close,
.lightbox-nav {
  position: absolute;
  width: 32px;
  height: 32px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: #2b2b2b;
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.15s ease, background 0.2s ease;
}
.lightbox-close svg,
.lightbox-nav svg { width: 14px; height: 14px; display: block; }
.lightbox-close:hover,
.lightbox-nav:hover { background: #1a1a1a; }

.lightbox-close { top: 16px; right: 16px; }
.lightbox-close:hover { transform: scale(1.06); }

/* Prev / next: page through every image and video in the section order. Vertically
   centred on the left and right edges, over the backdrop's padding band. Hidden by
   project.js when there's only one item to show. */
.lightbox-nav { top: 50%; transform: translateY(-50%); }
.lightbox-nav:hover { transform: translateY(-50%) scale(1.06); }
.lightbox-nav[hidden] { display: none; }
.lightbox-prev { left: 16px; }
.lightbox-next { right: 16px; }

/* Freeze the page behind the overlay so a scroll gesture doesn't move it. */
body.lightbox-open { overflow: hidden; }

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

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

.section-subheading--about {
  font-size: 24px;
}

.project-description {
  font-size: 16px;
  color: #646464;
  line-height: 1.5;
  /* Honour the line breaks the author typed in the editor's description box: the text is
     set via textContent (kept, for XSS safety), and plain HTML would collapse its "\n" into
     spaces. `pre-line` keeps newlines as breaks while still wrapping words normally and
     collapsing runs of spaces — so an empty line between paragraphs reads as one. */
  white-space: pre-line;
}

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

/* A named link inside a details value (e.g. a song title → its page): reads as the value
   text, underlined so it's clearly clickable, darkening to the chrome colour on hover. */
.details-link {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
  transition: color 0.2s ease-in-out;
}
.details-link:hover { color: #2b2b2b; }

/* Always EXACTLY two columns, at every width — this is a fixed design decision,
   not a responsive one. The details grid was previously flex-wrap with a 140px
   basis, which let it reflow into 3 or 4 columns on wider screens; in the design
   Client/Year/Role/Tools always read as two columns, and what changes with width
   is only whether the whole block sits beside the description or under it. */
.project-details {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px 20px;
}

.details-item {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.details-extra {
  display: none;
}

.project-details.expanded .details-extra {
  display: flex;
}

/* Typography from the Figma "Project Details" component, mobile variant (777:1798):
   label = Fira Sans Medium 16/24 #2b2b2b, value = Regular 16/24 #646464.

   NOTE — the narrow layout deliberately does NOT mirror the desktop size hierarchy.
   On desktop the label sits a tier above its value (24 over 18); here both are 16
   and the label is told apart by weight and colour instead. That was tried the
   other way once (label bumped to 20, toggle dropped to 16 to match the desktop
   ratios) and read worse on a phone — the narrow column wants fewer distinct sizes,
   not a scaled-down copy of the desktop scale. Leave it as the mock has it. */
.details-label {
  font-size: 16px;
  line-height: 24px;
  font-weight: 500;
  color: #2b2b2b;
}

.details-value {
  font-size: 16px;
  line-height: 24px;
  font-weight: 400;
  color: #646464;
}

/* Deliberately BURIED (2026-07-21): the toggle dresses as a details VALUE — the grey
   16/24 regular of the Year/Client values — so it reads as one more row of the grid,
   not a control demanding attention. The ▾ triangle is the only expand hint (an
   underline-bar hover was tried and removed together with the louder green/Medium
   versions). Hover just warms the text to the label black — feedback without fanfare. */
.details-toggle {
  align-self: flex-start;
  background: none;
  border: none;
  cursor: pointer;
  /* A <button> does NOT inherit font-family — without this it silently falls back
     to the system UI font (Arial here) while the rest of the page is Fira Sans. */
  font-family: inherit;
  font-size: 16px;
  line-height: 24px;
  /* Same voice as the Client/Year/Role/Tools labels: Fira Sans Medium, dark #2b2b2b
     (was 400/#646464, the value-grey). The ▾ arrow keeps it reading as a toggle. */
  font-weight: 500;
  color: #2b2b2b;
  padding: 10px 0;
  transition: color 0.2s ease-in-out;
}

.details-toggle:hover {
  text-decoration: underline;
}

.section-divider {
  border: none;
  height: 1px;
  background: #d9d9d9;
}

.related-section {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.section-subheading--related {
  font-size: 24px;
}

.related-cards {
  display: flex;
  gap: 24px;
  overflow-x: auto;
  /* Native scrollbar hidden — the strip speaks the site's own scroll language (the
     slim green progress track below), same as every .section-scroll strip. */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.related-cards::-webkit-scrollbar { display: none; }

/* Fixed 249px at EVERY width: on phones the base .card is full-width (the homepage
   grid wants that), which blew these thumbnails up to hero size and made them read as
   main content. Recommendations are a side dish — one constant, modest size. */
.related-cards .card {
  flex-shrink: 0;
  width: 249px;
}

.related-progress { margin-top: 8px; }
.related-progress.hidden { display: none; }

/* ===== 404: TABLET AND UP ===== */
@media (min-width: 768px) {
  .error-content {
    max-width: none;
    padding: 40px 60px;
    gap: 40px;
  }

  .error-code {
    font-size: 48px;
  }

  .error-heading {
    font-size: 32px;
  }

  .error-text {
    font-size: 18px;
    line-height: 27px;
    max-width: 520px;
  }
}

@media (min-width: 1002px) {
  .error-content {
    max-width: 987px;
    margin-left: auto;
    margin-right: auto;
  }
}

@media (min-width: 1200px) {
  .error-content {
    max-width: 1143px;
    padding: 40px 120px;
  }
}

@media (min-width: 1455px) {
  .error-content {
    max-width: 1440px;
    padding-left: 150px;
    padding-right: 150px;
  }
}

@media (min-width: 1752px) {
  .error-content {
    max-width: 1737px;
  }
}

/* ===== PROJECT PAGE: TABLET (from 768px) ===== */
@media (min-width: 768px) {
  .project-content {
    max-width: none;
    padding: 32px 60px 48px;
  }

  .back-link {
    display: none; /* not shown on tablet/desktop, matches design */
  }

  /* Optical nudge, measured — not a guess. From 768px up the project title is 48px
     while "My Works" and "About Me" are 64px. With identical 32px top padding the
     boxes start level, but the smaller title's optical centre lands 9.5px higher
     (61 vs 70.5 below the header) and its baseline 16.5px higher, so switching
     pages made the title visibly jump upward. 10px puts the optical centres level.
     Recalculate this if any of those three font sizes changes. */
  /* Optical nudge, measured — not a guess. From 768px up the project title is 48px
     while "My Works" and "About Me" are 64px — DELIBERATELY smaller: project names are
     long ("Butterfly Effect.", "BenBen and Friends") and 64px pushes them onto two
     lines. With identical 32px top padding the boxes start level, but the smaller
     title's optical centre lands 9.5px higher, so switching pages made the title
     visibly jump upward. 10px puts the optical centres level. */
  .project-title-row {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    margin-top: 10px;
  }

  .project-title {
    font-size: 48px;
  }

}

/* ===== PROJECT PAGE: same width caps as the homepage =====
   .header and .footer already pick these up from the shared 987/1143/1440 rules
   near the top of the file, but .project-content did not: the page-specific
   `max-width: none` above sits LATER in the file and beat them, so the content
   kept stretching edge-to-edge while the header centred — the logo visibly drifted
   out of line with the text. Re-declaring the same numbers here restores the
   homepage behaviour: past each cap the page stops growing, centres, and the extra
   width becomes plain side margin. Keep these three in sync with .content. */
@media (min-width: 1002px) {
  .project-content {
    max-width: 987px;
    margin-left: auto;
    margin-right: auto;
  }
}

/* ===== PROJECT PAGE: DESKTOP (from 1200px) ===== */
@media (min-width: 1200px) {
  .project-content {
    max-width: 1143px;
    /* 120 in the small-laptop band, back to 150 from 1455 — same as .content. */
    padding: 32px 120px 48px;
  }
}

/* ===== PROJECT PAGE: 1440px — the original Figma canvas width =====
   Description and details go side by side only HERE, not at 1200px. Between 1200
   and 1440 the container is capped at 1143px, so after the fixed 440px text
   column and the 130px gap only ~273px would be left for the details — the two
   columns collapse to ~125px and values like "Adobe Illustrator" wrap onto three
   lines. At 1440 the cap opens up to the full canvas and the split matches the
   Figma About Section (999:3037): 440 + 130 + 570 = 1140. */
@media (min-width: 1455px) {
  .project-content {
    max-width: 1440px;
    padding-left: 150px;
    padding-right: 150px;
  }

  .project-about {
    flex-direction: row;
    gap: 130px;
  }

  .project-about-text {
    width: 440px;
    flex-shrink: 0;
  }

  /* The whole block (grid + toggle) becomes the right column — the grid inside
     stays two columns, it just gets wider. */
  .project-details-block {
    flex: 1;
  }

  /* The About typography steps up to the Figma "Size=Desktop" values HERE, in the
     same breakpoint that splits the block into two columns — not earlier at 768px.
     Having the type switch at one width and the layout at another made the block
     change size twice while resizing, which read as jumping. Rule: as long as the
     block is a single column the text stays at its 16px mobile size; the moment
     description and details stand side by side, everything goes up together. */
  /* Figma 999:3039 — the heading grows to 32px on desktop, 24px everywhere else. */
  .section-subheading--about {
    font-size: 32px;
  }

  /* Media-section captions ("Video", "Styleframes", ...) match the details labels
     at 24px on desktop — they looked undersized next to them at 20px. */
  .section-title {
    font-size: 24px;
  }

  .project-description {
    font-size: 18px;
    line-height: 27px;
  }

  /* Tier 4 on desktop, alongside the values and the description. The details-toggle
     steps up WITH the tags — the two share one text-link voice at every width. */
  .project-tag,
  .details-toggle {
    font-size: 18px;
    line-height: 27px;
  }

  /* Label and value stay the SAME size and are told apart by weight and colour —
     the mobile relationship (both 16) scaled up, not the Figma desktop variant
     (which jumps the label to 24 and reads as a heading instead of a field name). */
  .details-label,
  .details-value {
    font-size: 18px;
    line-height: 27px;
  }

  .project-details {
    column-gap: 24px;
  }
}

/* ===== PROJECT PAGE: 1737px — the 5-cards-per-row cap (see the homepage block).
   MUST stay after the 1440px block above: both match past 1737px and they have
   the same specificity, so the later one wins on source order. ===== */
@media (min-width: 1752px) {
  .project-content {
    max-width: 1737px;
  }
}
