/* ---------------------------------------------------
   Sören Blaecker — Produktdesigner & Tischler
   Kachel-basiertes Layout, komprimierte Display-Typo,
   dunkles Slide-Menü, farbiger Hover, Scroll-Parallax
   auf Bildunterschriften. Referenz: karimrashid.com
--------------------------------------------------- */

/* Self-hosted rather than loaded from Google's CDN — the Google Fonts
   API sends every visitor's IP address to Google on page load, which
   German courts have repeatedly held to be a GDPR violation without
   explicit consent (e.g. LG München I, 20.01.2022 – 3 O 17493/20). */
@font-face {
  font-family: 'Anton';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/anton-latin.woff2') format('woff2');
}

:root {
  /* Neutral greys only — no warm/beige tint (R=G=B on every one of
     these) — per an explicit request that the site's base surface stay
     strictly black/white/grey across every page. */
  --color-bg: #f8f8f8;
  --color-bg-alt: #ececec;
  --color-text: #17161a;
  --color-text-muted: #6b6a70;
  /* Same blue family as --color-panel-hover below (the menu/link hover
     glow), just a darker shade of that hue -- #9fd8ff itself is too light
     to read as text on the site's near-white background (fails contrast),
     so this is the "text-safe" version of the one sitewide accent blue. */
  --color-accent: #1e6fa8;
  --color-line: #dbdbdb;
  --color-panel: #17161a;
  --color-panel-text: #f8f8f8;
  --color-panel-hover: #9fd8ff;
  --color-bg-gray: #e2e2e2;

  --cat-1: #000000;
  --cat-2: #33538f;
  --cat-3: #d9a12b;
  --cat-4: #b23b3b;
  --cat-5: #cf6a2c;
  --cat-6: #2f9a9a;

  /* Shared grid for Service/Contact/About/Awards: same title size and
     the same distance from the top, so all four read as one system. */
  --editorial-title-size: clamp(4rem, 14vw, 9.5rem);
  --editorial-top-pad: clamp(50px, 7vh, 100px);
  /* Roughly the average title+body height across Service/About/Awards
     right now (measured ~712/640/365px at a 1440px-wide viewport, title
     through the end of the body copy) -- used below to centre that block
     as a whole on tall viewports without each page centring to its own,
     different content length (which is what used to knock their titles
     off the same line). Re-measure and update this if any of the three
     gets substantially more or less copy over time -- Awards in
     particular, once its placeholder text is replaced with real entries. */
  --editorial-avg-content-h: 570px;

  --font-display: 'Anton', 'Arial Black', 'Helvetica Neue', sans-serif;
  --font-heading: Helvetica, Arial, 'Helvetica Neue', sans-serif;
  --font-body: Helvetica, Arial, 'Helvetica Neue', sans-serif;

  --maxw: 1240px;
  --gutter: clamp(20px, 5vw, 64px);
  /* Tracks the nav's own actual rendered height (26px padding top/bottom
     + its tallest child) -- everything below that reserves clearance
     under the nav, or centres text against it on the photo-hero pages,
     reads off this one value. Logo height doubled to 68px, so the nav
     itself is now taller than the 36px menu button and this grew with
     it: 26+26+68=120. */
  --nav-h: 120px;
  /* Shared mobile title offset for Service/About/Awards/Contact/
     Impressum/Datenschutz -- "as close under the status bar as About's
     already was" was the ask, but About's own old value (a flat
     clamp(90px, 22vh, 150px)) turned out to have a real bug: on a phone
     in landscape, 22vh can compute below even the 90px floor, landing
     under --nav-h (120px) and actually overlapping the nav (confirmed:
     90px top vs 120px nav bottom at several landscape sizes). Floor
     raised to comfortably clear the nav instead of a value that predates
     the nav being this tall. 132px (not var(--nav-h) + a calc()) is
     deliberate, not laziness -- combining clamp()/calc()/var() like that
     is a known source of a real Safari title/nav-overlap bug elsewhere
     in this file (see the .page-hero--photo mobile rule and its own
     comment); hardcoding it here avoids the same risk. Keep this and
     --nav-h in sync by hand if the nav's own height changes again. On a
     normal portrait phone, 22vh still comfortably exceeds this floor and
     the result matches the old clamp exactly. */
  --mobile-hero-top-pad: clamp(132px, 22vh, 150px);
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

body {
  margin: 0;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  /* No forwards/both fill mode on purpose: any non-none filter value
     held after the animation ends -- even the interpolated "blur(0px)"
     Chromium reports for a filter:none end keyframe once fill-mode
     holds it -- makes body a containing block for position:fixed
     descendants (nav, menu panel, lightbox all use it), silently
     breaking them for the rest of the page's life. Without a fill
     mode the animation's effect simply releases back to the normal
     cascade once it finishes, which is opacity:1/filter:none anyway
     (neither is set outside this animation), so there's no visual
     jump -- just a real, not just interpolated, filter:none at rest. */
  animation: page-fade-in 0.6s ease-in-out;
}

@keyframes page-fade-in {
  from { opacity: 0; filter: blur(10px); }
  to { opacity: 1; filter: none; }
}

img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }

h1, h2, h3, h4 {
  font-family: var(--font-heading);
  font-weight: 500;
  line-height: 1.15;
  margin: 0 0 0.5em;
  letter-spacing: -0.01em;
}

.display {
  font-family: var(--font-display);
  font-weight: 400;
  text-transform: uppercase;
  line-height: 0.86;
  letter-spacing: -0.01em;
}

/* .has-umlaut is toggled by js/main.js on whichever titles' currently
   visible text contains ä/ö/ü — extra room so the diaeresis dots don't
   crowd into (or clip against) the line above once the title wraps.
   Two classes each beats every single-class line-height rule above and
   in the media queries below regardless of source order, so one shared
   value here reliably overrides every title context. */
.display.has-umlaut,
.tile__title.has-umlaut,
.project-hero__title.has-umlaut,
.stack__title.has-umlaut,
.page-hero__title.has-umlaut {
  line-height: 1.15;
}
/* .hover-letters titles (About/Awards/Contact/Services page heroes) are
   short, single-line headlines that never wrap, so the extra line-height
   above buys them nothing -- it only shifts the title vertically when
   switching language, since "Über mich" trips has-umlaut and "About Me"
   doesn't. Three classes outranks the two-class rule above, pinning these
   back to the base line-height regardless of has-umlaut. */
.stack__title.hover-letters.has-umlaut,
.page-hero__title.hover-letters.has-umlaut {
  line-height: 0.92;
}
.page-title { font-size: clamp(2.4rem, 6vw, 4.2rem); }
.page-title--sm { font-size: clamp(1.9rem, 4vw, 2.8rem); }
.lede { max-width: 46ch; color: var(--color-text-muted); }

p { margin: 0 0 1em; }

.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--gutter); }

.eyebrow {
  display: inline-block;
  font-family: var(--font-heading);
  font-size: 0.75rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--color-accent);
  margin-bottom: 1em;
}

.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-heading);
  font-weight: 600;
  font-size: 0.95rem;
  letter-spacing: 0.01em;
  cursor: pointer;
}
.link-arrow::before { content: "\2192"; display: inline-block; transition: transform 0.2s ease; }
.link-arrow:hover::before { transform: translateX(5px); }
.link-arrow:hover { color: var(--color-accent); }
.link-arrow--light { color: #fff; }
.link-arrow--light:hover { color: var(--hover-1); }

/* ---------- Nav ---------- */

.nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 310;
  --nav-fg: #fff;
  --nav-fg-muted: rgba(255,255,255,0.78);
  background: none;
  border: none;
  color: var(--nav-fg);
}
/* Desktop only. While the menu is open, .nav__mark and .nav__right (see
   their own is-menu-open rules further down) are both hidden there — the
   panel has its own dedicated close (X) button, .menu-panel__close — so
   .nav itself, being fixed and spanning the full top strip at a z-index
   above the panel, was still silently intercepting clicks meant for that
   close button sitting in the same corner underneath it (confirmed via
   elementFromPoint: it resolved to .nav__inner, not the close button).
   Disabled here so clicks pass straight through to the panel's own X.
   Mobile does NOT get this: .menu-panel__close is deliberately hidden
   there (redundant with the hamburger, which morphs into an X in place)
   -- so the hamburger-turned-X inside .nav__right, un-hidden by scoping
   this rule to desktop, stays the *only* close control mobile has.
   Disabling pointer-events sitewide had silently taken that away too,
   leaving mobile with no way to close the panel at all. */
@media (min-width: 761px) {
  body.is-menu-open .nav { pointer-events: none; }
}
/* Mobile only: nav slides away on scroll-down, back on scroll-up (see
   js/main.js). Desktop keeps it always visible. */
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  .nav {
    transition: transform 0.3s ease;
  }
  .nav.is-hidden { transform: translateY(-100%); }
}
/* Dark nav text for whichever mobile page currently has no background
   photo to sit over (white text there would be invisible against the
   plain page background) -- toggled by js/main.js per page, not tied to
   any one page by name anymore now that all of Service/About/Awards/
   Contact/Impressum/Datenschutz can each optionally show one. */
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  .nav--solid-mobile {
    --nav-fg: var(--color-text);
    --nav-fg-muted: var(--color-text-muted);
  }
}

.nav__inner {
  position: relative;
  padding: 26px 28px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.nav__mark {
  font-family: var(--font-heading);
  font-size: 1.5rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--nav-fg);
  transition: opacity 0.2s ease, transform 0.45s cubic-bezier(.16,.84,.44,1);
  display: flex;
  align-items: center;
}
.nav__mark:hover { opacity: 0.7; }
/* .nav sits above the menu panel (310 vs 300) so the hamburger-to-X
   button stays clickable while the panel is open — but that puts the
   name above it too, visibly bleeding through the panel's own
   transparency. Slid off to the left (opposite the panel's own
   slide-in-from-the-right) so it reads as being pushed off-screen by
   it; only .nav__mark is targeted, not .nav__right, so the menu
   button itself stays put and usable throughout. */
body.is-menu-open .nav__mark {
  transform: translateX(-140%);
  opacity: 0;
  pointer-events: none;
}
/* logo.png loads directly as part of the page itself (a plain <img src>,
   not the async data-photo probe every other photo on this site uses) --
   deliberately not going through resolveStaticPhotos()'s
   fetch-and-swap-in, which needs a round trip through JS before anything
   appears and briefly showed placeholder text in its place while that
   was in flight. A static src has nothing to swap and nothing to flash. */
.nav__mark-logo {
  display: block;
  height: 68px;
  width: auto;
}

/* ---------- Project/category page hero title ----------
   Hovers large over the first tiles, styled exactly like the Service/
   About titles (same size, all white). On scroll it slides straight up
   and shrinks into the nav bar in one continuous motion — a single
   element animated by transform (see updateParallax in js/main.js), not
   two elements crossfading. transform-origin is the top-left corner, so
   translateY alone moves it (purely vertical, no horizontal drift) while
   scale shrinks it toward that same fixed left edge. The element stays
   position:absolute the whole time; the transform math itself cancels
   out the normal scroll-away drift once "docked", which is what makes it
   read as pinned in the nav without ever switching position modes. */
.project-hero { position: relative; }
.project-hero__overlay {
  position: absolute;
  top: 0; left: 0; right: 0;
  /* Above .nav (310) so the docking title still slides over/into the
     nav bar as it lands, same as before .nav was raised above the
     menu panel for the mobile hamburger button. */
  z-index: 315;
  padding-top: calc(var(--editorial-top-pad) + var(--nav-h));
  pointer-events: none;
  /* Same reasoning as .nav__mark above: this being above the menu panel
     is what lets the docking title slide *over* it while scrolling, but
     it also bled through while the panel is open. Transformed on this
     wrapper, not .project-hero__title itself — main.js rewrites the
     title's own transform every scroll frame for the docking effect, so
     a CSS transition on that same element/property would just get
     fought over; a transform on the (otherwise untouched) parent
     composes with the title's own and slides both together with no
     conflict. */
  transition: transform 0.5s cubic-bezier(.16,.84,.44,1), opacity 0.4s ease;
}
body.is-menu-open .project-hero__overlay {
  transform: translateX(-120%);
  opacity: 0;
}
/* The transform (translateY + scale, applied by js/main.js) lives on the
   title itself, not this wrapper — the wrapper's padding-top is what
   pushes the big title down from the section's top edge, and scaling the
   wrapper would scale that padding too, so the title would land offset
   from the docked target by (padding * scale) instead of exactly on it. */
.project-hero__title {
  font-family: var(--font-display);
  text-transform: uppercase;
  line-height: 0.95;
  letter-spacing: -0.01em;
  font-size: var(--editorial-title-size);
  color: #fff;
  text-shadow: 0 1px 3px rgba(0,0,0,0.55), 0 2px 20px rgba(0,0,0,0.45);
  margin: 0;
  overflow-wrap: anywhere;
  transform-origin: left top;
  /* js/main.js rewrites this element's transform on every scroll frame
     (docking animation) — without will-change the browser re-rasterizes
     the text (plus its blurred text-shadow) at the new scale each frame
     instead of just transforming one cached layer, which is what reads
     as the title shimmering/jittering while scrolling, worst on fast
     scroll-up. */
  will-change: transform;
  /* The docked title stays fixed for the rest of the page by design —
     js/main.js fades it out (opacity, set directly) if content ever
     scrolls up underneath it, since a position:fixed element has no way
     to be pushed aside by layout on its own. */
  transition: opacity 0.2s ease;
}
.project-hero__title a {
  color: inherit;
  text-decoration: none;
  /* inline, not inline-block — an inline element hit-tests per rendered
     line, tightly hugging just that line's text, instead of one
     rectangle spanning the full container width. inline-block's
     shrink-to-fit width still maxes out at the available container width
     whenever the title wraps to two lines at all (not the width of the
     longest line), so a two-line category/project title was hoverable —
     and blocking clicks to the tiles underneath — across its entire
     horizontal band on both lines, not just the visible words. */
  display: inline;
  pointer-events: auto;
  transition: opacity 0.2s ease;
}
.project-hero__title a:hover { opacity: 0.75; }
/* Category pages only (project detail pages have no .project-hero__intro
   at all, so this never applies there) -- short intro paragraph under the
   category title, over the same photo. Desktop: js/main.js mirrors the
   title's own dock transform onto this element every frame and fades its
   opacity in lockstep, so it visually "rides along" with the title and is
   fully gone by the moment docking completes. Mobile: no JS touches it
   (the title itself has no dock effect on mobile), so it just sits here
   normally and scrolls away with the rest of the page like any other
   text. */
