  /* webfonts (fonts/). Oracle is the LICENSED ABC Oracle Medium
     (2026-09-29), served exactly as Dinamo delivered it — no
     subsetting/conversion, the license forbids modifying the file. The
     site uses only 500 upright; italics are synthesized. Preloaded in
     the <head> of all three pages: keep that href in sync. Mercure is
     still the trial (unused on the sans branch); Libertinus Mono is
     OFL, built by tools/build-webfonts.sh. */
  @font-face {
    font-family: 'Oracle';
    src: url('../fonts/ABCOracle-Medium.woff2') format('woff2');
    font-weight: 500;
    font-style: normal;
    font-display: swap;
  }
  @font-face {
    font-family: 'Mercure';
    src: url('../fonts/MercureTRIAL-Regular.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
  }
  @font-face {
    font-family: 'Mercure';
    src: url('../fonts/MercureTRIAL-Italic.woff2') format('woff2');
    font-weight: 400;
    font-style: italic;
    font-display: swap;
  }
  /* case study index numbers ([001]) — free SIL license, not a trial;
     subset to printable ASCII (it only ever sets the index numbers) */
  @font-face {
    font-family: 'Libertinus Mono';
    src: url('../fonts/LibertinusMono-Regular.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
  }

  :root{
    /* unified 12px gutter/sticky-offset everywhere, expressed in rem.
       html keeps its default font-size (not overridden below) so
       rem always resolves predictably regardless of body's own
       font-size — see toPx() in the script for how JS reads this back. */
    --margin: 0.75rem;
    --col-gap: 3vw;
    --nav-gap: 12px;
    --hold-distance: 180px;
    --fade-distance: 540px;
    --max-blur: 10px;
    --bg: #fafafa;
    --ink: #111111;
  }

  @media (max-width: 1100px) {
    :root{
      --col-gap: 2.5vw;
      --hold-distance: 150px;
      --fade-distance: 460px;
      --max-blur: 9px;
    }
  }

  @media (max-width: 700px) {
    :root{
      --col-gap: 4vw;
      --hold-distance: 120px;
      --fade-distance: 380px;
      --max-blur: 7px;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    :root{ --max-blur: 0px; }
  }

  * { box-sizing: border-box; }

  /* text selection: the placeholder gray as highlight, text color
     unchanged (no color property here on purpose — setting it would
     override each element's own) */
  ::selection { background: rgb(240, 240, 242); }
  ::-moz-selection { background: rgb(240, 240, 242); }
  /* the grey is what the timeline cards show (rgba(235,235,238,.7)
     over --bg), per Maciek 2026-09-29. Text drawn white and blended
     with difference (the sans test: former-serif content, both blend
     twins) inverts its highlight along with it — so it gets the mirror
     color, --bg minus that grey, which the blend turns back into the
     same grey; the selected text itself still inverts to near-black. */
  .nav-reveal::selection, .nav-reveal ::selection,
  .about-bio::selection, .about-bio ::selection,
  .chrome-blend ::selection, .about-credits-blend ::selection,
  .cs-statement::selection, .cs-statement ::selection,
  .cs-cap-text::selection, .cs-cap-text ::selection,
  .cs-credit-names::selection, .cs-credit-names ::selection {
    background: rgb(10, 10, 8);
  }
  /* …except the one former-serif spot that renders flat, unblended
     (the tablet .about-compact, see the blend twin notes) */
  .chrome:not(.chrome-blend) .about-compact .nav-reveal::selection,
  .chrome:not(.chrome-blend) .about-compact .nav-reveal ::selection {
    background: rgb(240, 240, 242);
  }

  html {
    /* deliberately NOT setting font-size here — it stays at the
       browser default (usually 16px) so --margin's rem value has a
       stable, predictable basis independent of body's 12px text */
  }

  body {
    margin: 0;
    background: var(--bg);
    color: #8d8d8d;
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-size: 12px;
    line-height: 16px;
    font-weight: 500; /* all sans type applications are medium only */
    letter-spacing: 0.02em; /* 2% */
  }

  .chrome {
    position: fixed;
    inset: 0;
    z-index: 20;
    pointer-events: none;
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    column-gap: var(--nav-gap);
    padding: 0 var(--margin);
    align-items: center;
  }

  /* the two letter-spacing scales used across the whole nav:
     10% for anything that behaves like a link/wordmark, 2% (the
     page default) for everything else */
  .tracked-wide { letter-spacing: 0.1em; }
  .link-caps {
    text-transform: uppercase;
    letter-spacing: 0.1em;
    white-space: nowrap;
  }

  .nav-col-1,
  .nav-col-2,
  .nav-col-3,
  .nav-col-8,
  .nav-col-9 {
    grid-row: 1;
    position: relative; /* anchors the absolutely-positioned reveal-wrap below */
  }
  .nav-col-1 { grid-column: 1; }
  .nav-col-2 { grid-column: 2; }
  .nav-col-3 { grid-column: 3; }
  .nav-col-8 { grid-column: 8; }
  .nav-col-9 { grid-column: 9; }
  .nav-address { grid-column: 11; white-space: nowrap; }
  .nav-links {
    grid-column: 12;
    justify-self: end;
    text-align: right;
    white-space: nowrap;
  }

  /* the contact links show on the About page only, placed BEFORE the
     address (per Maciek 2026-09-29; an animated version was tried and
     dropped). The address always sits at the right margin. The links
     stay in the layout while hidden — their text sizes the grid's last
     track, and both chrome copies must keep identical geometry — so
     hiding is opacity/visibility, never display. Both offsets come from
     sizeAddressShift() in main.js: --addr-shift moves the address's
     right edge onto the margin, --links-shift moves the links to end
     one original gap before it. Only the ORIGINAL chrome shows them:
     the blend twin's copy is white + difference (it came out black). */
  .nav-address { transform: translateX(var(--addr-shift, 0px)); }
  .nav-links {
    transform: translateX(var(--links-shift, 0px));
    opacity: 0;
    visibility: hidden;
  }
  body.about-open .chrome:not(.chrome-blend) .nav-links {
    opacity: 1;
    visibility: visible;
  }

  /* Services / Selected clients only exist for the about page, and
     only on desktop — same reasoning as Discipline: no room/no use
     for them once things get narrower */
  .about-only { display: none; }
  body.about-open .about-only { display: block; }
  @media (max-width: 1100px) {
    /* .nav-col-8/9 also carry .about-only, whose body.about-open rule
       above has higher specificity than a plain ".nav-col-8{display:
       none}" would — so the hide-below-1100px rule has to match that
       specificity (body.about-open prefix) to actually win once the
       about overlay is open. Without this, these columns silently
       reappeared any time About was open on tablet/mobile. */
    .nav-col-8, .nav-col-9,
    body.about-open .nav-col-8,
    body.about-open .nav-col-9 { display: none; }
  }

  #aboutTrigger { pointer-events: auto; cursor: pointer; }

  /* the always-visible first line of each column — this alone
     determines the nav row's height, permanently */
  .nav-line { white-space: nowrap; }

  /* col2's "Discipline" label lives in row 1 like col1/col3's real
     lines do (so it never distorts row height), but it's only ever
     shown on hover — this is a fade-in-place variant, not something
     that lives inside reveal-wrap (that was the bug: stacking the
     label above the value inside reveal-wrap pushed the value down
     an extra line versus col1/col3's siblings) */
  .nav-line.hover-only {
    opacity: 0;
    transition: opacity 300ms ease;
  }
  body.nav-hover-active .nav-line.hover-only { opacity: 1; }

  /* col 2 is entirely hover-dependent (both its label and value only
     exist on hover), so it's hidden outright below desktop rather
     than left as dead grid space */
  @media (max-width: 1100px) {
    .nav-col-2 { display: none; }
  }

  /* reveal-on-hover content sits in its own absolutely-positioned
     wrapper, completely out of normal flow — so however long the
     project name/discipline/description text is, it can NEVER grow
     the nav row's height or shift "About" (or anything else) in it.
     It just sits underneath, exactly where it's placed. */
  .reveal-wrap {
    position: absolute;
    top: 100%;
    left: 0;
    opacity: 0;
    transition: opacity 300ms ease;
  }
  body.nav-hover-active .reveal-wrap { opacity: 1; }

  /* tablet/mobile has no hover, so col1's second-line slot instead
     permanently shows the tagline there — same position as the
     hover-driven project name, just not hover-dependent, and only
     rendered below desktop (the two never show at the same tier).
     Given a real width (matching the column) so it wraps onto
     multiple lines instead of being cut off. */
  .tablet-tagline {
    display: none;
    opacity: 1;
    /* NOT width:100% of the narrow 1/12 grid column — that was
       forcing wraps even when there was plenty of room. This uses
       the actual available viewport width instead, so it only wraps
       when the text truly doesn't fit (mainly on narrow phones) —
       wide on tablet, naturally narrower on an actual phone screen. */
    width: calc(100vw - 2 * var(--margin));
  }
  @media (max-width: 1100px) {
    .tablet-tagline { display: block; }
  }
  /* the case study page drops the studio tagline below 1100px (per
     Maciek, 2026-07-16): the studio description reads as noise on a
     project page — that space belongs to the project's own info, which
     now starts below the nav (see .cs-projinfo / sizeProjInfo()) */
  body.case-study .tablet-tagline { display: none; }

  /* on mobile the address/links move out of the fixed nav entirely
     and into a footer at the bottom of the page (see .mobile-footer) */
  @media (max-width: 700px) {
    .nav-address,
    .nav-links {
      display: none;
    }
  }

  .nav-reveal {
    /* TEST (branch test/sans-global): serif dropped site-wide —
       Mercure licensing is uncertain. All former-serif content renders
       in the sans instead, white + difference blend: reads near-black
       over --bg (#fafafa), inverts over passing media. The Mercure
       @font-face rules stay wired above for an easy revert. */
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
    color: #fff;
    mix-blend-mode: difference;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 33vw; /* generous cap so a long slogan can't bleed into the next column */
  }

  /* The grey sans NEVER blends (per Maciek) — only the former-serif
     white does. A fixed bar is an isolated stacking context, so a
     CHILD's mix-blend-mode can never see the page behind it — hence
     each fixed bar exists twice (see the twin markup in all three
     pages): the original shows only the grey (unblended) and hides its
     former-serif copies; the twin carries difference on the LAYER
     itself and shows only the former-serif text in white. Identical
     markup = identical geometry. */
  .chrome-blend,
  .about-credits-blend {
    mix-blend-mode: difference;
    z-index: 21;
    visibility: hidden;
    pointer-events: none;
  }
  .chrome-blend .nav-reveal,
  .chrome-blend .about-bio,
  .about-credits-blend .nav-reveal {
    visibility: visible;
    color: #fff;
    mix-blend-mode: normal; /* the layer blends as one group */
  }
  .about-credits-blend .nav-reveal { pointer-events: auto; }
  .chrome:not(.chrome-blend) .nav-reveal,
  .chrome:not(.chrome-blend) .about-bio,
  .about-credits:not(.about-credits-blend) .nav-reveal {
    visibility: hidden;
  }
  /* exception: the tablet .about-compact scrolls internally, and two
     copies can't share one scroll position — it stays single-layer:
     hidden in the twin, flat difference-result ink in the original
     (no inversion there, nothing scrolls beneath it anyway). */
  .chrome-blend .about-compact { display: none; }
  .chrome:not(.chrome-blend) .about-compact .nav-reveal {
    visibility: visible;
    color: #050505;
    mix-blend-mode: normal;
  }

  /* macOS draws light-on-dark glyphs with gamma bloom, and the blended
     text is rasterized as WHITE before difference inverts it — so it
     came out optically bolder than the grey sans at the same 500.
     Grayscale antialiasing suppresses the bloom. */
  .nav-reveal, .about-bio, .tl-title,
  .cs-statement, .cs-cap-text, .cs-credit-names {
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  /* About content never truncates — anything that doesn't fit its
     width cap (sizeAboutServices() inline caps, the generic 33vw, the
     mobile col-1 cap below) wraps onto the next line instead. The
     ellipsis behavior stays for hover reveals (.reveal-wrap) only. */
  .about-reveal .nav-reveal {
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
    word-break: break-word;
  }

  .sans {
    /* only the "Discipline" label is sans — the value below it
       (e.g. "Type design") stays serif like the other reveal lines */
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
  }

  /* always-visible centered tagline — sentence case, page-default
     tracking, no hover behavior (project info lives in cols 1–3 now) */
  .chrome-center {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    text-align: center;
    white-space: nowrap;
    max-width: 50vw;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  @media (max-width: 1100px) {
    .chrome-center { display: none; }
  }

  /* --- 404 page (404.html) --- */
  /* the homepage with its images hidden; first scroll adds
     .leaving-404 (js/main.js) and the pool fades in */
  body.page-404 .columns {
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.9s ease;
  }
  body.page-404.leaving-404 .columns {
    opacity: 1;
    pointer-events: auto;
  }

  /* --- about overlay --- */

  /* mirrors .reveal-wrap's positioning exactly (absolute, out of
     flow, so it never affects the nav row's height) but fades in
     with the overlay instead of on hover */
  .about-reveal {
    position: absolute;
    top: 100%;
    left: 0;
    opacity: 0;
    transition: opacity 300ms ease;
  }
  body.about-open .about-reveal { opacity: 1; }

  /* .chrome is pointer-events:none (so nav hover/clicks reach the page
     beneath) — but while About is open its reveal blocks ARE the page
     content, and inheriting `none` made their text unselectable AND let
     clicks fall through to the overlay background, which closed it.
     Re-enable pointer events on the about content only; the document
     click handler's guard list already keeps these from closing. */
  body.about-open .about-reveal,
  body.about-open .nav-col-8,
  body.about-open .nav-col-9 { pointer-events: auto; }

  /* the bio paragraph is the one piece of about-page content that's
     actually meant to wrap across multiple lines and take real
     width, unlike every other short nowrap/ellipsis reveal line */
  .about-bio {
    /* TEST (test/sans-global): sans instead of serif — see .nav-reveal.
       Lives inside .chrome, so the fixed-bar fallback above flips this
       to flat #050505, no blend. */
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
    color: #fff;
    mix-blend-mode: difference;
    white-space: normal;
    /* fallback only, for the instant before JS measures the real gap
       to Services and sets an exact width — see sizeAboutBio() */
    width: 45vw;
  }

  /* Services/Selected clients, stacked below the bio, tablet/mobile
     only — nav-col-8/9 (desktop's side columns) are hidden below
     1100px, so this is the only place they appear at that size.
     Lives inside the same .about-reveal wrapper as the bio, in
     normal flow, so it just stacks underneath it — no separate
     positioning math, no second content grid. */
  .about-compact { display: none; }
  @media (max-width: 1100px) {
    .about-compact {
      display: flex;
      /* .about-reveal (the parent) is absolutely positioned with no
         explicit width, so it shrink-to-fits around its widest child —
         normally .about-bio, whose width is set by sizeAboutBio(). A
         flex container has no width of its own to distribute between
         columns unless told to fill that resolved width explicitly. */
      width: 100%;
      gap: 6vw;
      margin-top: 24px;
    }
  }
  /* nav-col-1's forced-width content (long nowrap strings, see grid
     min-content note above) leaves very little room for the bio/
     compact block on real phones — not enough to split into two
     side-by-side columns without cramping single-word client names.
     Tablet has room to spare (tested at 900px), so only mobile drops
     to a single stacked column here. */
  @media (max-width: 700px) {
    .about-compact { flex-direction: column; gap: 16px; }
  }
  .about-compact-col {
    display: flex;
    flex-direction: column;
    flex: 1 1 0;
    min-width: 0;
  }
  /* the desktop reveal columns are single-line/ellipsis by design
     (narrow columns, short hover-reveal labels) — here the two
     columns are wider and meant to wrap normally instead. overflow/
     text-overflow also need resetting: text-overflow:ellipsis still
     truncates a single unbreakable long word (e.g. "Nasjonalmuseet")
     even once white-space is normal, since that word alone still
     overflows its line — word-break covers that remaining case. */
  .about-compact .nav-reveal,
  .about-mobile-columns .nav-reveal {
    white-space: normal;
    max-width: none;
    overflow: visible;
    text-overflow: clip;
    word-break: break-word;
  }

  /* while the about page is open: the slogan and Discipline column
     have nothing to do here, and col1/col3's normal hover-driven
     content (or the tablet tagline) gets replaced by the about-page
     content instead — never shown at the same time */
  body.about-open .chrome-center,
  body.about-open .nav-col-2 { display: none; }
  body.about-open .nav-col-1 > .reveal-wrap,
  body.about-open .nav-col-3 > .reveal-wrap { display: none; }

  /* mobile: per Figma, the wordmark block (nav-col-1) AND the "About"
     label (nav-col-3's nav-line) stay fixed in the chrome while About
     is open — only nav-col-3's reveal content (bio + .about-compact)
     is replaced by .about-mobile-content, which lives in the real,
     scrollable page underneath instead. */
  @media (max-width: 700px) {
    body.about-open .nav-col-3 .about-reveal { display: none; }
    /* the studio info (Graphic Design + Typography / MVA) scrolls with
       the mobile content instead of hanging fixed off the wordmark —
       it lives in .about-mobile-content's intro row there */
    body.about-open .nav-col-1 .about-reveal { display: none; }
    /* the mobile About content uses a 2-column rhythm whose right
       column starts at 53vw (margin + half the padded content + half
       the 6vw gap — the paddings cancel out to exactly 53vw). Per
       Figma the fixed ABOUT label sits at that same column, not at
       its desktop grid-column-3 slot, so the scrollable description
       lines up directly underneath it. Absolute (not a grid slot)
       because col-1's min-content width makes grid columns land
       unpredictably; vertical position matches the centered row. */
    .nav-col-3 {
      position: absolute;
      /* abspos grid children resolve against their GRID AREA, not the
         container — span the full row so the area starts at .chrome's
         padding edge (--margin), then subtract that padding to land
         at exactly 53vw in viewport terms */
      grid-column: 1 / -1;
      left: calc(53vw - var(--margin));
      top: 50%;
      transform: translateY(-50%);
    }
  }

  .about-overlay {
    display: none;
    position: fixed;
    inset: 0;
    /* BELOW .chrome's z-index (20) on purpose — this translucent
       background needs to dim/blur the homepage's images underneath
       it, but the about-page content lives partly INSIDE .chrome
       (col1/col3/8/9's about-reveal blocks). If this sat above
       chrome, its own white+blur background would wash that text
       out — which is exactly what was happening. */
    z-index: 15;
    /* 95% of the page color, not of pure white — white made About a
       visibly brighter page than the rest of the site (2026-09-29) */
    background: rgba(250, 250, 250, 0.95); /* fallback: --bg #fafafa */
    background: color-mix(in srgb, var(--bg) 95%, transparent);
    cursor: pointer; /* hints that clicking anywhere closes it — the actual close is handled at the document level now, see JS */
    /* open/close (per Maciek 2026-09-29): the page fades in over 250ms
       while the blur behind it eases from 0 to 4px — the homepage
       going gently out of focus; closing reverses, a bit faster. The
       display flip rides along (allow-discrete) and @starting-style
       gives the opening its from-state; browsers without either just
       open/close instantly, as before. Closed state = these values,
       with the CLOSING timing. */
    opacity: 0;
    backdrop-filter: blur(0px);
    -webkit-backdrop-filter: blur(0px);
    transition: opacity 180ms ease, backdrop-filter 180ms ease,
      -webkit-backdrop-filter 180ms ease, display 180ms allow-discrete;
  }
  body.about-open .about-overlay {
    display: block;
    opacity: 1;
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    transition: opacity 250ms ease, backdrop-filter 250ms ease,
      -webkit-backdrop-filter 250ms ease, display 250ms allow-discrete;
  }
  @starting-style {
    body.about-open .about-overlay {
      opacity: 0;
      backdrop-filter: blur(0px);
      -webkit-backdrop-filter: blur(0px);
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .about-overlay, body.about-open .about-overlay { transition: none; }
  }

  /* page transitions (per Maciek 2026-09-29): same-site navigations
     cross-fade, and the image clicked on the homepage grows into the
     case study's first image — both carry view-transition-name
     mms-hero for that one navigation (main.js names the clicked box,
     case-study.js the first .cs-media). Browsers without cross-document
     view transitions navigate instantly, as before. */
  @view-transition { navigation: auto; }
  @media (prefers-reduced-motion: reduce) {
    @view-transition { navigation: none; }
  }
  ::view-transition-group(root) { animation-duration: 300ms; }
  ::view-transition-group(mms-hero) {
    animation-duration: 450ms;
    animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1);
  }
  /* old and new frames differ in shape: crop both to the moving box
     instead of letting either one stretch */
  ::view-transition-old(mms-hero),
  ::view-transition-new(mms-hero) {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  /* this is the about page's own scroll container — the homepage's
     scroll is frozen (body.about-open locks it via JS) while this
     is open, and the profile photo's sticky-hold-fade is driven by
     scrolling THIS element instead of the window */
  .about-scroll {
    position: absolute;
    inset: 0;
    overflow-y: auto;
    padding: 0 var(--margin);
  }

  /* buffer so the photo's sticky-hold-fade effect (see sizeAboutPhoto())
     has room to play out before whatever comes next. Desktop/tablet
     have nothing after it, so a generous fixed 150vh was fine — but on
     mobile, real content (.about-mobile-content) now follows it, and
     150vh left a ~1750px dead scroll zone before any of that content
     appeared. Mobile instead sizes it to what the effect actually
     needs: hold + fade distance, plus a little breathing room. */
  .about-photo-spacer { height: 150vh; }
  @media (max-width: 700px) {
    /* fallback only — sizeAboutMobileSpacer() measures the real gap so
       the bio's first line lands right under the fixed ABOUT label */
    .about-photo-spacer { height: calc(var(--hold-distance) + var(--fade-distance) + 60px); }
  }

  .about-photo-runway { position: relative; width: 160px; z-index: 1; /* stays above the timeline card layer in the brief moment both are pinned */ }
  @media (max-width: 700px) {
    /* Figma mobile avatar is 117 wide — also keeps the photo's runway
       (photo + hold + fade) short enough that the bio can start at the
       ABOUT label's height at rest */
    .about-photo-runway { width: 117px; }
  }
  .about-photo-sticky {
    position: sticky;
    top: var(--margin);
    width: 100%;
    will-change: opacity, filter;
  }
  .about-photo-sticky img {
    width: 100%;
    display: block;
    object-fit: cover;
    aspect-ratio: 4 / 5;
  }

  /* credits row, desktop/tablet only — fixed to the very bottom of the
     viewport, same 12-column system as the main nav for alignment, but
     its own grid instance (kept as separate classes from .nav-col-* on
     purpose, so nothing here couples back to the persistent nav).
     Mobile replaces this entirely with .about-mobile-bottom, which
     sits in normal flow at the true end of the scrollable content
     instead — nothing about mobile's About page is fixed except the
     wordmark, per Figma. */
  .about-credits {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    display: grid;
    grid-template-columns: repeat(12, 1fr);
    column-gap: var(--nav-gap);
    padding: 0 var(--margin) 12px;
  }
  .credit-col-1 { grid-column: 1; }
  .credit-col-3 { grid-column: 3; }
  .about-rights { grid-column: 12; justify-self: end; align-self: end; text-align: right; white-space: nowrap; }
  @media (max-width: 700px) {
    body.about-open .about-credits { display: none; }
    /* .mobile-footer's own Oslo/Norway + Mail-Instagram-Are.na content
       is superseded by .about-mobile-bottom + the Contact column while
       About is open — without this it would show through underneath. */
    body.about-open .mobile-footer { display: none; }
  }

  /* --- mobile About content: everything except the wordmark scrolls
     normally (see the nav-col-3/chrome rules above) — .about-compact-
     col and its .nav-reveal override are shared with the tablet/desktop
     .about-compact block above. */
  .about-mobile-content { display: none; }
  @media (max-width: 700px) {
    .about-mobile-content {
      display: block;
      padding-bottom: 40px;
    }
  }
  .about-mobile-columns {
    display: flex;
    gap: 6vw;
    /* section rhythm — a round 12em (144px @ 12px text) rather than a
       literal 128px-from-Figma conversion, per Maciek */
    margin-bottom: 12em;
  }
  /* ONE extra scrolling line right under the fixed nav (per Maciek,
     2026-07-16): short address under the wordmark, contact links under
     the ABOUT label — same two-column rhythm as the rows below.
     ABSOLUTE, not in flow: the gap between the nav line and the intro
     row is a single line's worth (the runway/spacer put the intro just
     under the nav), and taking this out of flow is what keeps the
     info/bio row where it was (per Maciek: add the line WITHOUT moving
     those). It must stay ONE line tall — sizeAboutMobileSpacer() keeps
     the intro 160px below the nav (the halved mobile block rhythm),
     and this line lives inside that gap. That's also why the links are
     NOT .link-caps sized: caps at 10% tracking wrap to three lines in
     this ~44vw column, straight over the bio.
     Anchors to .about-scroll (the nearest positioned ancestor), whose
     padding box starts at the viewport edge — so the page margin is
     re-applied here, same as .about-cards. Vertical position is pure
     CSS: the chrome row is vertically centered, so "under the nav
     line" is 50% plus half the line's height (0.75em lands within 1px
     of the measured label bottom — no JS sizer needed, deliberately). */
  .about-mobile-topline {
    position: absolute;
    top: calc(50% + 0.75em);
    left: var(--margin);
    right: var(--margin);
    margin-bottom: 0; /* the 12em section rhythm is meaningless out of flow */
  }
  /* .about-compact-col is a COLUMN flexbox (built for stacked lists),
     which turns each inline link into its own row — as blocks the
     address and the links render as the single text line this slot
     needs. They stay flex ITEMS of the row, so the 50/50 split holds. */
  .about-mobile-topline .about-compact-col { display: block; }
  /* caps like the footer (per Maciek, 2026-07-16), but at the page's
     default tracking scale, not .link-caps' 10%: measured at 375px the
     ~164px column fits MAIL, INSTAGRAM, ARE.NA at 0.02em exactly,
     while 10% runs 181px — through the margin on any phone. Nowrap on
     purpose: wrapping would break the one-line slot (see above). */
  .about-mobile-topline [data-about-links-inline] {
    text-transform: uppercase;
    letter-spacing: 0.02em;
    white-space: nowrap;
  }
  .about-mobile-credit-gap { margin-top: 16px; }
  /* one row, per Figma: address left, rights right */
  .about-mobile-bottom {
    display: flex;
    justify-content: space-between;
    gap: 1em;
  }
  .about-mobile-bottom > div:last-child { text-align: right; }

  /* --- about timeline cards (Figma 551:572 / 551:438, card components
     551:650 "With image" + 551:657 "Basic") ---
     A layer of chronological career/life cards inside the about page's
     scroll. Desktop/tablet: absolutely-positioned scatter on the 12-col
     nav grid, sitting UNDER the fixed chrome text (chrome is z-index 20,
     this layer is z 0 inside the scroller). Mobile: a plain full-width
     stack in the .about-mobile-content flow, under the fixed wordmark.
     Each card reuses the homepage sticky-hold-fade mechanic — see
     buildAboutCards()/updateAboutCards() in main.js. */

  .about-cards {
    position: absolute;
    /* abspos offsets resolve against .about-scroll's padding BOX, not
       inside its padding — so the page margin has to be re-applied here
       for the card grid to line up with the chrome's grid */
    top: 0;
    left: var(--margin);
    right: var(--margin);
    z-index: 0;
  }
  @media (max-width: 700px) {
    .about-cards { display: none; } /* mobile uses .about-mobile-cards */
  }
  .about-cards .tl-runway { position: absolute; }
  /* mobile: cards stack full-width, 12px apart at rest (Figma) — the
     runway heights (card + hold + fade) provide the scroll room, same
     as the homepage columns */
  .about-mobile-cards .tl-runway { position: relative; margin-bottom: 12px; }
  .about-mobile-cards { margin-bottom: 12em; } /* same section rhythm as .about-mobile-columns */

  .tl-sticky {
    position: sticky;
    top: var(--margin);
    width: 100%;
    will-change: opacity, filter;
  }

  /* card: 70% #EBEBEE fill, one 12px rhythm throughout — 12px vertical
     padding and 12px between date/title/image (per Maciek 2026-07-14,
     tightened from the component's original 24/16). Sides stay 16px
     desktop / 12px mobile. */
  .tl-card {
    cursor: auto; /* the overlay's cursor:pointer means "click to close" — cards don't close it, so don't advertise it */
    display: flex;
    flex-direction: column;
    /* Figma's 12px text gap is measured between cap-trimmed text boxes —
       with normal line boxes that's a 4px gap here */
    gap: 4px;
    background: rgba(235, 235, 238, 0.7);
    padding: 12px 16px;
  }
  .tl-card .tl-img { margin-top: 8px; } /* image keeps the true 12px offset */
  @media (max-width: 700px) {
    .tl-card { padding: 12px; }
  }

  .tl-date {
    /* Figma: Oracle REGULAR here — deliberately the one place the sans
       drops below Medium; 12/20, 1% tracking, uppercase */
    font-weight: 400;
    font-size: 12px;
    line-height: 20px;
    letter-spacing: 0.01em;
    text-transform: uppercase;
    color: #8d8d8d;
  }

  .tl-title {
    /* TEST (test/sans-global): sans instead of serif — see .nav-reveal.
       Flat #050505 rather than white+difference: the card fades via
       the sticky-hold mechanic, and ancestor opacity would isolate a
       blended title into invisible white mid-fade. The title never
       overlaps media anyway, so the blend bought nothing here. */
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
    font-size: 12px; /* one text size site-wide (was 13px, the serif era's size) */
    line-height: 20px;
    letter-spacing: 0.01em;
    color: #050505;
  }
  /* de-emphasized names/places inside a title (50% in Figma) — same
     gray as the rest of the site's secondary text */
  .tl-title .tl-muted { color: #8d8d8d; }

  .tl-img {
    height: 320px; /* fixed in Figma at every breakpoint */
    width: 100%;
    overflow: hidden; /* square corners on purpose — no radius anywhere in the cards */
  }
  .tl-img img,
  .tl-img video {
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
    background: #d9d9d9;
  }

  .columns {
    display: flex;
    gap: var(--col-gap);
    padding: 0 var(--margin);
    align-items: flex-start;
    /* own stacking context: sticky items carry document-position z-indexes
       (sizeRunways() — lower on the page stacks on top) that run into the
       thousands; isolate keeps them from competing with the chrome (z 20) */
    isolation: isolate;
    /* NOTE: column widths are set inline as calc((100% - gaps) * fraction) —
       see buildLayout(). Using plain percentage flex-basis alongside this
       `gap` double-counts the gap and overflows the page horizontally,
       which is the bug this replaces. */
  }

  /* mobile-only footer: address + links move here from the fixed nav
     (there's no room for them up top once the tagline wraps to
     multiple lines). In normal document flow at the true end of the
     page's content — un-fixed per Maciek 2026-07-16; it only appears
     when you actually reach the bottom. NOTE the homepage's infinite
     scroll means it effectively never surfaces there — the about
     overlay's top line carries the same address + links on mobile. */
  .mobile-footer {
    display: none;
    padding: 0 var(--margin) 12px;
    margin-top: 6em; /* same breathing-room rhythm as .cs-projinfo's gap */
  }
  @media (max-width: 700px) {
    .mobile-footer { display: grid; grid-template-columns: 1fr auto; }
  }
  .mobile-footer > div:last-child { text-align: right; }

  .col { display: flex; flex-direction: column; min-width: 0; }

  .runway { position: relative; }

  .sticky-item {
    position: sticky;
    top: var(--margin);
    width: 100%;
    /* deliberately NO transition here by default — scroll-driven
       opacity/blur stay frame-exact, matching the raw scroll position.
       The hover-transition class below is toggled on temporarily,
       only while a hover gesture is actually happening, so the smooth
       300ms animation never leaks into normal scroll-scrubbing. */
  }
  /* own compositor layer (blur/opacity change without repainting the
     image) — only for items near the viewport, which render() in
     main.js marks .live. On every item it kept hundreds of layers
     alive after a long infinite scroll. */
  .sticky-item.live { will-change: opacity, filter, transform; }
  /* mobile (per Maciek 2026-09-29): the frames run to the screen edges
     (placement keeps a margin most of the time, see addItemToColumn),
     and the scroll blur sits on the media itself (clean edges, render()) */
  @media (max-width: 700px) {
    .columns { padding: 0; }
    .sticky-item.live .rect { will-change: filter, transform; }
  }
  .columns.hover-transition .sticky-item {
    transition: opacity 300ms ease, filter 300ms ease, transform 300ms ease;
  }

  .box { position: relative; width: 100%; cursor: pointer; }

  .rect {
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
    background: #d9d9d9;
  }

  /* blur-up loading (mmsBlurUp() in main.js): while an image's real
     file is on its way, the element shows its 400px derivative as a
     blurred background; the load event eases the blur to 0. The filter
     sits on the media element itself so it composes with (never
     fights) the scroll effects' filter on the sticky ancestors, and
     clip-path keeps the blur halo inside the image's own edges. Only
     ever applied while a file is genuinely still loading — cached
     images never get the class, so nothing flashes on revisits. */
  .blur-up {
    filter: blur(18px);
    clip-path: inset(0);
    transition: filter 600ms ease;
    background-size: cover;
    background-position: center;
  }
  .blur-up.blur-up-done { filter: blur(0); }
  @media (prefers-reduced-motion: reduce) {
    .blur-up { transition: none; }
  }
  /* the gray #d9d9d9 on .rect / .tl-img img is a LOADING placeholder —
     once the file is in (mmsBlurUp adds media-loaded) it has to go,
     or transparent images (svg, png alpha) show it through forever */
  img.media-loaded { background: transparent; }

  /* idle blur (per Maciek 2026-09-29): after 15s without input
     (main.js adds body.idle) every media frame goes soft; the first
     movement brings it back. Only the media element itself blurs —
     clip-path keeps each frame's edge crisp (it clips after the
     filter), and captions, nav and all text stay sharp. Sits on the
     .rect / timeline media, never on the containers that already carry
     scroll-driven inline filters (.sticky-item, .cs-media). Eases in
     slowly, snaps back quicker. */
  .box .rect, .cs-media .rect, .tl-img img, .tl-img video, .about-photo-sticky img {
    transition: filter 400ms ease;
  }
  body.idle .box .rect, body.idle .cs-media .rect,
  body.idle .tl-img img, body.idle .tl-img video, body.idle .about-photo-sticky img {
    filter: blur(10px);
    clip-path: inset(0);
    transition: filter 1200ms ease;
  }
  /* phones: the same blur without the see-through fringe clip-path
     leaves inside each edge — the media scales up just enough to push it
     outside its (clipping) frame: 10px of blur × --edge-k, the per-px
     factor main.js sets per homepage frame (fallbacks sized for the
     other frames). Desktop keeps the clip-path look. */
  @media (max-width: 700px) {
    .box, .cs-media, .tl-img, .about-photo-sticky { overflow: hidden; }
    .cs-media .rect, .tl-img img, .tl-img video { --edge-k: 0.012; }
    .about-photo-sticky img { --edge-k: 0.035; }
    .box .rect, .cs-media .rect, .tl-img img, .tl-img video, .about-photo-sticky img {
      transition: filter 400ms ease, transform 400ms ease;
    }
    body.idle .box .rect, body.idle .cs-media .rect,
    body.idle .tl-img img, body.idle .tl-img video, body.idle .about-photo-sticky img {
      clip-path: none;
      transform: scale(calc(1 + 10 * var(--edge-k, 0.02)));
      transition: filter 1200ms ease, transform 1200ms ease;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .box .rect, .cs-media .rect, .tl-img img, .tl-img video, .about-photo-sticky img,
    body.idle .box .rect, body.idle .cs-media .rect,
    body.idle .tl-img img, body.idle .tl-img video, body.idle .about-photo-sticky img { transition: none; }
  }

  /* homepage caption row (per Maciek 2026-09-29): the project title
     bottom-left and the index number on the right — the whole row shows
     only while its image is hovered (.show-index, set by onHoverStart —
     JS rather than :hover so an image scrolling under a parked cursor
     doesn't light up, same rule as the hover spotlight). Touch tiers
     have no hover, so they never show captions. */
  .caption {
    margin-top: 4px; /* halved from 8px (per Maciek 2026-09-29) */
    display: flex;
    align-items: baseline;
    gap: 12px;
    opacity: 0;
    transition: opacity 300ms ease, transform 300ms ease;
  }
  /* same type as the nav's project title (#navProjectName). The nav
     gets its near-black by blending white with difference over --bg;
     a sticky item is an isolated layer (opacity/filter), where a blend
     would come out invisible white, so this uses the flat result of
     that blend instead — same ink as the tablet .about-compact. Wraps
     rather than truncating in a narrow column. */
  .caption-title {
    flex: 1 1 auto;
    min-width: 0;
    overflow-wrap: anywhere;
    color: #050505;
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
  /* index numbers wear the same dress as the case study's [00N]:
     right-aligned, Libertinus Mono (per Maciek 2026-07-15) */
  .caption-index {
    flex: none;
    margin-left: auto;
    text-align: right;
    font-family: 'Libertinus Mono', ui-monospace, 'SF Mono', Menlo, 'Courier New', monospace;
    letter-spacing: 0;
  }
  .caption-title, .caption-index { transition: transform 300ms ease; }

  /* hover: ONLY the image grows (HOVER_SCALE in main.js); the caption
     keeps its type size and moves with the grown image's edges instead
     — down by the bottom's travel, title out with the left edge, index
     with the right (--grow-* set per item in addItemToColumn) */
  .box { transition: transform 300ms ease; }
  .sticky-item.show-index .box { transform: scale(1.1); }
  .sticky-item.show-index .caption { opacity: 1; transform: translateY(var(--grow-b, 0px)); }
  .sticky-item.show-index .caption-title { transform: translateX(calc(-1 * var(--grow-l, 0px))); }
  .sticky-item.show-index .caption-index { transform: translateX(var(--grow-r, 0px)); }
  /* the hovered frame must clear its neighbors: stacking is otherwise
     by document position, set inline per item (sizeRunways), hence
     !important */
  .sticky-item.show-index { z-index: 2147483000 !important; }

  /* --- case study page (case-study.html, Figma 507:1980) --- */

  /* the wordmark links home here — same look as the plain text
     wordmark, just clickable (.chrome is pointer-events:none) */
  /* contact links from about.json (populateAbout): real anchors that
     dress exactly like the plain text lines they replaced */
  .about-link {
    color: inherit;
    text-decoration: none;
  }
  /* the fixed chrome is pointer-events:none so hovers/clicks reach the
     page beneath — which also made the real anchors inside it (contact
     links, the address) unclickable. Re-enable pointer events on the
     anchors themselves. (The mobile footer no longer needs this: it
     sits in normal flow now, nothing beneath it to click through to.) */
  .nav-links a,
  .nav-address a { pointer-events: auto; }

  /* rich text from the admin (bio, timeline titles, captions,
     statements, descriptions): exactly three formats exist — <em>,
     <span class="muted">, <a href> — and links dress like the text
     around them; the muted gray IS the link affordance here, so no
     underline anywhere. The a.muted rule sits LAST so a muted link
     keeps the gray despite the equal-specificity inherit rules. */
  .tl-title a, .about-bio a, .about-mobile-bio a, .cs-statement a,
  .cs-cap-text a, #navDescription a, .cs-projinfo a {
    color: inherit;
    text-decoration: none;
  }
  .muted, a.muted { color: #8d8d8d; } /* same 50% gray as .tl-muted */
  /* TEST (test/sans-global): muted spans inside difference-blended
     prose need the compensated grey (#6d6d6d renders as #8d8d8d over
     --bg under the blend) — same idea as the chrome's sans. */
  .nav-reveal .muted, .about-bio .muted, .cs-statement .muted,
  .cs-cap-text .muted, .cs-credit-names .muted { color: #6d6d6d; }
  /* …the blend twins invert as a layer, so their muted needs the same
     compensation; the unblended originals keep the real site grey */
  .chrome-blend .muted, .about-credits-blend .muted { color: #6d6d6d; }
  .chrome:not(.chrome-blend) .muted,
  .about-credits:not(.about-credits-blend) .muted { color: #8d8d8d; }
  /* About's own scroller (mobile content): the difference blend can't
     reach the page from inside a scrolling layer — iOS Safari blended it
     against a transparent layer and the text came out WHITE. Nothing
     moves beneath this text anyway, so it gets the blend's flat result
     (near-black, real grey for muted), same as the tablet .about-compact. */
  .about-scroll .nav-reveal, .about-scroll .about-bio {
    color: #050505;
    mix-blend-mode: normal;
  }
  .about-scroll .nav-reveal .muted, .about-scroll .about-bio .muted { color: #8d8d8d; }
  /* case study captions: the row is one of the exit fade's styled
     children (opacity + will-change = its own layer), so a difference
     blend inside it saw a transparent backdrop and the caption text came
     out WHITE (invisible). A caption always sits under its media on the
     page color, so the flat blend result looks identical. */
  .cs-caption .cs-cap-text { color: #050505; mix-blend-mode: normal; }
  .cs-caption .cs-cap-text .muted { color: #8d8d8d; }
  .cs-caption .cs-cap-text::selection, .cs-caption .cs-cap-text ::selection { background: rgb(240, 240, 242); }
  .about-scroll .nav-reveal::selection, .about-scroll .nav-reveal ::selection,
  .about-scroll .about-bio::selection, .about-scroll .about-bio ::selection {
    background: rgb(240, 240, 242);
  }

  .home-link {
    color: inherit;
    text-decoration: none;
    pointer-events: auto;
  }

  /* the case study nav drops the centered slogan — the project info
     reveals are the permanent content here (per Maciek 2026-07-14).
     On desktop it stays in the DOM at --exit-p opacity: during the
     exit cross-fade the slogan takes the project info's place, the
     chrome itself never moving — it's the same module on both pages. */
  body.case-study .chrome-center { display: none; }
  @media (min-width: 1101px) {
    body.case-study .chrome-center {
      display: block;
      opacity: var(--exit-p, 0);
    }
  }

  /* exit cross-fade (case-study.js): the REAL front-page pool is
     built by main.js into this page's #columns too, held as a fixed,
     invisible backdrop under the case content. Overscrolling at the
     credits scrubs --exit-p: content fades out, pool fades in, and at
     full progress the page becomes the homepage in place (the class
     comes off body and these rules stop applying). */
  body.case-study .columns {
    position: fixed;
    inset: 0;
    z-index: 0;
    opacity: 0;
    overflow: hidden;
    pointer-events: none;
  }
  body.case-study .case-content {
    position: relative;
    z-index: 1;
    /* TEST (test/sans-global): the z-index makes this an isolated
       stacking context, which cut the blended text (statement,
       captions, credits) off from the BODY background — difference
       against a transparent backdrop = invisible white. Painting the
       page color here puts a backdrop inside the context, so the
       blends work again; during the exit scrub the whole layer
       (background included) fades via inline opacity, so the
       cross-fade to the pool is unchanged. */
    background: var(--bg);
  }

  /* a long project name wraps inside col 1 instead of ellipsizing at
     33vw — which let "Nasjonalmuseet – Kandinsky" run straight under
     Discipline (163px of text in a 127px track, near the 1101px
     breakpoint where the tracks are tightest). 100%, NOT the /12 calc
     col 2 uses: the chrome's tracks are `1fr` = minmax(auto, 1fr), so
     col 1 is as wide as the wordmark forces it (~127px), not a
     twelfth of the page — a calc would cap it at the wrong width.
     Safe because .reveal-wrap is absolutely positioned: a wrapping
     name adds no min-content to the track, so the grid never shifts.
     Unscoped: the same treatment serves the desktop hover state, the
     desktop case page AND the fixed mobile title below. */
  .nav-col-1 .nav-reveal {
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
    word-break: break-word;
    max-width: 100%;
  }

  /* the case study page keeps the project name FIXED in the nav at
     every width (per Maciek, 2026-07-16): below 1100px the same
     col-1 reveal that desktop shows is simply switched on — the
     tablet-tagline that used to occupy the slot is already hidden on
     this page. Fades out with the exit cross-fade like the rest of
     the chrome; hidden while the About overlay is open (the existing
     body.about-open rule wins with display:none). */
  @media (max-width: 1100px) {
    body.case-study .nav-col-1 .reveal-wrap { opacity: calc(1 - var(--exit-p, 0)); }
  }

  /* desktop: the frontpage's hover state, permanent — discipline and
     project info always visible so you always know which case study
     you're in. Desktop-only: below 1100px the nav carries only the
     wordmark + project name; discipline and description live in the
     page instead (.cs-projinfo). */
  @media (min-width: 1101px) {
    /* permanently visible — except during the exit cross-fade, where
       the project info fades out of the chrome as --exit-p rises */
    body.case-study .reveal-wrap { opacity: calc(1 - var(--exit-p, 0)); }
    /* the Discipline label sits outside the reveal wraps, so it
       carries the exit fade itself (the old --desc-fade scroll fade is
       gone, 2026-09-29: the project info stays until the exit) */
    body.case-study .nav-line.hover-only { opacity: calc(1 - var(--exit-p, 0)); }
    /* the description fades out over the last viewport of scroll
       (--end-fade, updateExitFade) so it never sits on top of the
       credits; multiplies with the wrap's exit fade */
    body.case-study #navDescription { opacity: calc(1 - var(--end-fade, 0)); }
    /* a long discipline wraps inside its own grid track instead of
       ellipsizing at 33vw — which let it run under the description in
       col 3 (the tracks are 1fr each, so one track's width is the
       page width minus margins and gutters, divided by 12). Applies to
       the homepage hover state and the case study page alike — it's
       the same column on both. */
    .nav-col-2 .nav-reveal {
      white-space: normal;
      overflow: visible;
      text-overflow: clip;
      word-break: break-word;
      max-width: calc((100vw - 2 * var(--margin) - 11 * var(--nav-gap)) / 12);
    }
    /* the description column wraps like the about bio (its width is
       measured to the same col-8 line by sizeNavDescription()) instead
       of ellipsizing at 33vw like the other hover reveals. Same column,
       same content, same behavior on both pages — on the homepage it's
       hover-driven, on the case study page it's permanent. */
    #navDescription {
      white-space: normal;
      overflow: visible;
      text-overflow: clip;
      max-width: none;
      word-break: break-word;
    }
  }

  /* block sequence: 20rem = the 320px block rhythm in Figma (rem, not
     body-em — 20em of 12px text is 240px, which isn't the design).
     72px top offset = where the hero starts in the frame. */
  .case-content {
    padding: 72px var(--margin) 4em;
  }

  /* tablet/mobile: the nav keeps the project name (fixed, see above)
     but drops discipline + description — those move into the page as
     normal scrollable content instead. The page opens with the FIRST
     BLOCK at the very top (per Maciek, 2026-07-16), so the info block
     slots in AFTER it: the DOM order (projinfo, block1, block2…) is
     shared with desktop, and flex `order` swaps the first two
     visually. Everything else keeps order 2, so DOM order rules
     within the rest of the page. */
  .cs-projinfo { display: none; }
  @media (max-width: 1100px) {
    .case-content {
      display: flex;
      flex-direction: column;
      padding-top: var(--margin); /* the 72px hero offset is desktop framing — here the page starts within the page margin */
    }
    .case-content > * { order: 2; }
    .cs-projinfo { order: 1; }
    .cs-projinfo + .cs-block { order: 0; } /* the DOM-first block leads the page */
    /* two top-aligned columns, same 50/50 + 6vw-gap rhythm as the
       about overlay's mobile columns: discipline left, description
       right */
    .cs-projinfo {
      display: flex;
      gap: 6vw;
      align-items: flex-start;
      margin-bottom: 6em; /* breathing room before the next block, in the page's em rhythm */
    }
    .cs-projinfo-col {
      flex: 1 1 0;
      min-width: 0;
    }
    .cs-projinfo .nav-reveal {
      white-space: normal;
      max-width: none;
      overflow: visible;
      text-overflow: clip;
      word-break: break-word;
    }
  }
  /* every block owns the 20rem below it — the credits at the end add
     no margin of their own, so the rhythm stays single-source */
  .cs-block { margin-bottom: 20rem; }
  /* mobile: the whole rhythm halves (per Maciek, 2026-07-16). The
     divider presets get their own halving next to their definitions
     below — they're declared later in the file, so an override here
     would lose the cascade (media queries add no specificity; see the
     .about-only gotcha) */
  @media (max-width: 700px) {
    .cs-block { margin-bottom: 10rem; }
  }

  /* single media block — hero is 1462/1920 of the viewport, centered;
     the width lives on the block so its caption aligns to the media's
     edges, not the page's */
  .cs-single {
    width: 76.15vw;
    max-width: 100%;
    margin-left: auto;
    margin-right: auto;
  }
  /* mobile: every container takes the full content width (inside the
     page padding) regardless of its desktop/tablet sizing */
  @media (max-width: 700px) {
    .cs-single { width: 100%; }
  }

  /* divider block: nothing but one full unit of the block rhythm —
     its own 20rem margin from .cs-block is the whole point, for
     spacing sections out (chosen in the backend later) */
  .cs-divider { height: 0; }
  /* divider size presets (`size` in the block JSON): default/m is the
     one extra rhythm unit above, s halves it, l doubles it */
  .cs-divider-s { margin-bottom: 10rem; }
  .cs-divider-l { margin-bottom: 40rem; }
  /* mobile: half rhythm, same ratios (the .cs-block base halves too) */
  @media (max-width: 700px) {
    .cs-divider-s { margin-bottom: 5rem; }
    .cs-divider-l { margin-bottom: 20rem; }
  }

  .cs-media { position: relative; }
  .cs-media video.rect { object-fit: cover; } /* .rect's img rules apply to videos too */

  /* exit fade (see updateExitFade() in case-study.js): scroll-driven
     opacity/blur on the way out of the viewport — same no-transition
     reasoning as the homepage's .sticky-item, values stay frame-exact */
  /* the fade is applied to the block's CHILDREN (media wrap + caption),
     not the block itself: will-change/opacity on the container makes it
     an isolated stacking context, which cut the caption's difference
     blend off from the page and rendered it invisible white — same
     trap as the fixed .chrome. See updateExitFade() in case-study.js. */
  .cs-single > *, .cs-full > *, .cs-pair-item > * {
    will-change: opacity, filter;
  }

  /* short centered statement — was serif like everything editorial;
     TEST (test/sans-global): sans + white/difference, see .nav-reveal.
     Desktop: exactly 4 grid columns wide, wrapping within; below that
     it just takes the full content width (good as is, per Maciek). */
  .cs-statement {
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
    /* compensated grey, not white (per Maciek): #6d6d6d under
       difference over --bg renders exactly the nav's #8d8d8d, and
       still inverts over passing media */
    color: #6d6d6d;
    mix-blend-mode: difference;
    font-size: 12px;
    line-height: 16px;
    letter-spacing: 0.01em;
    margin: 0 auto;
    text-align: center;
  }
  @media (min-width: 1101px) {
    .cs-statement {
      width: calc(4 * (100% - 11 * var(--nav-gap)) / 12 + 3 * var(--nav-gap));
    }
  }

  /* two media side by side, 12px gap, spanning grid cols 2-11 of the
     same 12-col system as the chrome (Figma: 171px margins on 1920) */
  .cs-pair {
    width: calc(10 * (100% - 11 * var(--nav-gap)) / 12 + 9 * var(--nav-gap));
    margin-left: auto;
    margin-right: auto;
  }
  .cs-pair-row {
    display: flex;
    gap: var(--nav-gap);
  }
  /* each half is its own numbered container (media + index caption) */
  .cs-pair-item {
    width: calc((100% - var(--nav-gap)) / 2);
  }
  /* mobile: the two images stack full-width instead of sitting side
     by side, so each one gets real size */
  @media (max-width: 700px) {
    .cs-pair { width: 100%; }
    .cs-pair-row { flex-direction: column; }
    .cs-pair-item { width: 100%; }
  }

  /* per-block column width (optional `width: 4-12` in the block JSON,
     single/pair only — full is edge-to-edge by definition): the block
     snaps to N columns of the chrome's 12-col system, centered. Same
     gap-aware calc idiom as .cs-pair above, driven by --cols set
     inline by buildBlock(). 12 = full content width, still inside the
     page margins (that's the difference from `full`). Sits after the
     .cs-single/.cs-pair defaults so it wins the cascade at equal
     specificity; min-width-gated so the mobile everything-full-width
     rules above stay in charge below 700px. */
  @media (min-width: 701px) {
    .cs-cols {
      width: calc(var(--cols) * (100% - 11 * var(--nav-gap)) / 12 + (var(--cols) - 1) * var(--nav-gap));
      max-width: 100%;
      margin-left: auto;
      margin-right: auto;
    }
  }

  /* full-screen bleed: edge to edge, one viewport tall — escapes the
     page margin; a caption below it stays inside the margins */
  .cs-full .cs-media {
    margin-left: calc(-1 * var(--margin));
    margin-right: calc(-1 * var(--margin));
    height: 100vh;
  }

  /* a case that OPENS with a full-bleed block starts at literal 0 —
     the top offset (72px hero framing on desktop, the page margin on
     tablet/mobile) is for framed blocks, not an edge-to-edge cover.
     .cs-projinfo is always the container's first DOM child
     (display:none on desktop, visually reordered after the first
     block below 1100px), so "+ .cs-full" is the first real block at
     every width. */
  @media (min-width: 1101px) {
    .cs-projinfo + .cs-full .cs-media { margin-top: -72px; }
  }
  @media (max-width: 1100px) {
    .cs-projinfo + .cs-full .cs-media { margin-top: calc(-1 * var(--margin)); }
  }

  /* two font blocks sharing one row (`beside` on the first, see
     buildCaseStudy()): each keeps its own optional .cs-cols width —
     no width = the two split the row evenly. The row owns the 20rem
     block rhythm; the halves' own margin is neutralized. Below 700px
     they stack full-width like everything else. */
  .cs-sidebyside {
    display: flex;
    gap: var(--nav-gap);
    align-items: flex-start;
    justify-content: center;
  }
  .cs-sidebyside > .cs-block {
    margin-bottom: 0;
    flex: 1 1 0;
    min-width: 0;
  }
  .cs-sidebyside > .cs-cols { flex: none; } /* an explicit width wins over the even split */
  @media (max-width: 700px) {
    .cs-sidebyside { flex-direction: column; gap: 6em; }
    .cs-sidebyside > .cs-block, .cs-sidebyside > .cs-cols { width: 100%; flex: none; }
  }

  /* --- font showcase blocks (js/specimens.js, 2026-09-29) ---
     The type is the subject: specimen text near-black in the font
     itself, controls quiet (12px grey site sans, values in the index
     mono), hairline rules (#e4e4e4). Text fades in once the scrambled
     font has loaded (.spec-ready) so the fallback never flashes. */
  .spec { position: relative; }
  .spec-display-text, .spec-sample, .spec-monitor-glyph, .spec .cs-glyphs,
  .spec-feature-sample, .spec-weight-text, .spec-poster {
    opacity: 0;
    transition: opacity 400ms ease;
  }
  .spec-ready .spec-display-text, .spec-ready .spec-sample, .spec-ready .spec-monitor-glyph,
  .spec-ready .cs-glyphs, .spec-ready .spec-feature-sample, .spec-ready .spec-weight-text,
  .spec-ready .spec-poster { opacity: 1; }
  .spec [contenteditable] { outline: none; caret-color: #111; }
  /* corner label: family · style · live size, dressed like the [00N] */
  .spec-label, .spec-poster-spec {
    margin-top: 8px;
    text-align: right;
    font-family: 'Libertinus Mono', ui-monospace, 'SF Mono', Menlo, monospace;
    letter-spacing: 0;
    color: #8d8d8d;
  }
  .spec-poster-spec { text-align: left; }

  /* display line: fitted edge to edge by JS (fitSize), one line */
  /* no overflow clipping: the fit guarantees the width, and a clip cut
     the descenders off */
  .spec-display-line { line-height: 1; }
  .spec-display-text {
    display: inline-block;
    white-space: pre;
    color: #111;
    font-weight: 400;
    letter-spacing: 0;
    line-height: 1.05;
  }

  /* tester: the toolbar is one hairline row that only shows while the
     specimen is hovered or being edited (touch: always) — 36 toolbars
     stacked like Optimo/Klim read as a form, not a specimen */
  .spec-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 24px;
    padding: 8px 0;
    margin-bottom: 24px;
    border-top: 1px solid #e4e4e4;
    border-bottom: 1px solid #e4e4e4;
    color: #8d8d8d;
    opacity: 0;
    transition: opacity 250ms ease;
  }
  .spec-tester:hover .spec-toolbar, .spec-tester:focus-within .spec-toolbar { opacity: 1; }
  @media (hover: none) { .spec-toolbar { opacity: 1; } }
  .spec-ctl { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
  .spec-ctl-val, .spec-feature-tag, .spec-guide-val, .spec-glyph-code, .spec-weight-val, .spec-feature-state {
    font-family: 'Libertinus Mono', ui-monospace, 'SF Mono', Menlo, monospace;
    letter-spacing: 0;
  }
  .spec-ctl-val { color: #111; min-width: 4ch; }
  /* hairline slider: 1px track, the traveled part near-black, 8px dot */
  .spec-range {
    -webkit-appearance: none;
    appearance: none;
    width: 88px;
    height: 14px;
    margin: 0;
    background:
      linear-gradient(#111, #111) 0 50% / var(--p, 0%) 1px no-repeat,
      linear-gradient(#cfcfcf, #cfcfcf) 0 50% / 100% 1px no-repeat;
    cursor: pointer;
  }
  .spec-range::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 8px; height: 8px;
    border-radius: 50%;
    background: #111;
    border: 0;
  }
  .spec-range::-moz-range-thumb { width: 8px; height: 8px; border-radius: 50%; background: #111; border: 0; }
  .spec-range::-moz-range-track { background: none; }
  .spec-select select {
    -webkit-appearance: none;
    appearance: none;
    border: 0;
    background: none;
    padding: 0;
    font: inherit;
    letter-spacing: inherit;
    color: #111;
    cursor: pointer;
  }
  .spec-btn {
    -webkit-appearance: none;
    appearance: none;
    border: 0;
    background: none;
    padding: 0;
    font: inherit;
    letter-spacing: inherit;
    color: #8d8d8d;
    cursor: pointer;
  }
  .spec-btn:hover, .spec-btn.active { color: #111; }
  .spec-align { gap: 12px; }
  .spec-features-ctl { position: relative; }
  .spec-feature-panel {
    display: none;
    position: absolute;
    top: calc(100% + 9px);
    left: 0;
    z-index: 5;
    min-width: 220px;
    padding: 8px 12px;
    flex-direction: column;
    gap: 4px;
    background: var(--bg);
    border: 1px solid #e4e4e4;
  }
  .spec-features-ctl.open .spec-feature-panel { display: flex; }
  .spec-feature-toggle { display: flex; justify-content: space-between; gap: 16px; text-align: left; }
  .spec-feature-toggle .spec-feature-name::before {
    content: '';
    display: inline-block;
    width: 7px; height: 7px;
    margin-right: 8px;
    border: 1px solid currentColor;
    border-radius: 50%;
    vertical-align: 1px;
  }
  .spec-feature-toggle.active .spec-feature-name::before { background: currentColor; }
  .spec-feature-none { color: #8d8d8d; }
  .spec-sample {
    color: #111;
    font-weight: 400;
    overflow-wrap: anywhere;
    column-gap: var(--col-gap);
    transition: opacity 400ms ease, font-size 250ms ease, line-height 250ms ease,
      letter-spacing 250ms ease, font-variation-settings 250ms ease;
  }
  /* while an axis loop drives it, the variation settings change every
     frame — a transition on them would only lag behind */
  .spec-sample.spec-anim {
    transition: opacity 400ms ease, font-size 250ms ease, line-height 250ms ease, letter-spacing 250ms ease;
  }

  /* glyph inspector: sticky monitor (1/3) beside the grouped grid (2/3) */
  .spec-glyphs {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
    gap: 24px;
    align-items: start;
  }
  .spec-inspector { position: sticky; top: var(--margin); background: var(--bg); z-index: 2; }
  .spec-monitor { position: relative; height: 420px; border: 1px solid #e4e4e4; overflow: hidden; }
  .spec-monitor-inner {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .spec-monitor-glyph { line-height: 1; color: #111; font-weight: 400; letter-spacing: 0; white-space: pre; }
  .spec-baseline-mark { display: inline-block; width: 0; height: 0; vertical-align: baseline; }
  .spec-guide {
    position: absolute;
    left: 0; right: 0;
    display: flex;
    justify-content: space-between;
    padding: 2px 8px 0;
    border-top: 1px solid #e4e4e4;
    color: #8d8d8d;
  }
  .spec-guide-base { border-top-color: #8d8d8d; }
  .spec-glyph-meta { display: flex; gap: 12px; margin-top: 8px; color: #8d8d8d; }
  .spec-glyph-name { color: #111; }
  .spec-glyph-set { margin-left: auto; text-align: right; }

  /* the grid itself: square hairline cells, set in the font */
  .cs-glyphs {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
    border-top: 1px solid #e4e4e4;
    border-left: 1px solid #e4e4e4;
  }
  .cs-glyph {
    -webkit-appearance: none;
    appearance: none;
    aspect-ratio: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    background: none;
    border: 0;
    border-right: 1px solid #e4e4e4;
    border-bottom: 1px solid #e4e4e4;
    font-family: inherit;
    font-size: 28px;
    font-weight: 400;
    line-height: 1;
    letter-spacing: 0;
    color: #111;
    cursor: pointer;
  }
  .cs-glyph:hover { background: rgb(240, 240, 242); }
  .cs-glyph.sel { background: #111; color: var(--bg); }
  /* multi-glyph cells (ligatures, fractions, ordinal pairs) */
  .cs-glyph-wide { font-size: 18px; }
  .cs-glyph-section { margin-top: 2.5em; }
  .cs-glyph-section:first-child { margin-top: 0; }
  .cs-glyph-label { text-transform: uppercase; color: #8d8d8d; margin-bottom: 8px; }

  /* OpenType features: rows on hairlines; the sample is two stacked
     layers (feature on / off) that cross-fade with a slight blur — the
     same softness as the rest of the site's motion */
  .spec-feature { border-top: 1px solid #e4e4e4; padding: 8px 0 24px; cursor: pointer; }
  .spec-feature-head { display: flex; gap: 12px; margin-bottom: 8px; color: #8d8d8d; }
  .spec-feature-head .spec-feature-name { color: #111; }
  .spec-feature-state { margin-left: auto; }
  .spec-feature-sample {
    display: grid;
    line-height: 1.1;
    font-weight: 400;
    letter-spacing: 0;
    overflow-wrap: anywhere;
  }
  .spec-feature-layer {
    grid-area: 1 / 1;
    color: #bdbdbd;
    transition: opacity 350ms ease, filter 350ms ease;
  }
  .spec-hit { color: #111; }
  .spec-feature-layer.is-off,
  .spec-feature.show-off .spec-feature-layer.is-on { opacity: 0; filter: blur(4px); }
  .spec-feature.show-off .spec-feature-layer.is-off { opacity: 1; filter: none; }

  /* weights: one line per style, name + live axis value at the right */
  .spec-weight {
    display: flex;
    align-items: baseline;
    gap: 16px;
    border-top: 1px solid #e4e4e4;
  }
  .spec-weight-text {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    white-space: pre;
    line-height: 1.1;
    color: #111;
    font-weight: 400;
    letter-spacing: 0;
  }
  .spec-weight-label {
    flex: none;
    display: flex;
    justify-content: space-between;
    gap: 8px;
    width: 10em;
    color: #8d8d8d;
  }
  .spec-weight-val { color: #111; }
  @media (max-width: 700px) {
    .spec-weight { flex-direction: column; gap: 0; }
    .spec-weight-text { width: 100%; }
    .spec-weight-label { width: 100%; }
  }

  /* posters: container units (cqw) so a composition scales as one
     piece; the panel is the timeline cards' grey, or inverted. Running
     text keeps a 11px floor so phones can still read it. */
  .spec-poster {
    container-type: inline-size;
    display: grid;
    gap: 3cqw;
    padding: 4cqw;
    background: rgba(235, 235, 238, 0.7);
    color: #111;
    font-weight: 400;
    letter-spacing: 0;
    overflow: hidden;
  }
  .spec-poster-invert { background: #111; color: var(--bg); }
  .spec-poster-invert [contenteditable] { caret-color: var(--bg); }
  .spec-p { white-space: pre-line; overflow-wrap: anywhere; }
  .spec-p-body { font-size: clamp(11px, 1.6cqw, 32px); line-height: 1.35; }
  .spec-p-sub { font-size: 4.5cqw; line-height: 1.05; }
  .spec-poster-aa-paragraph { grid-template-columns: 1.1fr 1fr; align-items: end; }
  .spec-p-giant { grid-row: 1 / span 2; font-size: 36cqw; line-height: 0.8; }
  .spec-p-head { font-size: 5cqw; line-height: 1; align-self: start; }
  .spec-p-hero { font-size: 12cqw; line-height: 0.92; }
  .spec-p-cols { columns: 2; column-gap: 3cqw; }
  .spec-p-figs { font-size: 13cqw; line-height: 1; }
  .spec-p-alpha { font-size: 7.4cqw; line-height: 1.05; }
  @media (max-width: 700px) {
    .spec-p-cols { columns: 1; }
  }

  @media (max-width: 700px) {
    .spec-glyphs { grid-template-columns: 1fr; gap: 16px; }
    .spec-monitor { height: 220px; }
    .cs-glyphs { grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); }
    .cs-glyph { font-size: 22px; }
    .cs-glyph-wide { font-size: 15px; }
  }

  /* optional description under a block: text centered (per Maciek
     2026-07-14), index pinned to the right edge, 4px under the media
     like the homepage captions (both halved from 8px, 2026-09-29) */
  .cs-caption {
    position: relative;
    text-align: center;
    margin-top: 4px;
    /* the index is absolutely positioned, so an index-only caption
       (no description) needs its own line of height */
    min-height: 16px;
  }
  .cs-cap-text {
    /* TEST (test/sans-global): sans instead of serif — see .nav-reveal */
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
    color: #fff;
    mix-blend-mode: difference;
    font-size: 12px;
    letter-spacing: 0.01em;
    /* room for the index on either side so a long description can't
       run under it (kept symmetric so the text stays truly centered) */
    margin: 0 4em;
  }
  .cs-cap-index {
    position: absolute;
    right: 0;
    top: 0;
    font-family: 'Libertinus Mono', ui-monospace, 'SF Mono', Menlo, 'Courier New', monospace;
    font-weight: 400;
    letter-spacing: 0;
  }

  /* team credits close the page: centered column, sans label over
     serif names, same 12/13px scale as the about credits */
  .cs-credits {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 24px;
    text-align: center;
  }
  /* 4px label→content gap, the same rhythm as the timeline cards —
     8px read too airy (Maciek 2026-07-14) */
  .cs-credit-group { display: flex; flex-direction: column; gap: 4px; }
  .cs-credit-label { text-transform: uppercase; }
  .cs-credit-names {
    /* TEST (test/sans-global): sans instead of serif — see .nav-reveal */
    font-family: 'Oracle', -apple-system, "Helvetica Neue", Arial, sans-serif;
    font-weight: 500;
    color: #fff;
    mix-blend-mode: difference;
    font-size: 12px;
    letter-spacing: 0.01em;
  }