.project-hero__intro {
  max-width: 46ch;
  margin: 14px 0 0;
  font-size: 15px;
  line-height: 1.55;
  color: rgba(255,255,255,0.85);
  text-shadow: 0 1px 3px rgba(0,0,0,0.5), 0 2px 14px rgba(0,0,0,0.35);
  transform-origin: left top;
  will-change: transform;
}

/* Project detail pages: photo 1 is a full-viewport sticky hero, photos 2+
   sit in a normal (non-sticky) tile grid directly after it — same "cover
   slide" mechanic as the About→Awards stack, but simpler: the gallery
   grid doesn't need to be sticky itself, since default DOM-paint-order
   already puts it above the hero as it scrolls up (explicit z-index here
   just makes that certain rather than relying on the implicit rule).
   Once the gallery's own content is exhausted, normal scrolling continues
   straight into the "About the project" section below — the hero panel's
   sticky window is exactly the gallery's height (a sticky element's stuck
   duration comes from trailing content, not its own height), so it can
   never outlast the thing covering it. */
.project-stack { position: relative; }
.project-hero-panel {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  z-index: 1;
}
.project-stack .tile-grid--projects {
  position: relative;
  z-index: 2;
}
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  .project-hero__overlay { padding-top: clamp(90px, 18vh, 140px); }
  .project-hero__title { font-size: clamp(2.4rem, 13vw, 5.5rem); line-height: 1.05; }
  /* Used to also slide/fade out in sync with .nav.is-hidden here (the
     same scroll-direction class, toggled on this element too every
     scroll frame) -- reported: on a real phone, the title would exit at
     the top then immediately re-enter from the bottom, ending up stuck
     at the bottom edge of the last gallery photo, only releasing again
     near the very top of the page. Root cause: unlike .nav (position:
     fixed, purely viewport-relative), this title is position:absolute
     against .project-hero -- a section whose height includes the sticky
     hero photo *and* the whole gallery beneath it. It was already
     scrolling itself out of view from ordinary document flow within
     about one screen of scrolling, well before the gallery's sticky
     photo even releases -- the .is-nav-hidden transform was fighting an
     element that was, for all practical purposes, already gone, and
     doing so right as the sticky panel's own containing block changes
     (the gallery finishing, releasing the stuck photo) is exactly the
     kind of moment mobile WebKit has repeatedly gotten scroll-position
     timing wrong on elsewhere in this codebase. Dropping the coupling
     removes that fight entirely -- the title still disappears early via
     its own ordinary scroll, just without a second, redundant mechanism
     also trying to move it at the same time. */
}
@media (prefers-reduced-motion: reduce) {
  .project-hero__overlay { position: relative; z-index: 5; }
}

.nav__right { display: flex; align-items: center; gap: 20px; transition: opacity 0.2s ease; }
/* Desktop only -- see the matching .nav rule above for why. .menu-panel
   has its own dedicated close (X) button there, so hiding the nav's own
   menu button too (along with the language toggle) while open isn't a
   usability regression on desktop, and stops both bleeding through the
   panel's own transparency. Mobile has no such second close button: the
   hamburger inside here IS the close control (it morphs into an X), so
   hiding it left mobile with literally nothing to tap to get out of the
   open panel -- reported as "no X, doesn't close". */
@media (min-width: 761px) {
  body.is-menu-open .nav__right {
    opacity: 0;
    pointer-events: none;
  }
}

/* iOS-style switch: a pill track with a round thumb sliding between DE
   and EN. Previously the letters were cut out of the *track* itself,
   with the thumb just a plain white circle sliding underneath -- moved
   the cutout onto the thumb instead, so the circle you're actually
   watching slide carries whichever language it's about to switch you
   to, rather than uncovering a letter that was already sitting there.
   Two SVGs (.lang-toggle__thumb-cutout, one per language, same mask
   technique as the menu button below) sit stacked inside the thumb;
   the sitewide html[data-lang] rule that already hides every other
   [data-lang] pair on the page hides whichever one isn't current, no
   extra JS needed. The track itself is now a plain CSS pill (its own
   text cutout would only fight with the thumb's). Real, independently
   clickable <button data-set-lang> elements still drive the switch in
   main.js as always, just invisible, layered above everything so
   clicks land. main.js slides the thumb via the .lang-toggle--en class
   alongside the existing [data-active] attribute. */
.lang-toggle {
  position: relative;
  width: 58px;
  height: 30px;
  border-radius: 15px;
  background: rgba(255,255,255,0.4);
}
.lang-toggle button {
  position: absolute;
  z-index: 3;
  top: 0;
  width: 29px;
  height: 100%;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  outline: none;
  -webkit-tap-highlight-color: transparent;
}
.lang-toggle button[data-set-lang="de"] { left: 0; }
.lang-toggle button[data-set-lang="en"] { right: 0; }
.lang-toggle__thumb {
  position: absolute;
  z-index: 1;
  top: 3px;
  left: 3px;
  width: 24px;
  height: 24px;
  transition: transform 0.25s cubic-bezier(.4,0,.2,1);
  /* Shadow lives here (a plain box-shadow, rimming the thumb's own round
     edge) rather than as a filter:drop-shadow on the cutout SVG below --
     drop-shadow follows alpha edges, which would rim the letter-hole's
     inner edge too and read as a shadow bleeding through the cutout
     letter itself. box-shadow only ever looks at this element's own box,
     so it can't do that. */
  border-radius: 50%;
  box-shadow: 0 0 0.6px rgba(0,0,0,0.55), 0 0 1.2px rgba(0,0,0,0.35);
}
.lang-toggle--en .lang-toggle__thumb { transform: translateX(28px); }
.lang-toggle__thumb-cutout {
  position: absolute;
  inset: 0;
  display: block;
  /* A mask hole is fully transparent, not tinted, so it shows exactly
     whatever sits behind the thumb -- the track's own near-white fill --
     which is what makes the letter cutout read at all. */
}

/* Menu button: solid white shape with the label knocked out of it,
   so whatever sits behind the nav shows through the letterforms. */
.menu-btn {
  background: none;
  border: none;
  padding: 0;
  line-height: 0;
  cursor: pointer;
  filter: drop-shadow(0 1px 3px rgba(0,0,0,0.4));
  transition: opacity 0.2s ease;
}
.menu-btn:hover { opacity: 0.7; }
.menu-btn__cutout svg { display: block; }

/* Mobile: the SVG cutout label gives way to a plain 3-line hamburger
   that morphs into an X in place — same button, same box, no position
   jump between the closed and open state. */
.menu-btn__burger { display: none; }
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  .menu-btn__cutout { display: none; }
  .menu-btn__burger {
    position: relative;
    display: block;
    width: 26px;
    height: 17px;
  }
  .menu-btn__burger span {
    position: absolute;
    left: 0;
    width: 100%;
    height: 2px;
    background: #fff;
    border-radius: 1px;
    transition: transform 0.35s ease, opacity 0.2s ease, top 0.35s ease;
  }
  .menu-btn__burger span:nth-child(1) { top: 0; }
  .menu-btn__burger span:nth-child(2) { top: 7.5px; }
  .menu-btn__burger span:nth-child(3) { top: 15px; }
  .menu-btn[aria-expanded="true"] .menu-btn__burger span:nth-child(1) { top: 7.5px; transform: rotate(45deg); }
  .menu-btn[aria-expanded="true"] .menu-btn__burger span:nth-child(2) { opacity: 0; }
  .menu-btn[aria-expanded="true"] .menu-btn__burger span:nth-child(3) { top: 7.5px; transform: rotate(-45deg); }
}

/* ---------- Slide menu panel ---------- */

.menu-panel {
  position: fixed;
  inset: 0 0 0 auto;
  width: min(420px, 88vw);
  background: rgba(10, 9, 11, 0.3);
  backdrop-filter: blur(14px);
  color: var(--color-panel-text);
  z-index: 300;
  transform: translateX(100%);
  transition: transform 0.45s cubic-bezier(.16,.84,.44,1);
  display: flex;
  flex-direction: column;
  padding: var(--gutter);
  /* Vertically centered nav+footer normally fit with room to spare, but
     on a short viewport (mobile landscape) they can be taller than the
     panel itself. overflow-y:auto alone doesn't fix that with
     justify-content:center — browsers park the initial scroll position
     at the visual centre, and the "before centre" overflow (the top
     items) stays unreachable no matter how you scroll, so the classic
     fix is used instead: flexible ::before/::after spacers with
     margin:auto centre the content when there's room to spare and
     shrink to zero when there isn't, which keeps the whole thing
     reachable by scrolling either way. */
  overflow-y: auto;
}
.menu-panel::before,
.menu-panel::after {
  content: '';
  margin: auto 0;
}
.menu-panel.is-open { transform: translateX(0); }

.menu-panel__close {
  position: absolute;
  top: 24px;
  right: 24px;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  color: var(--color-panel-text);
  cursor: pointer;
  padding: 0;
}
.menu-panel__close::before,
.menu-panel__close::after {
  content: '';
  position: absolute;
  width: 22px;
  height: 1px;
  background: currentColor;
}
.menu-panel__close::before { transform: rotate(45deg); }
.menu-panel__close::after { transform: rotate(-45deg); }
/* Mobile: redundant now that the hamburger itself morphs into the close
   control, and would otherwise sit right next to it in the same corner
   as a second, overlapping X. Placed after the base rule above (rather
   than inside the .menu-btn__burger block near the top of this file) so
   it actually wins the cascade — same specificity, source order decides. */
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  .menu-panel__close { display: none; }
}

.menu-panel__nav { display: flex; flex-direction: column; }
.menu-panel__nav a {
  font-family: var(--font-display);
  text-transform: uppercase;
  /* min(8vw, 6vh): sized off whichever dimension is actually tight. On a
     normal portrait phone width is the constraint and this behaves just
     like the old 8vw-only version; on a landscape phone (reported: the
     five links barely fit, needed scrolling) height is by far the
     tighter dimension, and 6vh keeps the same five links comfortably
     within the shorter panel instead of sizing as if it had a portrait
     phone's full height to spend. */
  font-size: clamp(2.2rem, min(8vw, 6vh), 3.4rem);
  line-height: 1.02;
  padding: 4px 0;
  transition: color 0.2s ease, transform 0.2s ease;
  transform-origin: left center;
}
.menu-panel__nav a:hover { color: var(--color-panel-hover); transform: translateX(6px); }

.menu-panel__foot {
  margin-top: 40px;
  padding-top: 20px;
  border-top: 1px solid rgba(250,248,245,0.2);
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 14px;
}
.menu-panel__social { display: flex; gap: 16px; font-family: var(--font-heading); font-size: 0.85rem; }
.menu-panel__social a:hover { color: var(--color-panel-hover); }
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  /* Slightly narrower panel on mobile (asked for), and the four social/
     legal links centred on their own row rather than left-flush with a
     big gap on the right. That flush-left result wasn't a sizing issue
     -- .menu-panel__foot's justify-content:space-between only has two
     items (the footer text, then .menu-panel__social as a whole); once
     the panel's narrow enough that they wrap onto separate lines,
     space-between has nothing left to split on .menu-panel__social's own
     now-solo line, so it just sits at the line's start. Forcing it onto
     its own full-width row here and centring *within* that row (it's
     already its own flex container) is what actually balances the
     space either side of it, rather than a panel-width tweak alone
     fixing an alignment rule it was never actually about. */
  .menu-panel { width: min(380px, 84vw); }
  /* gap trimmed down too -- at the old 16px, the four links' combined
     natural width (295px) slightly exceeded even the *un-narrowed*
     panel's own content width (287px), so centring alone still left it
     overflowing a few px past the right edge instead of actually
     balancing. */
  .menu-panel__social { flex-basis: 100%; justify-content: center; gap: 10px 16px; }
}

.menu-scrim {
  position: fixed;
  inset: 0;
  background: rgba(23,22,26,0.3);
  z-index: 290;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease, backdrop-filter 0.3s ease;
  /* Blurs the rest of the page while the menu is open, so only the (still
     sharp) menu panel itself stays readable -- opacity alone (above) only
     dimmed it. backdrop-filter here needs no vendor prefix/fallback: every
     browser this site otherwise targets already supports it, since
     .menu-panel a few rules up already relies on the same property. */
  backdrop-filter: blur(0px);
}
.menu-scrim.is-open { opacity: 1; pointer-events: auto; backdrop-filter: blur(6px); }

/* ---------- Placeholder photo boxes ---------- */

.ph {
  background: var(--color-bg-alt);
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  width: 100%;
  height: 100%;
  min-height: 140px;
  color: var(--color-text-muted);
  font-family: var(--font-heading);
  font-size: 0.78rem;
  letter-spacing: 0.02em;
  padding: 16px;
  transition: opacity 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}

/* ---------- Hero ---------- */

.hero-tile {
  position: relative;
  overflow: hidden;
  height: 100vh;
  height: 100dvh;
  padding: 0;
  background: var(--color-bg-alt);
}
.hero-tile .ph { border: none; min-height: 100%; margin: 0; }
.hero-tile__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(23,22,26,0) 40%, rgba(23,22,26,0.72) 100%);
  pointer-events: none;
}
.hero-tile__content {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: clamp(24px, 5vw, 56px) var(--gutter);
  color: #fff;
  transform-origin: bottom left;
  will-change: transform;
}
.hero-tile__content .eyebrow { color: #fff; opacity: 0.85; }
.hero-tile__content h1 { color: #fff; font-size: clamp(2.4rem, 7vw, 5.2rem); margin-bottom: 0.2em; }
.hero-tile__content p { color: rgba(250,248,245,0.85); max-width: 46ch; margin-bottom: 1.3em; }
@media (max-width: 760px) {
  /* .display's 0.86 line-height is tuned for a single line — at this
     width the headline reliably wraps to two, and the lines nearly
     touch with no gap (same issue already fixed for the other big
     titles above). */
  .hero-tile__content h1 { line-height: 1.05; }
}
/* Phone in landscape: .hero-tile is still 100dvh, but that's now a short
   dimension (~375-430px) rather than a tall one -- the content block
   (bottom-anchored, sized for a tall portrait screen) no longer fits
   underneath the fixed nav and rode up into/behind it (reported: the
   eyebrow line sat under the nav). Trimmed to just title + actions,
   noticeably smaller, so the whole block comfortably clears the nav
   instead of needing to be exactly measured against it. */
@media (orientation: landscape) and (max-height: 600px) {
  .hero-tile__content { padding: 12px var(--gutter); }
  .hero-tile__content .eyebrow { display: none; }
  .hero-tile__content h1 { font-size: clamp(1.3rem, 4.5vw, 2.1rem); line-height: 1.05; margin-bottom: 0.15em; }
  .hero-tile__content p { display: none; }
  .hero-tile__actions { gap: 16px; }
}
.hero-tile__actions { display: flex; gap: 28px; flex-wrap: wrap; }

/* ---------- Sections ---------- */

section { padding: clamp(50px, 9vh, 100px) 0; }
.section--flush { padding: 0; }

.section-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: 20px;
  margin-bottom: 36px;
}
.section-head h2 { font-size: clamp(1.7rem, 3.4vw, 2.6rem); margin: 0; }
.section-alt { background: var(--color-bg-alt); }

/* ---------- Tile grid ---------- */

.tile-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  /* Row height = half the column width, so a tile--tall (2 rows) is an
     exact square: 2 * 25vw = 50vw = one column's own width. */
  grid-auto-rows: 25vw;
  grid-auto-flow: dense;
  gap: 0;
}

/* Category listing pages: the tile grid itself fills the window on load
   (nav floats over it, same as project pages) — the category title is a
   .project-hero__overlay that hovers directly over the existing tiles and
   docks into .nav__center on scroll, it does not add a separate hero/photo
   section or push the grid down. See .project-hero below. */
.category-page-body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;
}
.category-grid-section { flex: 1 1 auto; display: flex; }
/* Only single/duo grids (explicitly sized to 1fr rows, see below) should
   stretch to fill the section — a normal masonry grid has its own natural
   row height (grid-auto-rows: 25vw, same as the homepage) and stretching
   it to fill the viewport compresses every row into thin slivers once
   there are enough tiles to need several rows. align-self:flex-start
   opts a masonry grid out of the flex container's default stretch. */
.category-grid-section .tile-grid { width: 100%; }
.category-grid-section .tile-grid:not(.tile-grid--single):not(.tile-grid--duo) {
  align-self: flex-start;
}
/* Single-project category: that one tile takes the full width and the
   full remaining height instead of sitting in a half-width column. */
.tile-grid--single { grid-template-rows: 1fr; }
.tile-grid--single .tile {
  grid-column: 1 / -1 !important;
  grid-row: 1 / -1 !important;
}
/* Two-project category: both tiles take the full available height,
   side by side in the existing two columns, instead of half-height
   masonry tiles with empty space below. */
.tile-grid--duo { grid-template-rows: 1fr; }
.tile-grid--duo .tile {
  grid-column: span 1 !important;
  grid-row: 1 / -1 !important;
}
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  /* The base (unscoped) .tile-grid--duo rule above sets grid-template-rows:
     1fr and grid-row: 1 / -1 !important on both tiles -- meant for the
     desktop side-by-side layout, but with no media condition it still
     applies on mobile too. Left alone, that collapses both tiles into the
     same single 1fr row (overlapping) instead of letting them fall through
     to the plain .tile-grid mobile rule below. This override restores
     normal full-width stacking for a two-project category, matching every
     other category grid, and then applies the same first-tile-tall
     treatment as .category-grid-section .tile-grid--projects > .tile:
     first-child further down -- needed here explicitly (with !important)
     because that rule's plain specificity can't beat the base duo rule's
     !important on its own. */
  .tile-grid--duo { grid-template-rows: 300px 300px; }
  .tile-grid--duo .tile {
    grid-column: 1 / -1 !important;
    grid-row: span 1 !important;
  }
  .category-grid-section .tile-grid--duo .tile:first-child {
    grid-row: span 2 !important;
  }
}

.tile { position: relative; display: block; grid-column: span 1; }
.tile--full { grid-column: span 2; }
/* Row height is half the column width (grid-auto-rows: 25vw above), so a
   1-row tile reads as landscape (2:1), a 2-row tile (tile--tall) is an
   exact square, and a 3-row tile (tile--portrait) is a true upright
   rectangle (2:3) — matched to the actual photo's orientation by
   js/main.js rather than assigned at random. */
.tile--tall { grid-row: span 2; }
.tile--portrait { grid-row: span 3; }

/* Also applies to a phone in landscape, not just portrait -- a phone
   turned sideways is still often wider than 760px, so width alone was
   letting it fall through to the desktop 2-column grid (reported: no
   longer edge-to-edge, tiles picked up the desktop grid's side margins). */
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  .tile-grid { grid-template-columns: 1fr; grid-auto-rows: 300px; }
  .tile { grid-column: span 1 !important; }
  .tile--tall, .tile--portrait { grid-row: span 1; }
  /* Category-listing pages only (not the homepage grid, not a project's
     own photo gallery — both use .tile-grid--projects too, neither sits
     inside .category-grid-section) -- the opening project gets a double
     row so its photo reads as a proper lead image and the next tile still
     peeks in underneath, instead of every mobile tile being the same
     size. .tile__caption is bottom-anchored (see its own rule) regardless
     of the tile's height, so the title/year/country still sits in the
     same normal single-tile-sized band at the bottom, not stretched or
     centred across the taller tile. */
  .category-grid-section .tile-grid--projects > .tile:first-child { grid-row: span 2; }
}

.tile__media {
  position: relative;
  overflow: hidden;
  width: 100%;
  height: 100%;
  background-color: var(--color-bg-alt);
  transition: background-color 0.4s ease;
}

/* Category tiles: idle background is a plain near-white, not the warm
   beige placeholder tone — the coloured --tile-hover only shows on hover. */
#categories .tile:nth-of-type(odd) .tile__media { background-color: #ffffff; }
#categories .tile:nth-of-type(even) .tile__media { background-color: #f5f5f5; }

/* Real photos sit on top of the placeholder pattern and cover it once
   loaded. Until a file exists at that path the <img>'s onerror attribute
   hides it, so the .ph placeholder shows through underneath —
   drop a numbered photo in and the page picks it up with no code change. */
.tile__media > img,
.hero-tile > img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Homepage category tiles: hover reveals one colour per category */
#categories .tile:nth-of-type(1) { --tile-hover: var(--cat-1); }
#categories .tile:nth-of-type(2) { --tile-hover: var(--cat-2); }
#categories .tile:nth-of-type(3) { --tile-hover: var(--cat-3); }
#categories .tile:nth-of-type(4) { --tile-hover: var(--cat-4); }
#categories .tile:nth-of-type(5) { --tile-hover: var(--cat-5); }
#categories .tile:nth-of-type(6) { --tile-hover: var(--cat-6); }
#categories .tile:hover .tile__media { background-color: var(--tile-hover, var(--color-accent)); }
#categories .tile:hover .ph { opacity: 0.22; border-color: rgba(255,255,255,0.6); color: rgba(255,255,255,0.9); }

/* Service tile: a permanent white "drafting paper" CTA banner — no
   colour-reveal on hover (that's for the photographed categories above).
   It carries a technical line-drawing of a chair — pencil-grey on white,
   like a blueprint — that draws itself once the tile scrolls into view
   (js/main.js toggles .is-drawn), each group revealed in turn via
   stroke-dasharray/dashoffset with the SVG's own pathLength="1"
   (normalises the reveal regardless of each path's real length):
   drafting guide lines first, then the angle/protractor circle, then the
   chair itself, then its dimension lines, then the handwritten notes. */
#categories .tile.tile--cta .tile__media { background-color: var(--color-bg); }
#categories .tile.tile--cta .tile__cat,
#categories .tile.tile--cta .tile__title { color: #fff; }

.tile__sketch {
  position: absolute;
  top: 50%;
  right: 4%;
  width: 46%;
  max-width: 480px;
  height: auto;
  transform: translateY(-50%);
  color: rgba(23,22,26,0.62);
  transition: color 0.4s ease;
  pointer-events: none;
}
#categories .tile.tile--cta:hover .tile__sketch { color: rgba(23,22,26,0.85); }

.tile__sketch-guide,
.tile__sketch-angle,
.tile__sketch-base,
.tile__sketch-dims,
.tile__sketch-notes {
  opacity: 0;
}
.tile__sketch-guide path,
.tile__sketch-angle path,
.tile__sketch-angle circle,
.tile__sketch-base path,
.tile__sketch-dims path,
.tile__sketch-notes path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
}
.tile__sketch-guide,
.tile__sketch-guide path { transition: opacity 0.2s ease, stroke-dashoffset 0.7s ease; }
.tile__sketch-angle,
.tile__sketch-angle path,
.tile__sketch-angle circle { transition: opacity 0.2s ease 0.55s, stroke-dashoffset 0.9s ease 0.55s; }
.tile__sketch-base,
.tile__sketch-base path { transition: opacity 0.2s ease 1.15s, stroke-dashoffset 1.3s ease 1.15s; }
.tile__sketch-dims,
.tile__sketch-dims path { transition: opacity 0.2s ease 2.3s, stroke-dashoffset 0.9s ease 2.3s; }
.tile__sketch-notes { transition: opacity 0.6s ease 2.95s; }
.tile__sketch-notes path { transition: opacity 0.2s ease 2.95s, stroke-dashoffset 0.6s ease 2.95s; }
.tile__sketch.is-drawn .tile__sketch-guide,
.tile__sketch.is-drawn .tile__sketch-angle,
.tile__sketch.is-drawn .tile__sketch-base,
.tile__sketch.is-drawn .tile__sketch-dims,
.tile__sketch.is-drawn .tile__sketch-notes {
  opacity: 1;
}
.tile__sketch.is-drawn .tile__sketch-guide path,
.tile__sketch.is-drawn .tile__sketch-angle path,
.tile__sketch.is-drawn .tile__sketch-angle circle,
.tile__sketch.is-drawn .tile__sketch-base path,
.tile__sketch.is-drawn .tile__sketch-dims path,
.tile__sketch.is-drawn .tile__sketch-notes path {
  stroke-dashoffset: 0;
}
.tile__sketch-guide path { stroke-width: 1; opacity: 0.5; }
.tile__sketch-dims path { stroke-width: 1; }
.tile__sketch-dims text,
.tile__sketch-angle text { font-family: var(--font-heading); font-size: 9px; letter-spacing: 0.02em; fill: currentColor; stroke: none; }
.tile__sketch-notes text {
  font-family: Georgia, 'Times New Roman', serif;
  font-style: italic;
  font-size: 11px;
  fill: currentColor;
  stroke: none;
}
@media (max-width: 760px) {
  .tile__sketch { width: 64%; right: 6%; }
}

/* Project detail pages: own photos open in the lightbox on click —
   including the hero panel's photo (photo 1), which isn't part of a
   gallery grid at all. */
.tile-grid--projects[data-fixed-layout] .tile__media,
.project-hero-panel .tile__media {
  cursor: zoom-in;
}
.project-hero-panel .tile__media img { transition: transform 0.4s ease; }
.project-hero-panel:hover .tile__media img { transform: scale(1.03); }

/* Hero-photo blur-in reveal: the sitewide page-fade-in on body (see
   above) is a short fixed timer tied to navigation start, not to when
   this page's own big above-the-fold photo actually finishes loading --
   fine for text-heavy pages, but on Project/Service/Contact/Impressum/
   Datenschutz/About/Awards a slow-loading hero photo pops in well after
   that fade is already over, so the blur never reads. Same problem hits
   a category page's own project tiles even harder -- their photos come
   from a GitHub API discovery call *before* the tile markup even exists,
   so on a slow connection the whole grid can still be empty well past
   the point body's fade has already finished.
   Blurred/faded on the *container* (.page-hero__bg etc.), not the <img>
   itself -- every one of these sits under a permanently-visible
   .stack__scrim (the dark gradient every hero photo needs for legible
   overlaid text; only About/Awards' mobile glass card turns it off,
   relying on its own tint instead, see .stack__page-bg .stack__scrim
   further down). Blurring just the photo underneath a scrim that never
   itself changes barely reads as a blur at all on desktop, where that
   scrim + text sits over EVERY hero photo -- blurring the container
   blurs the scrim along with it, so the whole composed visual (photo
   *and* the darkening over it) resolves together, the same way it
   already visibly does on mobile where there's no scrim in the way.
   Clears once main.js confirms the photo has actually loaded
   (.is-revealed, toggled off the same photoresolved event the
   nav-colour/footer-tint logic above already uses -- attachPhotoReveal()
   re-runs it against a category grid's freshly-built tiles too), so the
   reveal always lines up with what's actually appearing on screen. */
.page-hero__bg,
.stack__bg,
.stack__page-bg {
  opacity: 0;
  filter: blur(18px);
  transition: opacity 0.6s ease, filter 0.6s ease;
}
.project-hero-panel .tile__media,
.tile-grid--projects:not([data-fixed-layout]) .tile__media {
  opacity: 0;
  filter: blur(18px);
  transition: opacity 0.6s ease, filter 0.6s ease;
}
.page-hero__bg.is-revealed,
.stack__bg.is-revealed,
.stack__page-bg.is-revealed,
.project-hero-panel .tile__media.is-revealed,
.tile-grid--projects:not([data-fixed-layout]) .tile__media.is-revealed {
  opacity: 1;
  filter: none;
}

/* Project tiles (category & project pages): hover darkens with a tint */
.tile-grid--projects .tile__media::after {
  content: '';
  position: absolute;
  inset: 0;
  background: rgba(10, 9, 11, 0.7);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}
.tile-grid--projects .tile:hover .tile__media::after,
.tile-grid--projects .tile.is-tap-focused .tile__media::after { opacity: 1; }
/* A project's own photo grid has no caption to reveal — skip the dark hover tint, use a subtle zoom cue instead */
.tile-grid--projects[data-fixed-layout] .tile__media::after { content: none; }
.tile-grid--projects[data-fixed-layout] .tile__media img,
.tile-grid--projects[data-fixed-layout] .tile__media .ph { transition: transform 0.4s ease; }
.tile-grid--projects[data-fixed-layout] .tile:hover .tile__media img,
.tile-grid--projects[data-fixed-layout] .tile:hover .tile__media .ph { transform: scale(1.03); }

/* Project tiles on a category page: the focused tile (hover on desktop,
   first tap on touch -- see js/main.js for the .is-tap-focused toggle)
   zooms in slightly, same cue as the fixed-layout gallery above. */
.tile-grid--projects:not([data-fixed-layout]) .tile__media img,
.tile-grid--projects:not([data-fixed-layout]) .tile__media .ph {
  transition: transform 0.4s ease;
}
.tile-grid--projects:not([data-fixed-layout]) .tile:hover .tile__media img,
.tile-grid--projects:not([data-fixed-layout]) .tile:hover .tile__media .ph,
.tile-grid--projects:not([data-fixed-layout]) .tile.is-tap-focused .tile__media img,
.tile-grid--projects:not([data-fixed-layout]) .tile.is-tap-focused .tile__media .ph {
  transform: scale(1.06);
}

.tile__scrim {
  position: absolute;
  inset: auto 0 0 0;
  height: 60%;
  background: linear-gradient(180deg, rgba(23,22,26,0) 0%, rgba(23,22,26,0.72) 100%);
  pointer-events: none;
}

.tile__caption {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 16px 20px;
  color: #fff;
  will-change: transform;
  z-index: 1;
}
.tile__year { font-family: var(--font-heading); font-size: 0.72rem; letter-spacing: 0.06em; opacity: 0.85; }
.tile__title { font-size: clamp(2.2rem, 5vw, 3.8rem); line-height: 0.96; color: #fff; margin: 10px 0 0; transform-origin: bottom left; transition: transform 0.35s cubic-bezier(.2,.8,.3,1); }
.tile__cat { font-family: var(--font-heading); font-size: 0.8rem; opacity: 0.85; display: block; margin-top: 2px; transition: opacity 0.25s ease; }

/* Category tiles: title grows on hover and overtakes the project count.
   Shrink-wrap the box (instead of the full tile width) so the scale-up
   can't push it past the tile edge — long headlines wrap to a second
   line instead of being clipped by the tile's overflow:hidden. */
#categories .tile__title { margin-top: 6px; display: inline-block; max-width: 75%; }
#categories .tile:hover .tile__title { transform: scale(1.32); }
#categories .tile:hover .tile__cat { opacity: 0; }

/* Project tiles: image only until hover, then dark tint + title/meta */
.tile-grid--projects .tile__caption {
  opacity: 0;
  transition: opacity 0.3s ease;
}
.tile-grid--projects .tile:hover .tile__caption,
.tile-grid--projects .tile.is-tap-focused .tile__caption { opacity: 1; }
.tile-grid--projects .tile__title { margin-top: 0; }
.tile__meta {
  display: block;
  margin-top: 8px;
  font-family: var(--font-heading);
  font-size: 0.8rem;
  letter-spacing: 0.04em;
  opacity: 0.85;
}

/* Category tile cover photo — fills the tile edge-to-edge like any other
   photo tile (cover, cropped to the tile's own shape), just with the same
   fade/blur-in reveal as it loads. Used to be a centered, uncropped
   "cutout" floating over a colour background (for isolated product shots
   on transparent PNGs); real photography needs to fill the frame instead. */
.tile__media > img.tile__object {
  pointer-events: none;
  opacity: 0;
  filter: blur(18px);
  transform: translateY(22px);
  transition: opacity 0.9s ease, filter 0.9s ease, transform 0.9s ease;
}
.tile__media > img.tile__object.is-revealed {
  opacity: 1;
  filter: blur(0);
  transform: translateY(0);
}

/* ---------- Services / skills strip ---------- */

.strip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; }
@media (max-width: 760px) { .strip { grid-template-columns: 1fr; } }
.strip__item { border-top: 1px solid var(--color-line); padding-top: 18px; }
.strip__item .eyebrow { margin-bottom: 0.6em; }

/* ---------- About ---------- */

.about-grid {
  display: grid;
  grid-template-columns: 0.8fr 1.2fr;
  gap: clamp(30px, 6vw, 70px);
  align-items: start;
  margin-top: clamp(10px, 2vh, 24px);
}
.about-grid .ph { min-height: 460px; }
@media (max-width: 860px) {
  .about-grid { grid-template-columns: 1fr; }
  .about-grid .ph { min-height: 300px; }
}
/* Project pages ("Über das Projekt"): description paragraph in the same
   muted grey as the Kategorie/Land/Jahr/Material list next to it, instead
   of the default near-black body text — one consistent text colour for
   all of a project's info, not two. Scoped away from the About page's own
   .about-grid.stack__body (light text over a photo background there). */
.about-grid:not(.stack__body) p { color: var(--color-text-muted); }
.stack__body p { margin: 0 0 1.3em; }
.stack__body p:last-child { margin-bottom: 0; }

.timeline { margin-top: clamp(20px, 4vh, 40px); }
.timeline__row {
  display: grid;
  grid-template-columns: 110px 1fr;
  gap: 20px;
  padding: 18px 0;
  border-top: 1px solid var(--color-line);
}
.timeline__row time { font-family: var(--font-heading); color: var(--color-text-muted); font-size: 0.85rem; }
/* var(--color-text-muted) is a warm grey tuned for the light body
   background — on About/Awards' dark photo panels it needs to be part of
   the same white family as the rest of .stack__body, just dimmed for
   hierarchy, not a colour from a different palette entirely. */
.stack__body .timeline__row time { color: rgba(250,248,245,0.6); }
.stack__body .timeline__row { border-top-color: rgba(255,255,255,0.25); }

/* ---------- Stacking full-screen panels (About) ----------
   Rebuilt from an earlier position:sticky "cover slides over the
   previous panel" version, which relied on two entangled scroll
   mechanisms at once: the outer page scroll (driven by sticky + a
   dynamically toggled scroll-snap-type) *and* an inner overflow-y:auto
   scrollbar on .stack__content so panels with more copy than fits in
   one screen were still readable. Nested scroll regions like that are
   exactly the kind of thing mobile WebKit handles unreliably -- the
   whole string of reports (hard scroll lock, swipe stopping dead at the
   panel boundary, cover rendering cropped) trace back to the hand-off
   between those two contexts, not to any one of the individual fixes
   layered on top of it.
   This version drops both: each panel is normal, ordinary block-flow
   content with min-height (not height) covering at least one screen and
   growing taller on its own if the content needs more room, exactly the
   same self-sizing pattern .page-hero--photo (Service) already uses
   without issue. There's only one scroll context on the whole page,
   same as everywhere else on the site. The remaining "About -> Awards
   auto-completes on a scroll past the end" flourish is done in JS (see
   the .stack IIFE in main.js) purely by nudging the *resting* scroll
   position once a gesture has settled, never by touching native scroll
   machinery -- so there's nothing left for a real device to get stuck
   fighting. */

/* Desktop: .stack is its own scroll container (not the page), snapping
   to each panel's start and stopping there even on a fast fling
   (scroll-snap-stop: always) -- so Awards is always actually seen, never
   skipped past in one motion. The footer lives as a plain (non-snapping)
   third child inside .stack, not outside it as a separate page section --
   continuing to scroll past Awards' snap point reaches it as ordinary
   free scroll within the same single context. That one-context design
   is deliberate, not incidental: an earlier version kept the footer
   outside .stack, relying on the browser's scroll-chaining hand-off
   between two separate scroll contexts (.stack's internal one and the
   page's own) -- reaching the footer worked, but scrolling back *up*
   from it didn't reliably hand control back immediately, since which of
   the two contexts a given wheel event's delta lands in depends on
   exactly where the cursor is hit-testing, and .stack visually covers
   almost the whole viewport even with the page scrolled just far enough
   to reveal the footer's few px. One scroll context removes that
   ambiguity entirely: there's only one scrollTop to reason about, so
   scrolling up starts closing the footer on the very first tick,
   regardless of cursor position. This whole approach also replaces an
   even earlier version that computed pixel boundaries in a wheel
   listener and drove scrollTo() by hand: reported repeatedly, most
   recently in Safari, to still overshoot into the footer -- almost
   certainly because a trackpad's momentum scroll is decided by the
   OS/browser the instant the gesture starts, so a JS preventDefault() a
   few frames later can lose that race. Plain scroll-snap doesn't have
   that race: the browser itself decides where momentum lands, and (with
   everything in one scroll context) there's no second context for that
   decision to disagree with. */
@media (min-width: 761px) {
  .stack {
    height: 100vh;
    height: 100dvh;
    overflow-y: auto;
    scroll-snap-type: y mandatory;
  }
}
.stack { position: relative; }
/* Wraps just the two panels (not the footer) so a single mobile-only
   background photo (see .stack__page-bg, mobile media query below) can
   span exactly them, edge to edge, without also running behind the
   footer. display:contents on desktop removes it from the box tree
   entirely -- .stack__panel stays a direct child of .stack there, so the
   existing scroll-snap behaviour (which needs that direct relationship)
   is completely unaffected. */
.stack__group { display: contents; }
.stack__page-bg { display: none; }
@media (min-width: 761px) {
  /* Without a snap point of its own, scroll-snap-stop:always on Awards
     (above) has nothing to advance *to* -- confirmed by testing: even a
     large, separate, later scroll gesture stayed stuck exactly at
     Awards' snap point instead of continuing into the footer at all.
     Giving the footer its own snap point (aligned to the container's
     end, since it's shorter than a full panel) is what lets a further,
     deliberate scroll actually reach it. */
  .stack > footer { scroll-snap-align: end; }
}
.stack__panel {
  position: relative;
  min-height: 100vh;
  min-height: 100dvh;
  padding: 0;
  overflow: hidden;
  background: var(--color-bg-alt);
  /* Overridden off on mobile below, where .stack goes back to being
     plain page flow rather than its own scroll container -- these two
     only mean anything paired with that container. */
  scroll-snap-align: start;
  scroll-snap-stop: always;
  /* Top-packs .stack__content (its one in-flow child) at a fixed offset
     (.stack__content's own padding-top) rather than centring it. Used to
     centre instead, splitting whatever extra room min-height left once
     content was shorter than a screen between top and bottom — but since
     .page-hero--photo (Service) centres too, and centring's actual
     on-screen offset depends on each panel's own content height, About's
     and Awards' titles drifted further from Service's the taller the
     viewport got (reported: still not on the same line). All three share
     the exact same padding-top formula, so packing them to it instead of
     centring is what actually guarantees the same line, at any viewport
     height, regardless of how much copy any one of them has. Mobile
     already used flex-start unconditionally below; this just extends the
     same idea to desktop. */
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
}
@media (max-width: 760px) {
  .stack__panel { min-height: 100svh; }
}

.stack__bg {
  position: absolute;
  inset: 0;
  z-index: 0;
  will-change: transform;
}
.stack__panel .ph { border: none; min-height: 100%; margin: 0; }
.stack__panel img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.stack__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(23,22,26,0.25) 0%, rgba(23,22,26,0.6) 100%);
  pointer-events: none;
}
.stack__panel--light { background: #f5f5f5; }
.stack__panel--light .stack__scrim { display: none; }

/* Down-scroll cue (About panel only) — three chevrons hopping in
   sequence like little footsteps down a staircase, hinting that Awards
   follows below. Replaces an earlier version that only pulsed opacity
   in place (no actual movement) -- asked for something more playful;
   this keeps the same shape, wave direction and blue flash, just adds a
   real (small, quick) hop to each chevron instead of a static blink, and
   settles into a pause before repeating rather than looping continuously,
   so it still reads as restrained rather than distracting. */
.scroll-cue {
  position: absolute;
  right: clamp(20px, 5vw, 64px);
  bottom: clamp(24px, 5vh, 48px);
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  /* Also a real button now (click scrolls down to Awards) — generous
     invisible hit padding around the small chevrons so it's easy to hit
     without enlarging what's actually drawn. */
  padding: 14px;
  margin: -14px;
  background: none;
  border: none;
  cursor: pointer;
}
.scroll-cue:hover span { opacity: 0.7; }
.scroll-cue span {
  width: 16px;
  height: 16px;
  border-right: 3px solid #fff;
  border-bottom: 3px solid #fff;
  /* translateY listed before rotate() is load-bearing, not stylistic --
     transform functions compose left-to-right in the element's own
     (already-rotated-by-later-functions) space, so translateY *after*
     rotate would hop diagonally along the chevron's own tilted axis
     instead of straight down the screen. Listing translateY first keeps
     the hop vertical regardless of the chevron's own 45deg tilt. */
  transform: translateY(0) rotate(45deg);
  opacity: 0.3;
  animation: scrollCueHopDown 1.8s ease-in-out infinite;
}
.scroll-cue span:nth-child(1) { animation-delay: 0s; }
.scroll-cue span:nth-child(2) { animation-delay: 0.12s; }
.scroll-cue span:nth-child(3) { animation-delay: 0.24s; }
@keyframes scrollCueHopDown {
  0%, 42%, 100% { transform: translateY(0) rotate(45deg); opacity: 0.3; border-color: #fff; }
  14% { transform: translateY(7px) rotate(45deg); opacity: 1; border-color: var(--color-panel-hover); }
  24% { transform: translateY(0) rotate(45deg); opacity: 0.55; border-color: #fff; }
  32% { transform: translateY(3px) rotate(45deg); opacity: 0.85; border-color: var(--color-panel-hover); }
}
@media (prefers-reduced-motion: reduce) {
  .scroll-cue span { animation: none; opacity: 0.6; }
}
@media (max-width: 760px) {
  .scroll-cue span { width: 13px; height: 13px; border-width: 2.5px; }
}
/* Up-pointing variant (Awards panel) — same idea, flipped: chevrons
   hop upward and the wave runs bottom-to-top, so the motion's direction
   still matches which way the arrow itself points. */
.scroll-cue--up span { transform: translateY(0) rotate(225deg); animation-name: scrollCueHopUp; }
.scroll-cue--up span:nth-child(1) { animation-delay: 0.24s; }
.scroll-cue--up span:nth-child(2) { animation-delay: 0.12s; }
.scroll-cue--up span:nth-child(3) { animation-delay: 0s; }
@keyframes scrollCueHopUp {
  0%, 42%, 100% { transform: translateY(0) rotate(225deg); opacity: 0.3; border-color: #fff; }
  14% { transform: translateY(-7px) rotate(225deg); opacity: 1; border-color: var(--color-panel-hover); }
  24% { transform: translateY(0) rotate(225deg); opacity: 0.55; border-color: #fff; }
  32% { transform: translateY(-3px) rotate(225deg); opacity: 0.85; border-color: var(--color-panel-hover); }
}

/* Horizontal cousin of .scroll-cue, used inline at the end of a linked
   Awards title (see about.html) -- same hop/glow/grow language pointing
   sideways instead of up/down, so a linked award reads as "leads
   somewhere" without spelling out the URL. */
.award-arrows {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  margin-left: 9px;
  vertical-align: middle;
}
.award-arrows span {
  width: 8px;
  height: 8px;
  border-right: 2px solid #fff;
  border-bottom: 2px solid #fff;
  transform: translateX(0) rotate(-45deg) scale(1);
  opacity: 0.35;
  animation: awardArrowHop 1.8s ease-in-out infinite;
}
.award-arrows span:nth-child(1) { animation-delay: 0s; }
.award-arrows span:nth-child(2) { animation-delay: 0.12s; }
.award-arrows span:nth-child(3) { animation-delay: 0.24s; }
@keyframes awardArrowHop {
  0%, 42%, 100% { transform: translateX(0) rotate(-45deg) scale(1); opacity: 0.35; border-color: #fff; }
  14% { transform: translateX(6px) rotate(-45deg) scale(1.25); opacity: 1; border-color: var(--color-panel-hover); }
  24% { transform: translateX(0) rotate(-45deg) scale(1); opacity: 0.55; border-color: #fff; }
  32% { transform: translateX(3px) rotate(-45deg) scale(1.1); opacity: 0.85; border-color: var(--color-panel-hover); }
}
@media (prefers-reduced-motion: reduce) {
  .award-arrows span { animation: none; opacity: 0.6; }
}

.stack__content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  /* Just packs title above body here — the actual vertical centring
     (desktop only) happens one level up, on .stack__panel; see its own
     comment for why it has to live there. */
  justify-content: flex-start;
  /* Longhand on purpose — this element is also .wrap (for the shared
     left/right gutter); a padding shorthand here would silently wipe
     that horizontal padding out since both rules target the same
     property on the same element. Service/Contact sit under a solid nav
     that pushes content down by --nav-h (body.nav-pad); this page's nav
     floats over the photo instead, so --nav-h is added back in here so
     centring still leaves the same minimum clearance under it. Plain
     in-flow content now (see the .stack__panel comment above) — this is
     what actually determines each panel's rendered height, growing past
     min-height on its own if the title and body need more than one
     screen. On a tall viewport, the plain nav-clearance offset alone
     left a lot of empty space below the content — centred around the
     shared --editorial-avg-content-h guess (its own comment) instead, so
     the title+body block as a whole sits closer to mid-page the taller
     the window gets, while staying floored at the nav-clearance minimum
     so it never creeps up under the floating nav on a short one. Same
     formula on .page-hero--photo (Service) below, which is what keeps
     this page's title on the very same line as that one instead of
     drifting per page the way independently centring each panel did. */
  padding-top: max(calc(var(--editorial-top-pad) + var(--nav-h)), calc((100vh - var(--editorial-avg-content-h)) / 2));
  padding-bottom: clamp(50px, 8vh, 90px);
}
.stack__title {
  font-family: var(--font-display);
  text-transform: uppercase;
  /* Not 1:1 with the glyphs' cap-height (would be tighter/more "editorial")
     but tight enough to touch on titles that wrap to 2+ lines (e.g.
     "Datenschutzerklärung") — 0.92 keeps the tight look on one-line
     titles while giving wrapped ones clearance. */
  line-height: 0.92;
  letter-spacing: -0.01em;
  font-size: var(--editorial-title-size);
  color: #fff;
  margin: 0 0 clamp(20px, 3vh, 36px);
  overflow-wrap: anywhere;
}
.stack__panel--light .stack__title { color: var(--color-text); }
.stack__body { color: rgba(250,248,245,0.92); }
.stack__panel--light .stack__body { color: var(--color-text); }
.stack__body p { max-width: 60ch; }
.stack__body .eyebrow { color: rgba(255,255,255,0.85); }
.stack__panel--light .stack__body .eyebrow { color: var(--color-accent); }

@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  /* About/Awards on mobile: two glass cards over their own (optional)
     full-bleed background photo, read by scrolling normally, instead of
     two full-viewport photo panels with a snap assist between them -- the
     immersive desktop treatment didn't hold up ported down to a phone
     screen (reported: felt broken/disorienting). Used to drop the photo
     entirely in favour of a plain solid card; now reinstated as a
     frosted-glass card *over* the photo instead (still asked to keep the
     rounded-card shape, just glass rather than solid), matching the
     other mobile pages here, which all optionally show a photo behind
     their own content the same way. */
  .stack {
    height: auto;
    overflow-y: visible;
    scroll-snap-type: none;
  }
  /* One continuous photo behind both cards (not one per panel, which read
     as two disconnected patches with a plain gap between them) -- see
     .stack__group/.stack__page-bg above. .stack__group turns from
     display:contents into a real, position:relative box here, giving the
     single background something to span (exactly the two panels, not the
     footer after them) and something for the glass cards to visually sit
     over as the page scrolls past. */
  /* overflow:hidden isn't clipping anything of its own here -- it's what
     stops the first/last panel's margin below from collapsing straight
     through .stack__group and pushing the whole group (photo included)
     down/up instead of actually opening up space *inside* it, which is
     what actually lets the photo show through in that space. */
  .stack__group { display: block; position: relative; overflow: hidden; }
  /* Same breathing room above the first card as its own left/right
     gutter (.stack__panel's 16px padding below); the last card gets
     noticeably more underneath than that, so the photo has room to read
     as a deliberate margin instead of the card just stopping at the very
     edge of the page. */
  .stack__page-bg + .stack__panel { margin-top: var(--mobile-hero-top-pad); }
  .stack__panel:last-child { margin-bottom: 48px; }
  .stack__page-bg {
    display: block;
    position: absolute;
    inset: 0;
    z-index: 0;
    overflow: hidden;
  }
  .stack__page-bg img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Flush to the very top, cropping the bottom as the page grows --
       same "crop from the bottom, never the top" idea as every other
       page's optional background here. */
    object-position: top;
    display: block;
  }
  /* The dark gradient here exists so *white* text (desktop) reads over a
     photo -- this card's text is dark instead (see .stack__content
     below), which a dark scrim would only fight, so it's switched off
     here rather than left fighting the glass card's own tint. */
  .stack__page-bg .stack__scrim { opacity: 0; }
  /* Each panel's own background is superseded by the single shared one
     above -- hidden rather than removed so the desktop two-photo,
     full-screen-panel treatment (a completely different design, see
     .stack__bg above) is untouched there. */
  .stack__panel > .stack__bg { display: none; }
  .stack__panel {
    min-height: 0;
    background: none;
    /* Was hidden to clip the old per-panel photo to the panel's own
       bounds -- that photo is gone now (.stack__panel > .stack__bg above
       is display:none here), and the card's own gentle scroll-parallax
       transform (main.js) needs to be free to drift a few pixels past
       the panel's edges without getting cut off. */
    overflow: visible;
    display: block;
    padding: 0 16px;
    margin: 0 0 28px;
    scroll-snap-align: none;
    scroll-snap-stop: normal;
  }
  .scroll-cue { display: none; }
  .stack__content {
    margin: 0;
    padding: clamp(32px, 8vw, 44px) clamp(20px, 6vw, 28px) clamp(32px, 8vw, 44px);
    /* Liquid-glass tint, take four: reference is a real "Liquid Glass" UI
       screenshot -- almost no blur at all (the scene behind reads sharp,
       just faintly softened) and, most of what actually sells it as
       glass rather than a plain translucent panel, colour fringing at
       the corners: a cool blue-cyan hint top-left, warm pink/orange
       bottom-left, cooler purple top/bottom-right -- real glass bends
       light slightly differently per edge depending on the angle it's
       viewed/lit from, this is a flat CSS approximation of that (the
       radial-gradient corner glows below, .stack__content::before)
       rather than true refraction. Blur down to 5px (from 10) and the
       tint gradient trimmed further (0.16 -> 0.03, from 0.22/0.04) to
       match how clear the reference's centre reads. saturate() stays
       high since there's now almost no blur left to flatten colour.
       Text colour itself is unaffected by any of this (see the dark/
       white/.contrast-dark rules further down) -- it's not reacting to
       the tint here, it's reacting to whether a photo is even present at
       all and, once it is, how bright it turns out (recalculated for
       this lower tint -- see the threshold passed to applyBgContrast in
       main.js). */
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0.08) 45%, rgba(255, 255, 255, 0.03) 100%);
    backdrop-filter: blur(5px) saturate(190%);
    -webkit-backdrop-filter: blur(5px) saturate(190%);
    border: 1px solid rgba(255, 255, 255, 0.32);
    border-radius: 20px;
    /* Clips the corner glow (::before, just below) to the card's own
       rounded corners so it never bleeds past the border the way a real
       backdrop-filter blur would. */
    overflow: hidden;
    /* The bevel: stacked inset shadows tracing the card's own rounded
       outline (every inset shadow here follows border-radius natively,
       no extra clipping needed) to read as a curved, physically thick
       edge rather than a flat cutout -- light hits the top-left more
       directly (brighter, sharper line) and falls off toward the
       bottom-right (darker, softer), the same asymmetry real glass
       shows under a single light source. The wide, soft inset glow at
       the end fills in the curve *between* those two sharp lines --
       without it the bevel reads as a hard ring floating just inside
       the border instead of a surface that's actually rounded over. */
    box-shadow:
      0 10px 34px rgba(0, 0, 0, 0.16),
      inset 0 0 0 1px rgba(255, 255, 255, 0.22),
      inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
      inset 1.5px 0 0 rgba(255, 255, 255, 0.4),
      inset 0 -1.5px 0 rgba(0, 0, 0, 0.12),
      inset -1.5px 0 0 rgba(0, 0, 0, 0.08),
      inset 0 0 26px rgba(255, 255, 255, 0.14);
  }
  /* The corner colour fringing described above -- screen-blended so it
     only ever brightens/tints (never darkens or flattens whatever photo
     shows through). */
  .stack__content::before {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    mix-blend-mode: screen;
    background:
      radial-gradient(120px 90px at 0% 0%, rgba(110, 190, 255, 0.32), transparent 65%),
      radial-gradient(140px 110px at 0% 100%, rgba(255, 150, 110, 0.22), transparent 60%),
      radial-gradient(160px 130px at 100% 100%, rgba(190, 120, 255, 0.26), transparent 65%);
  }
  /* Same glass-card treatment as .stack__content above, reused verbatim
     for Service/Contact (.page-hero--photo): those pages aren't part of
     the About/Awards scroll-panel system, so they can't just share the
     .stack__content selector itself (its base, non-mobile rules carry
     panel-centring layout logic that doesn't apply here) -- a separate
     class with the identical visual declarations instead, nested one
     level inside the page's own .wrap, mirroring .stack__panel (outer,
     margin from the screen edge) > .stack__content (inner, the card
     itself) exactly. .wrap's own site-wide gutter is overridden to a
     flat 16px right below -- matching .stack__panel's own edge margin
     -- rather than left at its default clamp(20px,5vw,64px), which
     previously left the card's rounded border touching the window edge
     with no margin around it at all once .hero-glass-card had briefly
     been combined directly onto .wrap instead of nested inside it (that
     version replaced .wrap's gutter outright rather than adding this
     fixed 16px outer margin back in). */
  .page-hero--photo > .wrap { padding: 0 16px; }
  .hero-glass-card {
    position: relative;
    padding: clamp(32px, 8vw, 44px) clamp(20px, 6vw, 28px) clamp(32px, 8vw, 44px);
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0.08) 45%, rgba(255, 255, 255, 0.03) 100%);
    backdrop-filter: blur(5px) saturate(190%);
    -webkit-backdrop-filter: blur(5px) saturate(190%);
    border: 1px solid rgba(255, 255, 255, 0.32);
    border-radius: 20px;
    overflow: hidden;
    box-shadow:
      0 10px 34px rgba(0, 0, 0, 0.16),
      inset 0 0 0 1px rgba(255, 255, 255, 0.22),
      inset 0 1.5px 0 rgba(255, 255, 255, 0.85),
      inset 1.5px 0 0 rgba(255, 255, 255, 0.4),
      inset 0 -1.5px 0 rgba(0, 0, 0, 0.12),
      inset -1.5px 0 0 rgba(0, 0, 0, 0.08),
      inset 0 0 26px rgba(255, 255, 255, 0.14);
  }
  .hero-glass-card::before {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    mix-blend-mode: screen;
    background:
      radial-gradient(120px 90px at 0% 0%, rgba(110, 190, 255, 0.32), transparent 65%),
      radial-gradient(140px 110px at 0% 100%, rgba(255, 150, 110, 0.22), transparent 60%),
      radial-gradient(160px 130px at 100% 100%, rgba(190, 120, 255, 0.26), transparent 65%);
  }
  /* The card now does the photo-legibility job on its own (its own tint
     over the backdrop blur) -- the plain dark scrim underneath would
     only flatten the photo showing through outside the card's rounded
     corners for no benefit, same reasoning as .stack__page-bg .stack__scrim
     above. */
  .page-hero--photo .page-hero__bg .stack__scrim { opacity: 0; }
  /* backdrop-filter isn't universally supported (older Android WebView in
     particular) -- falls back to a solid, opaque card instead of a
     razor-sharp (unblurred) photo showing straight through a mostly
     see-through tint. Text colour rules further down don't need their
     own fallback copy here: they were already rewritten to react to
     whether a photo is present/bright rather than to the tint itself, so
     they stay correct with or without blur support. */
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .stack__content { background: var(--color-bg-alt); box-shadow: none; }
    .hero-glass-card { background: var(--color-bg-alt); box-shadow: none; }
  }
  /* Looser line-height than the desktop 0.82 — long titles wrap to 2-3
     lines at this width and need clearance so ascenders/descenders
     don't collide between lines.
     Default stays dark text -- safe against the plain, near-white .ph
     placeholder shown before any photo has been uploaded (confirmed by
     testing: white-by-default here left both cards nearly illegible on
     a blank page). Only switches to white once .stack__group--bg-active
     is confirmed, toggled by main.js off the same photoresolved event
     Impressum/Datenschutz use for their own .page-hero--bg-active. Both
     panels share one photo (.stack__page-bg above), so the brightness
     sample driving the white/dark switch (applyBgContrast in main.js)
     toggles .contrast-dark on .stack__group too, not per panel.
     Every rule below is routed through ".stack .stack__group .stack__panel"
     (real ancestors, not an arbitrary specificity bump) so it reliably
     outranks .stack__panel.contrast-dark .stack__title etc. further down
     the file -- those exist for the desktop-only two-photo design and,
     because the hidden mobile .stack__bg photos underneath are still
     probed/sampled even though invisible here, can end up with their own
     independent .contrast-dark on .stack__panel that has nothing to do
     with the one *visible* mobile photo. Without the extra specificity
     that unrelated, equally-specific-but-later rule would silently win
     and react to the wrong photo. */
  .stack .stack__group .stack__panel .stack__title { font-size: clamp(2.4rem, 13vw, 5.5rem); line-height: 0.98; color: var(--color-text); }
  .stack .stack__group .stack__panel .stack__body { color: var(--color-text); }
  .stack .stack__group .stack__panel .stack__body .eyebrow { color: var(--color-accent); }
  .stack .stack__group .stack__panel .stack__body .timeline__row time { color: var(--color-text-muted); }
  .stack .stack__group .stack__panel .stack__body .timeline__row { border-top-color: var(--color-line); }
  .stack .stack__group--bg-active .stack__panel .stack__title,
  .stack .stack__group--bg-active .stack__panel .stack__body { color: #fff; }
  .stack .stack__group--bg-active .stack__panel .stack__body .timeline__row time { color: rgba(255, 255, 255, 0.7); }
  .stack .stack__group--bg-active .stack__panel .stack__body .timeline__row { border-top-color: rgba(255, 255, 255, 0.3); }
  .stack .stack__group--bg-active.contrast-dark .stack__panel .stack__title,
  .stack .stack__group--bg-active.contrast-dark .stack__panel .stack__body { color: var(--color-text); }
  .stack .stack__group--bg-active.contrast-dark .stack__panel .stack__body .timeline__row time { color: var(--color-text-muted); }
  .stack .stack__group--bg-active.contrast-dark .stack__panel .stack__body .timeline__row { border-top-color: var(--color-line); }
}

/* Plain-section counterpart of .stack__content/.stack__title, used by
   normal scrolling pages (Service, Contact) that share the same title
   size and top offset as the full-viewport About/Awards panels, without
   being full-viewport themselves. */
.section--gray { background: var(--color-bg-gray); }
.page-hero {
  padding: calc(var(--editorial-top-pad) + var(--nav-h)) 0 clamp(50px, 8vh, 90px);
  /* Nav floats (transparent, position:fixed) over this section instead of
     body reserving space above it — same as every other page-hero page —
     so the clearance has to come from this section's own top padding
     instead. Fills the window on its own so short content still reads as
     "one screen, then scroll once for the footer" instead of leaving a
     stub of empty space. Plain block layout (not flex) so the title still
     starts at the top, matching the About/Awards grid — any extra space
     lands below the content, not by centring it. */
  box-sizing: border-box;
  min-height: 100vh;
  min-height: 100dvh;
}
.page-hero__title {
  font-family: var(--font-display);
  text-transform: uppercase;
  line-height: 0.92;
  letter-spacing: -0.01em;
  font-size: var(--editorial-title-size);
  margin: 0 0 clamp(20px, 3vh, 36px);
  overflow-wrap: anywhere;
}
/* One long compound word (e.g. "Datenschutzerklärung") has no spaces to
   break at, so at the shared editorial size it wraps mid-word wherever it
   happens to fit — this smaller clamp keeps it to one line instead. */
.page-hero__title--compact { font-size: clamp(2.6rem, 7.2vw, 6rem); }
/* Service only -- its lede sits noticeably further from the title than on
   the other page-hero pages, this pulls it in closer underneath "Service"
   without touching the shared spacing everywhere else. */
.page-hero__title--tight { margin-bottom: clamp(-2px, 0.3vh, 6px); }
/* Impressum/Datenschutz: optional background photo, same idea as every
   other page here, layered onto the plain (non --photo) .page-hero
   instead of switching them onto the desktop-oriented --photo variant
   wholesale -- that would also drag along its always-centred layout and
   more, none of which is wanted here. Works the same at every viewport
   width (desktop included) -- unlike most of this file's other photo
   features, nothing here is mobile-only. Spans the *entire* section
   (title+lede AND all the legal text below) -- matches the same "one
   continuous photo, content scrolls over it" treatment About/Awards
   uses. A photo that's much shorter than these text-heavy pages will
   still get cropped hard by object-fit:cover the further the legal text
   stretches the page -- worth using a tall, fairly plain/low-detail
   source photo here rather than one with a lot going on that a deep,
   narrow crop would mangle. No photo present (the common case,
   data-photo-optional hides both image and placeholder on failure) --
   this whole block is invisible and the page reads as plain white,
   exactly as before. */
.page-hero:not(.page-hero--photo) {
  position: relative;
}
.page-hero:not(.page-hero--photo) .page-hero__bg img { object-position: top; }
/* Every .wrap here (title+lede *and* the legal text after it), not just
   the first -- a sibling combinator so it isn't tied to exactly one
   .wrap following .page-hero__bg, the same mistake already made (and
   fixed) once on this page's own :first-of-type rule further up. */
.page-hero:not(.page-hero--photo) .page-hero__bg ~ .wrap { position: relative; z-index: 1; }
/* Scrim/white-text only switch on once a photo actually resolves --
   .page-hero--bg-active is toggled by JS alongside nav--solid-mobile
   (see main.js), reusing the same photoresolved event. Until then the
   scrim stays off so no dark gradient shows over a plain white page. */
.page-hero:not(.page-hero--photo) .page-hero__bg .stack__scrim { opacity: 0; }
.page-hero--bg-active .page-hero__bg .stack__scrim { opacity: 1; }
/* White text baseline across *both* wraps -- title/lede and the legal
   text below it too (headings, paragraphs inherit; the list needs its
   own rule same as everywhere else .contact-list appears over a photo,
   see .page-hero--photo further down). .contrast-dark (real per-photo
   brightness sampling, same mechanism as Service/About/Contact -- see
   applyBgContrast in main.js, now also watching this page's photo)
   below reverts all of it back to normal dark text for a photo that
   turns out bright rather than the dark one this whole block otherwise
   assumes. */
.page-hero--bg-active { color: #fff; }
.page-hero--bg-active .lede { color: rgba(250,248,245,0.85); }
.page-hero--bg-active .contact-list li { border-top-color: rgba(255,255,255,0.25); color: #fff; }
.page-hero--bg-active .contact-list li span { color: rgba(250,248,245,0.75); }
.page-hero--bg-active .contact-list li > span:nth-child(1),
.page-hero--bg-active .contact-list li > span:nth-child(2) { color: rgba(250,248,245,0.9); }
.page-hero--bg-active.contrast-dark { color: var(--color-text); }
.page-hero--bg-active.contrast-dark .lede { color: var(--color-text-muted); }
.page-hero--bg-active.contrast-dark .contact-list li { border-top-color: var(--color-line); color: var(--color-text); }
.page-hero--bg-active.contrast-dark .contact-list li span { color: var(--color-text-muted); }
.page-hero--bg-active.contrast-dark .contact-list li > span:nth-child(1),
.page-hero--bg-active.contrast-dark .contact-list li > span:nth-child(2) { color: #58575c; }
.page-hero--bg-active.contrast-dark .page-hero__bg .stack__scrim { opacity: 0; }
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  .page-hero { padding-top: var(--mobile-hero-top-pad); }
  .page-hero__title { font-size: clamp(2.4rem, 13vw, 5.5rem); line-height: 0.98; }
  .page-hero__title--compact { font-size: clamp(2.1rem, 9.5vw, 3.4rem); }
}

/* Photo-background variant of .page-hero (Service): mirrors the About
   page's full-bleed .stack__bg treatment — one photo fills the whole
   viewport including behind the nav, title/lede sit on top. Pairs with a
   floating (non-solid) nav and no body.nav-pad, same as About, so the
   photo actually reaches the very top of the window instead of stopping
   below a reserved nav bar. */
.page-hero--photo {
  position: relative;
  /* min-height (not a fixed height) so the whole page is exactly this one
     panel followed by the footer — no separate scrollable section below
     it. Content (title/lede/services) determines the actual height; on
     any normal viewport that's one screen, so scrolling once reveals the
     footer directly, same as the rest of the site. */
  min-height: 100vh;
  min-height: 100dvh;
  /* Top-packed at the same offset as .stack__content (About/Awards) above
     it in the file, formula and all — see its own comment for both why
     plain centring drifted them off the same line, and why this one
     instead grows on a tall viewport toward roughly centring the
     title+body block using a shared average across all three, rather
     than each page's own (differing) content height. Mobile overrides
     back to flex-start below (was already the same value, unchanged in
     effect). */
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  padding-top: max(calc(var(--editorial-top-pad) + var(--nav-h)), calc((100vh - var(--editorial-avg-content-h)) / 2));
  padding-bottom: clamp(30px, 5vh, 60px);
  color: #fff;
}
.page-hero__bg {
  position: absolute;
  inset: 0;
  z-index: 0;
}
/* Bottom-corner, dimmed placeholder text — this hero has real content
   (title, lede, services) sitting on top of it, so the instructional copy
   must stay out of the way instead of competing for the centre. */
.page-hero__bg .ph,
.stack__bg .ph,
.stack__page-bg .ph {
  border: none;
  min-height: 100%;
  margin: 0;
  align-items: flex-end;
  justify-content: flex-start;
  text-align: left;
  opacity: 0.45;
}
.page-hero__bg img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.page-hero--photo > .wrap { position: relative; z-index: 1; }
.page-hero--photo .lede { color: rgba(250,248,245,0.85); }
@media (max-width: 760px), (max-height: 600px) and (orientation: landscape) {
  /* clamp(210px, 18vh + 120px, 260px) computes the same range as the
     previous calc(clamp(90px, 18vh, 140px) + var(--nav-h)) -- 90+120 and
     140+120 -- but without nesting calc(), clamp() and var() together.
     That specific combination is a known source of real title/nav
     overlap on iOS Safari (reported: Service page title rendering
     right on top of the nav instead of below it); clamp()'s own
     preferred-value slot already accepts a plain calc-sum directly, so
     the wrapping calc() was never actually needed. --nav-h is 120px
     (see :root) -- hardcoded rather than var()'d in for the same
     reason, so nothing here still depends on the fragile combination.
     Now matches --mobile-hero-top-pad (About/Service/Contact/Impressum/
     Datenschutz all sit on the same line as a result, closer under the
     status bar than this used to sit) -- that variable is itself a
     plain clamp() with no var()/calc() nested inside it, so referencing
     it here via a single var() substitution doesn't reintroduce the
     bug; only *defining* a clamp with calc(var()) nested inside it did. */
  .page-hero--photo { justify-content: flex-start; padding-top: var(--mobile-hero-top-pad); }
  /* Flush to the top, cropping the bottom as content grows -- same as
     every other page's optional background photo now, instead of the
     centred crop desktop still uses. */
  .page-hero--photo .page-hero__bg img { object-position: top; }
}

/* Edge-to-edge on mobile: these hero/title blocks use a smaller gutter
   than the rest of the site so the big title gets more of the narrow
   screen instead of losing width to the standard side margins. */
@media (max-width: 760px) {
  .stack__content.wrap,
  .page-hero .wrap,
  .project-hero__overlay {
    padding-left: 16px;
    padding-right: 16px;
  }
}

/* ---------- Editorial list (Services) ---------- */

.editorial-row {
  display: grid;
  grid-template-columns: 0.9fr 1.3fr 0.8fr;
  gap: clamp(20px, 4vw, 50px);
  padding: clamp(30px, 5vh, 50px) 0;
  border-top: 1px solid var(--color-line);
}
.editorial-row:last-child { border-bottom: 1px solid var(--color-line); }
.editorial-row h3 {
  font-family: var(--font-display);
  text-transform: uppercase;
  line-height: 0.92;
  font-size: clamp(1.6rem, 2.6vw, 2.3rem);
  margin: 0;
  transition: transform 0.3s ease, color 0.3s ease;
  transform-origin: left center;
}
/* Hover only reacts to the title/description columns (:has() scoped to
   just h3/p), not the "Verwandte Arbeiten" links column on the right —
   that column has its own link hover state. Same blue as the menu panel's
   link hover, for one consistent "interactive" colour sitewide. */
.editorial-row:has(> h3:hover) h3,
.editorial-row:has(> p:hover) h3 {
  transform: scale(1.06);
  color: var(--color-panel-hover);
}
.editorial-row p { margin: 0; color: var(--color-text-muted); }
.editorial-row__links { display: flex; flex-direction: column; gap: 8px; }
.editorial-row__links span { font-family: var(--font-heading); font-weight: 700; font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-text-muted); }
.editorial-row__links a {
  font-family: var(--font-heading);
  font-size: 0.95rem;
  color: var(--color-accent);
  transition: color 0.2s ease, transform 0.2s ease;
  transform-origin: left center;
  display: inline-block;
}
.editorial-row__links a:hover { color: var(--color-panel-hover); transform: scale(1.06); }
@media (max-width: 860px) {
  .editorial-row { grid-template-columns: 1fr; gap: 20px; }
}

/* Compact variant used inside .page-hero--photo (Service): the same three
   rows, but tightened up and recoloured for white text on a dark photo so
   the whole page — title, lede and services — fits in the one hero panel
   instead of needing a second scrollable section. */
.page-hero--photo .editorial-row {
  padding: clamp(20px, 3.2vh, 36px) 0;
  border-top-color: rgba(255,255,255,0.25);
}
.page-hero--photo .editorial-row:last-child { border-bottom-color: rgba(255,255,255,0.25); }
.page-hero--photo .editorial-row h3 {
  font-size: clamp(1.05rem, 1.7vw, 1.4rem);
  text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
.page-hero--photo .editorial-row p { color: rgba(250,248,245,0.82); text-shadow: 0 1px 2px rgba(0,0,0,0.4); }
.page-hero--photo .editorial-row__links span { color: #fff; }
.page-hero--photo .editorial-row__links a { color: #fff; }
.page-hero--photo .editorial-row__links a:hover { color: var(--color-panel-hover); }

/* ---------- Contact ---------- */

/* width:100% is load-bearing, not decorative, since .page-hero--photo
   became a flex column container (for the desktop vertical-centring
   above): a grid container that also carries max-width+margin:auto (via
   .wrap) defaults to shrinking to its own tracks' fit-content size when
   it's a flex item, instead of stretching to fill the cross axis the
   way a plain block .wrap does -- landed at ~650px instead of matching
   the title's ~1050px, with the auto margins then centring that
   shrunken box. Explicit width:100% forces it to actually fill the
   available space up to max-width, same as everywhere else .wrap is
   used. */
.contact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(30px, 6vw, 70px); width: 100%; }
@media (max-width: 860px) { .contact-grid { grid-template-columns: 1fr; } }

.contact-list { list-style: none; padding: 0; margin: 24px 0 0; }
.contact-list li {
  border-top: 1px solid var(--color-line);
  padding: 14px 0;
  display: flex;
  justify-content: space-between;
  font-family: var(--font-heading);
  font-size: 0.95rem;
}
.contact-list li span { color: var(--color-text-muted); }
/* Label spans (Kategorie, Land, Jahr, and on project pages Material,
   Fertigungstechniken, Besonderheiten, …) get a light touch of extra
   weight and a touch darker grey to read as the heading of their row —
   same family and size as the rest of the list, just a subtle step up.
   Targeted by position (always the first two children — one per
   data-lang) rather than :not(:last-child): Kategorie's value is itself
   split into two data-lang siblings (unlike Land/Jahr, whose value is
   the same in both languages and needs only one), which made the last
   child a label-language-pair away from the actual last element and
   caught the visible value span too. */
.contact-list li > span:nth-child(1),
.contact-list li > span:nth-child(2) {
  font-weight: 500;
  color: #58575c;
}

/* Longer info rows (Material, Fertigungstechniken, Besonderheiten on
   project pages) — a full sentence or bullet list doesn't fit pinned to
   the right on a single line the way Kategorie/Land/Jahr's short values
   do, so these stack label above value instead. */
.contact-list li.row-detail {
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}
.contact-list li.row-detail > span:last-child { color: var(--color-text-muted); max-width: 46ch; }
.contact-list li.row-detail ul {
  list-style: disc;
  padding-left: 1.2em;
  margin: 0;
  max-width: 46ch;
  color: var(--color-text-muted);
}
.contact-list li.row-detail ul li {
  border: none;
  padding: 2px 0;
  display: list-item;
  font-size: inherit;
  font-family: inherit;
}

.field { margin-bottom: 14px; position: relative; }
/* Label itself is hidden (off-screen, not display:none, so it stays in
   the accessibility tree as the input's real label) on every viewport --
   the placeholder text (set from data-placeholder-de/-en, see
   js/main.js applyLang) shows faded inside the field instead. Originally
   desktop-only (closing up the gap the labels left above the send
   button); mobile used to keep the visible label above each field with
   its own placeholder made invisible to avoid duplicating it -- now
   unified to the same in-field-label treatment everywhere instead. */
.field label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.field input, .field textarea {
  width: 100%;
  border: 1px solid var(--color-line);
  background: #fff;
  padding: 16px 18px;
  font-family: var(--font-body);
  font-size: 1.05rem;
}
.field input:focus, .field textarea:focus { outline: none; border-color: var(--color-accent); }
/* Honeypot: a field real visitors never see or reach (off-screen,
   aria-hidden on the wrapper, no tab stop) that spam bots fill in anyway
   because they read the raw HTML rather than render it -- a non-empty
   value on submit is treated server-side (api/contact.js) as a bot and
   silently dropped. */
.field--hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
/* Status text (sending/success/error) toggled by main.js via a class on
   the form itself -- only one of the three is ever shown at once. */
.form__status { display: none; margin: 12px 0 0; font-size: 0.9rem; }
.form--sending .form__status--sending,
.form--success .form__status--success,
.form--error .form__status--error {
  display: block;
}
.form__status--success { color: #7ed492; }
.form__status--error { color: #e58a8a; }
.page-hero--photo.contrast-dark .form__status--success { color: #1c7a34; }
.page-hero--photo.contrast-dark .form__status--error { color: #b3261e; }
/* Browser autofill (name/email remembered from a previous visit) paints
   its own background colour straight over whatever's set here -- a
   yellow tint in Safari/Chrome, blue in some others -- ignoring normal
   background/color entirely; the only thing that actually overrides it
   is an absurdly large inset box-shadow (paints on top of the autofill
   layer) paired with -webkit-text-fill-color (background-color alone
   doesn't touch the text). transition-delay is the standard trick for
   stopping Chrome re-animating its own colour back in a few seconds
   later. Repeated per background variant below (plain/on-photo/
   on-photo-contrast-dark) since each needs its own matching fill. */
.field input:-webkit-autofill,
.field input:-webkit-autofill:hover,
.field input:-webkit-autofill:focus,
.field textarea:-webkit-autofill,
.field textarea:-webkit-autofill:hover,
.field textarea:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--color-text);
  -webkit-box-shadow: 0 0 0 1000px #fff inset;
  box-shadow: 0 0 0 1000px #fff inset;
  transition: background-color 5000s ease-in-out 0s;
}

/* White-on-photo variant, same treatment as .page-hero--photo .editorial-row */
.page-hero--photo .contact-list li { border-top-color: rgba(255,255,255,0.25); color: #fff; }
.page-hero--photo .contact-list li span { color: rgba(250,248,245,0.75); }
.page-hero--photo .field label { color: rgba(250,248,245,0.75); }
.page-hero--photo .field input,
.page-hero--photo .field textarea {
  background: rgba(255,255,255,0.08);
  border-color: rgba(255,255,255,0.35);
  color: #fff;
}
.page-hero--photo .field input::placeholder,
.page-hero--photo .field textarea::placeholder { color: rgba(255,255,255,0.5); }
.page-hero--photo .field input:-webkit-autofill,
.page-hero--photo .field input:-webkit-autofill:hover,
.page-hero--photo .field input:-webkit-autofill:focus,
.page-hero--photo .field textarea:-webkit-autofill,
.page-hero--photo .field textarea:-webkit-autofill:hover,
.page-hero--photo .field textarea:-webkit-autofill:focus {
  -webkit-text-fill-color: #fff;
  -webkit-box-shadow: 0 0 0 1000px rgba(255,255,255,0.08) inset;
  box-shadow: 0 0 0 1000px rgba(255,255,255,0.08) inset;
  transition: background-color 5000s ease-in-out 0s;
}

/* ---------- Auto text contrast (Service/About/Awards/Contact) ----------
   Every one of these sections assumes a dark background photo and sets
   its text white -- fine for the actual project/studio photos this site
   ships with, but not guaranteed for whatever gets uploaded later (a
   bright, high-key shot would wash the white text out). .contrast-dark
   is added/removed by applyBgContrast() in js/main.js once each hero
   photo has actually loaded, by sampling its own average brightness --
   switches this whole block over to the site's normal dark text instead
   of touching colours in place, so nothing here needs to duplicate the
   values, just point at the same variables .stack__panel--light above
   already uses for the identical "light background, dark text" case. */
.stack__panel.contrast-dark .stack__title { color: var(--color-text); }
.stack__panel.contrast-dark .stack__body { color: var(--color-text); }
.stack__panel.contrast-dark .stack__body .eyebrow { color: var(--color-accent); }
.stack__panel.contrast-dark .stack__body .timeline__row time { color: var(--color-text-muted); }
.stack__panel.contrast-dark .stack__body .timeline__row { border-top-color: var(--color-line); }
.page-hero--photo.contrast-dark { color: var(--color-text); }
.page-hero--photo.contrast-dark .lede { color: var(--color-text-muted); }
.page-hero--photo.contrast-dark .editorial-row:last-child { border-bottom-color: var(--color-line); }
.page-hero--photo.contrast-dark .editorial-row p { color: var(--color-text-muted); text-shadow: none; }
.page-hero--photo.contrast-dark .editorial-row__links span { color: var(--color-text-muted); }
.page-hero--photo.contrast-dark .editorial-row__links a { color: var(--color-text); }
.page-hero--photo.contrast-dark .contact-list li { border-top-color: var(--color-line); color: var(--color-text); }
.page-hero--photo.contrast-dark .contact-list li span { color: var(--color-text-muted); }
.page-hero--photo.contrast-dark .field label { color: var(--color-text-muted); }
.page-hero--photo.contrast-dark .field input,
.page-hero--photo.contrast-dark .field textarea {
  background: rgba(23,22,26,0.05);
  border-color: var(--color-line);
  color: var(--color-text);
}
.page-hero--photo.contrast-dark .field input::placeholder,
.page-hero--photo.contrast-dark .field textarea::placeholder { color: var(--color-text-muted); }
.page-hero--photo.contrast-dark .field input:-webkit-autofill,
.page-hero--photo.contrast-dark .field input:-webkit-autofill:hover,
.page-hero--photo.contrast-dark .field input:-webkit-autofill:focus,
.page-hero--photo.contrast-dark .field textarea:-webkit-autofill,
.page-hero--photo.contrast-dark .field textarea:-webkit-autofill:hover,
.page-hero--photo.contrast-dark .field textarea:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--color-text);
  -webkit-box-shadow: 0 0 0 1000px rgba(23,22,26,0.05) inset;
  box-shadow: 0 0 0 1000px rgba(23,22,26,0.05) inset;
  transition: background-color 5000s ease-in-out 0s;
}
/* .stack__scrim (the dark gradient every photo panel already has, for the
   white-text case) works against dark text instead of for it -- fading
   it out here rather than deleting it outright keeps the photo's own
   vignette/depth, just without the added darkening layer on top. */
.stack__panel.contrast-dark .stack__scrim,
.page-hero--photo.contrast-dark .page-hero__bg .stack__scrim { opacity: 0; }

/* ---------- Footer ---------- */

/* No scroll-snap here on purpose. It was tried (scroll-snap-type:
   proximity + scroll-snap-align:end on <footer>) to give every ordinary
   page the same "auto-completes to reveal exactly the footer" feel as
   the fixed About/Awards page — but Chromium's proximity threshold turned
   out to be roughly a full viewport height, not "only when already very
   close": it snapped early and jumped the last stretch in one motion,
   reproducing the exact same premature-jump bug on every page instead of
   fixing anything. Plain, unsnapped scrolling already does exactly what
   was wanted here for free — it moves proportionally with the input and
   naturally stops exactly at the true end of the page, footer fully
   revealed, no more — which is the same natural behaviour the Awards fix
   now matches once its own snap disarms. */

/* .footer--tinted (see the sampling IIFE in main.js) sets background-color
   directly (an actual sampled colour, not a variable/class -- endless
   possible photos, not a fixed palette) via inline style, so it's the one
   thing intentionally left off this transition-eligible list below. */
footer { border-top: 1px solid var(--color-line); padding: 30px 0; transition: border-color 0.4s ease; }
.footer__inner {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  font-size: 0.85rem;
  color: var(--color-text-muted);
  transition: color 0.4s ease;
}
.footer__social { display: flex; gap: 18px; }
.footer__social a { transition: color 0.2s ease; }
.footer__social a:hover { color: var(--color-panel-hover); }
/* Whatever colour the last bit of this page's own background photo
   happens to end on (sampled from its very bottom edge), continued
   straight through as the footer's own background instead of the page
   cutting hard back to plain white right where the photo stops --
   .footer--tinted-dark on top of that switches the footer's own text to
   match, the same white-on-dark/dark-on-light split every other photo
   section on the site already uses. */
footer.footer--tinted { border-top-color: transparent; }
footer.footer--tinted-dark .footer__inner { color: rgba(250,248,245,0.75); }
footer.footer--tinted-dark .footer__social a:hover { color: #fff; }

/* ---------- Title hover glow (About/Awards/Service/Contact titles) ----------
   Replaces an earlier per-letter bounce+colour-flash (asked for something
   dezenter -- "completely different", not a variation on the same idea):
   no movement and no colour change of the glyphs themselves now, just a
   soft blue glow fading in behind the whole title as one calm block. Pure
   CSS -- the old version's per-character DOM splitting (one span per
   letter, purely to animate them individually) is gone with it, so this
   also stops fragmenting each title's text for screen readers. */
.hover-letters {
  transition: text-shadow 0.45s ease;
  text-shadow: 0 0 0 rgba(159, 216, 255, 0);
}
.hover-letters:hover {
  text-shadow: 0 0 22px rgba(159, 216, 255, 0.55), 0 0 5px rgba(159, 216, 255, 0.35);
}
@media (prefers-reduced-motion: reduce) {
  .hover-letters { transition: none; }
}

/* ---------- Lightbox (project photo zoom) ---------- */

body.lightbox-open { overflow: hidden; }

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 400;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.35s ease;
}
.lightbox.is-open { opacity: 1; pointer-events: auto; }

.lightbox__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(10, 9, 11, 0.6);
}

.lightbox__close {
  position: absolute;
  top: 24px;
  right: 24px;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  color: #fff;
  cursor: pointer;
  padding: 0;
  z-index: 2;
}
.lightbox__close::before,
.lightbox__close::after {
  content: '';
  position: absolute;
  width: 22px;
  height: 1px;
  background: currentColor;
}
.lightbox__close::before { transform: rotate(45deg); }
.lightbox__close::after { transform: rotate(-45deg); }

.lightbox__stage {
  position: relative;
  max-width: 92vw;
  max-height: 90vh;
  overflow: hidden;
  cursor: zoom-in;
  transform: scale(0.86);
  opacity: 0;
  transition: transform 0.45s cubic-bezier(.16,.84,.44,1), opacity 0.4s ease;
  display: flex;
}
.lightbox.is-open .lightbox__stage { transform: scale(1); opacity: 1; }
.lightbox__stage.is-zoomed { cursor: zoom-out; }

.lightbox__media {
  display: flex;
  transition: transform 0.25s ease;
  transform-origin: 50% 50%;
}
.lightbox__stage.is-zoomed .lightbox__media { transform: scale(2.4); }

/* No fixed box, no object-fit crop — each photo shows at its own
   natural aspect ratio, only ever scaled down (never up) to fit the
   viewport, unlike the tile grid's deliberately cropped thumbnails. */
.lightbox__media .ph,
.lightbox__media img {
  display: block;
  width: auto;
  height: auto;
  max-width: 92vw;
  max-height: 90vh;
  min-height: 0;
}

/* ---------- PDF page-flip viewer (Editorial Design projects only) ----------
   Injected by js/main.js as the last full-viewport section on a project
   page, only when a .pdf file is found in that project's own folder. Built
   on the StPageFlip library (js/vendor/pageflip) for the realistic paper
   page-turn/shadow rendering — .pdfv__book is StPageFlip's own mount
   point, sized from the PDF's actual page pixel dimensions so the mockup's
   proportions always match the uploaded format instead of a fixed ratio.
   Clicking the page itself zooms (loupe, same mechanic as the lightbox);
   clicking the empty margin left/right of the book turns the page instead
   — StPageFlip's own click/drag handling is turned off (useMouseEvents:
   false) specifically so those two click areas never overlap. */
.pdfv-section {
  position: relative;
  background: var(--color-bg);
  overflow: hidden;
  /* Content-hugging, not a forced 100dvh slide — the book used to sit
     centred in a full-viewport box regardless of its own size, which read
     as "far from the hero photo above" and "far from the page counter
     below" (pinned to the section's bottom edge, not the book's). Sizing
     to content and keeping the counter in normal flow right after the
     book fixes both at once, on any breakpoint. Equal top/bottom padding
     on purpose — see the ".pdfv-section + section" override below, which
     is what actually makes the space above and below the book match; on
     its own this side alone wouldn't be enough, since the plain <section>
     that follows normally brings its own much larger top padding too. */
  padding: 32px 0;
}
/* The next section's own top padding (section{padding:clamp(50px,9vh,
   100px) 0}) would otherwise stack on top of .pdfv-section's own bottom
   padding above, making the gap below the book roughly 3-4x the gap
   above it. Zeroed here, specifically only right after the viewer, so
   .pdfv-section's padding-bottom is the entire visible gap on both
   sides — everywhere else this rule doesn't touch, sections keep their
   normal spacing. */
.pdfv-section + section { padding-top: 0; }
/* .about-grid (the "About the project" layout that normally follows)
   carries its own margin-top, which — with the next section's own
   padding-top now zeroed above — collapses straight through it and
   silently adds back part of the gap this whole rule exists to remove.
   Zeroed here too, scoped to just this position. */
.pdfv-section + section .about-grid { margin-top: 0; }
.pdfv {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.pdfv__heading {
  margin: 0 0 18px;
  font-size: clamp(1rem, 1.6vw, 1.3rem);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--color-text-muted);
  text-align: center;
}
/* Hugs the book's own size (main.js sizes .pdfv__book-wrap directly,
   against the viewport — see sizeBook()'s comment — not against this
   element), so there's never dead space between the book and the
   counter below it, whatever the book's computed aspect ends up being.
   Only while still loading does it need an explicit size of its own —
   .pdfv__book-wrap has no size yet at that point, so without one the
   loading spinner below would collapse to nothing and never appear. */
.pdfv__stage {
  position: relative;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.pdfv__stage.is-loading { min-height: min(46vh, 420px); }
.pdfv__zone {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50%;
  z-index: 3;
  cursor: pointer;
}
.pdfv__zone--prev { left: 0; }
.pdfv__zone--next { right: 0; }
.pdfv__zone[data-disabled="true"] { cursor: default; }

.pdfv__loader {
  position: absolute;
  inset: 0;
  /* Above every other layer in the stage (.pdfv__zone is 3, .pdfv__arrow
     is 4, .pdfv__book-wrap is 5) -- at its old z-index:2 this sat
     *underneath* the book, so giving it a background alone still
     wouldn't have hidden anything, only ever drawing the spinning ring
     on top of a cover that was already fully visible (and, sitting
     under z-index 3, already had its click zones rendering above it
     too) the instant the browser had painted it, regardless of whether
     our own JS considered the viewer actually ready yet. That's what
     let the cover show through, looking interactive, well before it
     (or the rest of the pages behind it) had actually finished loading. */
  z-index: 6;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  /* Matches .pdfv-section's own background so it reads as part of the
     page while up, not a separate box. */
  background: var(--color-bg);
}
.pdfv__loader::after {
  content: '';
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 3px solid rgba(0, 0, 0, 0.1);
  border-top-color: var(--color-accent);
  animation: pdfv-spin 0.85s linear infinite;
}
@keyframes pdfv-spin { to { transform: rotate(360deg); } }

/* .pdfv__book (StPageFlip's own mount point) always stays sized for a
   full two-page spread — that's what StPageFlip's internal page-pairing
   math needs — but a single page (cover/back cover) only actually draws
   into one half of it. .pdfv__book-wrap is the visible window onto that:
   overflow:hidden, and only as wide as what should actually show (half
   width for a single page, full width for a spread) — main.js shrinks it
   and shifts .pdfv__book left by the same amount so the occupied half
   lands inside that window, and the empty other half is simply never
   rendered rather than sitting there off to one side. Being a flex child
   with justify-content:center on the parent, shrinking its own width is
   what keeps every state — cover, spread, back cover — centered on
   screen with no extra positioning math needed. */
.pdfv__book-wrap {
  position: relative;
  z-index: 5;
  overflow: hidden;
  transition: width 0.5s ease;
}
.pdfv__book-wrap::after {
  content: '';
  position: absolute;
  left: 6%;
  right: 6%;
  bottom: -34px;
  height: 34px;
  background: radial-gradient(ellipse at 50% 0%, rgba(0,0,0,0.35) 0%, rgba(0,0,0,0) 72%);
  pointer-events: none;
}
/* Carries the recto/verso shift + zoom transform (set by main.js) — kept
   separate from .pdfv__book itself, which has its own transform for the
   devicePixelRatio fix below and can't share a single "transform"
   declaration with this one since they're independent concerns animated
   independently. */
.pdfv__book-shift {
  position: relative;
  cursor: zoom-in;
  filter: drop-shadow(0 26px 46px rgba(0,0,0,0.35));
  transition: transform 0.5s ease;
  transform-origin: 50% 50%;
}
.pdfv__book-shift.is-zoomed { cursor: zoom-out; }
/* .pdfv__book is the element StPageFlip mounts into — the library adds
   its own "stf__parent" class straight onto it (not a wrapped child).
   It's sized dpr times larger than it should visually occupy (main.js)
   and scaled back down here, purely so its internal canvas — which the
   library sizes 1:1 with this element's own CSS pixels, with no
   devicePixelRatio awareness of its own — actually has enough pixels to
   render sharp on a retina screen instead of blurry. */
.pdfv__book { position: absolute; top: 0; left: 0; }

/* --- Vendored from StPageFlip's stPageFlip.css (page-flip npm package). --- */
.pdfv__book.stf__parent { position: relative; display: block; box-sizing: border-box; touch-action: pan-y; }
.pdfv__book .stf__wrapper { position: relative; width: 100%; height: 100%; box-sizing: border-box; }
.pdfv__book.stf__parent canvas { position: absolute; width: 100%; height: 100%; left: 0; top: 0; }
.pdfv__book .stf__block { position: absolute; width: 100%; height: 100%; box-sizing: border-box; perspective: 2200px; }
.pdfv__book .stf__item { display: none; position: absolute; transform-style: preserve-3d; }
.pdfv__book .stf__outerShadow,
.pdfv__book .stf__innerShadow,
.pdfv__book .stf__hardShadow,
.pdfv__book .stf__hardInnerShadow { position: absolute; left: 0; top: 0; }

.pdfv__arrow {
  position: absolute;
  top: 50%;
  z-index: 4;
  width: 34px;
  height: 34px;
  transform: translateY(-50%);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}
.pdfv__arrow::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 16px;
  height: 16px;
  border-right: 3px solid var(--color-accent);
  border-bottom: 3px solid var(--color-accent);
}
.pdfv__arrow--prev { left: clamp(16px, 4vw, 56px); }
.pdfv__arrow--prev::before { transform: translate(-30%, -50%) rotate(135deg); }
.pdfv__arrow--next { right: clamp(16px, 4vw, 56px); }
.pdfv__arrow--next::before { transform: translate(-70%, -50%) rotate(-45deg); }
.pdfv__zone--prev:hover .pdfv__arrow--prev,
.pdfv__zone--next:hover .pdfv__arrow--next { opacity: 0.85; }

.pdfv__count {
  margin-top: 14px;
  color: var(--color-text-muted);
  font-family: var(--font-heading);
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  pointer-events: none;
}
@media (max-width: 760px) {
  .pdfv__arrow { width: 26px; height: 26px; }
}

/* ---------- i18n ---------- */
/* Content elements are duplicated with [data-lang="de"] / [data-lang="en"];
   the inactive language is hidden via attribute on <html>. */

html[data-lang="de"] [data-lang="en"] { display: none !important; }
html[data-lang="en"] [data-lang="de"] { display: none !important; }
