/* --- 0. Faces ------------------------------------------------------------
   NB, supplied by the studio. The vendor stripped the name table, so the family
   is named here. It ships as a single weight, so `font-synthesis: none` keeps the
   browser from faking 500 and 600 — a faux weight is exactly what the contract
   forbids. Where the system needs a heavier weight it falls through to Inter.
   ------------------------------------------------------------------------ */

@font-face {
  font-family: "NB";
  /* woff2 only. The woff fallback served browsers that predate 2016; none of
     them run the rest of this stylesheet, and in the single-file build the
     second format was 80KB of base64 nobody ever fetched. */
  src: url("../fonts/nb.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* ==========================================================================
   Street Studios — design system
   Calibrated against moffittmoffitt.com computed styles at 1440x900.
   See bar.md for the seven mechanisms this file implements.

   The rules that matter most, because they are the ones easy to drift from:
     - letter-spacing is `normal` everywhere, at every size
     - the ramp is 40 / 24 / 16, stepping to 22 for card titles below 1080,
       plus 12 for meta. There is no 13px size: the reference sets its labels
       in a weight NB does not ship, so the size alone cannot carry it
     - one family. There is no serif in this system
     - 50px radius belongs to badges and pills; 5px to cards and the filter chip
     - hovers 0.3s, transforms 0.5s, nothing over 0.9s
   ========================================================================== */

/* --- 1. Tokens ----------------------------------------------------------- */

:root {
  /* Palette — fully achromatic; colour lives only inside the work. The ink and
     the ground are inspirux.com's near-black and near-white (#101012 /
     #eeeeee), at the owner's request, in place of pure #000 / #fff; the greys
     between them are re-set against the new ground (--steel clears 4.5:1 on
     #eeeeee at 5.2:1). Their lime accent was tried in the footer and left out:
     the only colour on the site outside the films, it read as their brand. */
  --obsidian: #101012;
  --graphite: #55565b;
  --steel:    #636366;
  --ash:      #cbcbce;
  --fog:      #dddde0;
  --mist:     #e6e6e8;
  --bone:     #eeeeee;
  --warn:     #b00000;   /* form errors: 5.8:1 on the light ground */
  /* translucent whites, for chrome that sits over footage */
  --bone-60:  rgba(255, 255, 255, 0.6);
  --bone-70:  rgba(255, 255, 255, 0.7);
  --bone-80:  rgba(255, 255, 255, 0.8);
  --bone-35:  rgba(255, 255, 255, 0.35);

  /* Chrome that sits over footage never flips with the theme: film is film in
     both modes, so white stays white and the frame behind it stays black. */
  --on-film:   #ffffff;
  --film-back: #000000;

  /* Type. One family carries the whole page — NB, and one weight of it. */
  --sans:  "NB", "Inter", "Suisse Int'l", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;

  --fs-display: 40px;   /* section headings + hero caption; 50px above 1700 */
  --fs-card:    24px;   /* card titles + the intro statement */
  --fs-body:    16px;   /* body, card descriptions, feed titles */
  --fs-sub:     22px;   /* project titles — the reference's subheading token */
  --fs-meta:    12px;   /* dates, captions */

  --lh-display: 45px;
  --lh-card:    28px;
  --lh-body:    22.22px;
  --lh-sub:     35px;

  /* Rhythm — 4px base, 40px gutter as measured */
  --gap:      40px;
  --gap-sm:   20px;
  --card-gap: 15px;   /* image to caption on every card — owner set this by eye; the reference runs 50px at 1440 and 20px at 390 */
  --gutter:   40px;             /* measured: 40px page margin at 1440 */
  --band:     clamp(64px, 8vw, 120px);
  --row-gap:  140px;            /* vertical rhythm between project rows — the reference's own card margin-bottom at 1440 */

  /* Shape — 50px for badges, 5px for cards and the filter chip */
  --r-badge: 50px;
  --r-card:  5px;

  --hairline: 1px solid var(--fog);

  /* Motion — two curves, three durations. Nothing exceeds --t-big. */
  --t-fast: 0.3s;
  /* Colour changes take longer than fades. Measured on the reference: seven
     elements at `color 0.5s` against four at `opacity 0.3s`. A tint crossing
     in 0.3s reads as a flicker; at 0.5s it reads as the element answering. */
  --t-tint: 0.5s;
  --t-move: 0.5s;
  --t-big:  0.9s;
  --e-move: cubic-bezier(0.4, 0, 0.2, 1);
}

/* Dark mode. The palette is achromatic, so it inverts cleanly: the ink and the
   canvas swap, and the four greys between them run the other way. Three states —
   an explicit choice wins over the OS in both directions, and the un-stamped
   default follows the OS. */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --obsidian: #eeeeee;
    --graphite: #a8a9ae;
    --steel:    #8a8b90;
    --ash:      #33343a;
    --fog:      #232326;
    --mist:     #18181b;
    --bone:     #101012;
    --warn:     #ff6b6b;
  }
}

:root[data-theme="dark"] {
  --obsidian: #eeeeee;
  --graphite: #a8a9ae;
  --steel:    #8a8b90;
  --ash:      #33343a;
  --fog:      #232326;
  --mist:     #18181b;
  --bone:     #101012;
  --warn:     #ff6b6b;   /* 6.9:1 on the dark ground; #b00 was 2.8:1 */
}

/* --- 2. Reset ------------------------------------------------------------ */

*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  overflow-x: clip;
}

[id] { scroll-margin-top: 96px; }

body {
  margin: 0;
  font-synthesis: none;
  background: var(--bone);
  color: var(--obsidian);
  font-family: var(--sans);
  font-size: var(--fs-body);
  font-weight: 400;
  line-height: var(--lh-body);
  letter-spacing: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body.is-locked { overflow: hidden; }

h1, h2, h3, h4, p, figure, blockquote, dd { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
img, video { display: block; max-width: 100%; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }

::selection { background: var(--obsidian); color: var(--bone); }
:focus-visible { outline: 1px solid var(--obsidian); outline-offset: 3px; }

/* --- 3. Type ------------------------------------------------------------- */

/* Mechanism 1: no tracking anywhere. Mechanism 2: three sizes. */

.h-section {
  font-size: var(--fs-display);
  font-weight: 600;
  line-height: var(--lh-display);
  letter-spacing: normal;
}

/* The intro statement is the card size, not a display size. */
.statement {
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
}

.body { font-size: var(--fs-body); line-height: var(--lh-body); }
.muted { color: var(--steel); }

.meta {
  font-size: var(--fs-meta);
  font-weight: 400;
  line-height: 16px;
  letter-spacing: normal;
  color: var(--steel);
}

/* Project titles: 22/600/35, measured off the reference's project page. */
.h-sub {
  font-size: var(--fs-sub);
  font-weight: 600;
  line-height: var(--lh-sub);
  letter-spacing: normal;
}

/* --- 4. Pills and badges ------------------------------------------------
   Mechanism 4: 50px radius belongs to this component only.

   The reference sets every pill in 13px at a hairline weight, in a 25px box.
   We set ours at body size in a 32px box, because NB has one cut and a 13px
   label at weight 400 is a different object. The consequence is real and
   visible: our pills carry more weight against a card than the reference's do.
   ------------------------------------------------------------------------ */

.badge {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding: 0 16px;
  border-radius: var(--r-badge);
  border: 1px solid currentColor;
  font-family: var(--sans);
  font-size: var(--fs-body);
  font-weight: 400;
  line-height: 1;
  letter-spacing: normal;
  white-space: nowrap;
  transition: background var(--t-fast) ease, color var(--t-tint) ease;
}

/* filled — the primary action, and the intro links */
.badge--solid {
  background: var(--obsidian);
  border-color: var(--obsidian);
  color: var(--bone);
}
.badge--solid:hover { background: var(--graphite); border-color: var(--graphite); }

/* outline on light ground */
.badge--line { border-color: var(--ash); color: var(--obsidian); }
.badge--line:hover { border-color: var(--obsidian); }

/* Over imagery. The reference keeps these black, which works because its stills
   are mid-tone; against real footage that runs dark the text disappears. A fixed
   translucent white plate keeps the reference's black label legible on anything,
   and does not flip with the theme because photography does not either. */
.badge--over {
  /* A label sitting on artwork, not a control: it runs smaller than the pills
     that are there to be pressed. The reference's own category badge is 13px on
     a 25px box; this is 12px on 24px, the same proportion on our scale.
     Outlined, not plated — the reference draws this pill as a stroke on the
     photograph. */
  height: 24px;
  padding: 0 12px;
  font-size: var(--fs-meta);
  line-height: 16px;
  background: transparent;
  border-color: var(--bone-60);
  color: var(--on-film);
  transition: opacity var(--t-fast) ease, transform var(--t-fast) ease;
}
/* The label steps aside while the card is hovered and returns when the pointer
   leaves, as the reference's does: it fades and lifts 4px over 0.3s. The image
   itself does not fade — only the pill. */
.card:hover .badge--over { opacity: 0; transform: translateY(-4px); }


/* toggled filter option */
.badge[aria-pressed="true"] {
  background: var(--obsidian);
  border-color: var(--obsidian);
  color: var(--bone);
}

/* The filter control is a 5px chip on mist — deliberately not a pill. */
.chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 14px;
  border-radius: var(--r-card);
  background: var(--mist);
  font-size: var(--fs-meta);
  line-height: 16px;
  letter-spacing: normal;
  color: var(--obsidian);
  transition: background var(--t-fast) ease;
}
.chip:hover { background: var(--fog); }
.chip svg { transition: transform var(--t-move) var(--e-move); }
.chip[aria-expanded="true"] svg { transform: rotate(45deg); }

.icon-btn {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--ash);
  display: grid;
  place-items: center;
  transition: background var(--t-fast) ease, border-color var(--t-tint) ease, color var(--t-tint) ease;
}
.icon-btn:hover { background: var(--obsidian); border-color: var(--obsidian); color: var(--bone); }
/* Text link with a wiping underline — 0.3s, per mechanism 5. */
.ulink {
  position: relative;
  display: inline-block;
  font-size: var(--fs-body);
  line-height: var(--lh-body);   /* never inherit leading from a .meta parent */
  letter-spacing: normal;
  padding-bottom: 1px;
  justify-self: start;
}
.ulink::after {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  width: 100%; height: 1px;
  background: currentColor;
  transform: scaleX(1);
  transform-origin: right;
  transition: transform var(--t-fast) ease;
}
.ulink:hover::after { animation: wipe var(--t-move) var(--e-move); }
@keyframes wipe {
  0%   { transform: scaleX(1); transform-origin: right; }
  45%  { transform: scaleX(0); transform-origin: right; }
  55%  { transform: scaleX(0); transform-origin: left; }
  100% { transform: scaleX(1); transform-origin: left; }
}

/* --- 5. Layout ----------------------------------------------------------- */

.shell { padding-inline: var(--gutter); }

.band { padding-block: var(--band); }
/* Section heading flush left, control on the right of the same row. */
.sec-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--gap-sm);
  margin-bottom: var(--gap);
}

/* 12-column grid, as the reference uses. */
.grid12 {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
  row-gap: var(--gap-sm);
}
.col-6 { grid-column: span 6; }
.start-7 { grid-column: 7 / span 6; }

.row-links {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: var(--gap-sm);
}

/* Facts in the intro's right half, so the column is composed rather than void. */
.intro-stats { grid-column: 9 / span 4; align-self: start; }
/* Number and label read as one pair: the rule carries the column width,
   the content stays left so the two never detach across the measure. The
   rule above the first pair is left out, at the owner's request. */
.stat-line {
  display: flex;
  align-items: baseline;
  gap: 16px;
  padding-block: 14px;
  border-top: var(--hairline);
}
.stat-line:first-child { padding-top: 0; border-top: 0; }   /* registers with the paragraph's first baseline */
.stat-line b {
  min-width: 72px;
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
  font-variant-numeric: tabular-nums;
}
.stat-line span {
  font-size: var(--fs-meta);
  line-height: 16px;
  color: var(--steel);
}

/* --- 6. Media placeholders ----------------------------------------------
   Stand-ins for film stills. Swap the .media block for <img>/<video>.
   ------------------------------------------------------------------------ */

.media {
  position: relative;
  overflow: hidden;
  background: var(--mist);
  border-radius: var(--r-card);
  isolation: isolate;
}

.media::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -2;
  background:
    radial-gradient(112% 84% at var(--fx, 30%) var(--fy, 20%), rgba(255,255,255,.30), transparent 46%),
    radial-gradient(135% 105% at 50% 48%, transparent 42%, rgba(0,0,0,.26) 100%),
    linear-gradient(var(--fa, 145deg), var(--t1, #d6d7d9) 0%, var(--t2, #b9babd) 46%, var(--t3, #8e9095) 100%);
}

.media::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  opacity: .78;
  mix-blend-mode: multiply;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.62' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
}

.media--3x2 { aspect-ratio: 3 / 2; }        /* 653x440 on the reference */
.media--6x7 { aspect-ratio: 6 / 7; }        /* 330x385 feed card on the reference */
.media--16x10 { aspect-ratio: 16 / 10; }    /* 1360x850 project image */
.media--5x3 { aspect-ratio: 5 / 3; }        /* 893x536 service image */        /* 330x385 feed card on the reference */
.media--4x5 { aspect-ratio: 4 / 5; }
.media--flush { border-radius: 0; }

.t-1 { --t1:#e4e5e6; --t2:#c2c3c6; --t3:#96979b; --fa:155deg; --fx:22%; --fy:16%; }
.t-2 { --t1:#cfd0d2; --t2:#a4a5a9; --t3:#6d6f74; --fa:120deg; --fx:70%; --fy:24%; }
.t-3 { --t1:#dedfe0; --t2:#b0b1b4; --t3:#7c7e83; --fa:200deg; --fx:40%; --fy:70%; }
.t-4 { --t1:#c4c5c8; --t2:#8f9095; --t3:#54565b; --fa:165deg; --fx:60%; --fy:12%; }
.t-5 { --t1:#eaeaeb; --t2:#cbccce; --t3:#9fa0a4; --fa:135deg; --fx:18%; --fy:60%; }
.t-6 { --t1:#b8b9bc; --t2:#87888d; --t3:#3f4145; --fa:190deg; --fx:75%; --fy:70%; }
.t-7 { --t1:#d2d3d5; --t2:#9c9da1; --t3:#616368; --fa:110deg; --fx:35%; --fy:35%; }
.t-8 { --t1:#e0e1e2; --t2:#bcbdc0; --t3:#8a8c90; --fa:175deg; --fx:80%; --fy:40%; }
.t-9 { --t1:#c9cacd; --t2:#95969b; --t3:#5b5d62; --fa:145deg; --fx:50%; --fy:80%; }

.t-h1 { --t1:#8a8c90; --t2:#5c5e63; --t3:#2b2d31; --fa:160deg; --fx:28%; --fy:14%; }
.t-h2 { --t1:#9c9ea2; --t2:#68696e; --t3:#333539; --fa:125deg; --fx:66%; --fy:22%; }
.t-h3 { --t1:#7e8085; --t2:#525459; --t3:#232528; --fa:195deg; --fx:44%; --fy:66%; }
.t-h4 { --t1:#93959a; --t2:#616368; --t3:#2f3134; --fa:140deg; --fx:74%; --fy:38%; }
.t-h5 { --t1:#86888d; --t2:#57595e; --t3:#26282b; --fa:170deg; --fx:20%; --fy:56%; }


/* --- 7. Masthead --------------------------------------------------------- */

.masthead {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 20px var(--gutter);
  color: var(--on-film);
  transition: color var(--t-tint) ease,
              transform var(--t-move) var(--e-move),
              background var(--t-fast) ease,
              backdrop-filter var(--t-fast) ease;
}

/* Past the hero the bar stops sitting on a picture and starts sitting on the
   page, where white type on a light ground would vanish. It takes the ink
   colour and a blurred, translucent ground so the content reads through it
   without the bar becoming a solid slab. */
.masthead.is-past {
  /* the plain value first, for anything without color-mix */
  background: rgba(12, 12, 13, .72);
  background: color-mix(in srgb, var(--bone) 72%, transparent);
  backdrop-filter: blur(14px) saturate(1.3);
  -webkit-backdrop-filter: blur(14px) saturate(1.3);
}

/* The `body:not(.is-locked)` is not decoration. Without it this selector is
   three classes deep and beats the two-class rule further down that clears the
   plate while the drawer is open, so the bar kept a white band across the top
   of the drawer — in light mode only, because the dark plate above is one class
   shallower and loses to it cleanly. Guard the condition here rather than
   escalating specificity there. */
:root[data-theme="light"] body:not(.is-locked) .masthead.is-past { background: rgba(255, 255, 255, .72); }
:root[data-theme="light"] body:not(.is-locked) .masthead.is-past { background: color-mix(in srgb, var(--bone) 72%, transparent); }

.masthead.is-tucked { transform: translateY(-100%); }

@media (prefers-reduced-motion: reduce) {
  /* Sliding furniture is the first thing to go, but the bar must not simply
     vanish and reappear — it stays put instead. */
  .masthead { transition: color var(--t-tint) ease; }
  .masthead.is-tucked { transform: none; }
}
/* set once the hero has scrolled past, so the wordmark stays legible on white */
.masthead.is-dark { color: var(--obsidian); }

/* Wordmark, not a monogram — an unfamiliar studio has to name itself. */
/* centred by the box, not by line-height, so leading stays on the body token */
.mark {
  display: inline-flex;
  align-items: center;
  height: 38px;
  /* Back on the scale at the card size: 20% up from 20px lands exactly on
     --fs-card, so the wordmark no longer needs an exception of its own. */
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
  transition: opacity var(--t-fast) ease;
}
.mark:hover { opacity: .7; }
/* The logo, drawn from the brand file's outlines. currentColor keeps it the
   masthead's ink: white on the dark theme, dark on the light one. */
.mark-logo { display: block; height: 24px; width: auto; }

/* Measured off the reference: a 37x30 box holding two bars, not three — each
   the full 37px wide and 3px thick, set 9px apart (bar tops at 9px and 21px). */
.burger { position: relative; width: 37px; height: 30px; }
.burger span {
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 3px;
  background: currentColor;
  /* the reference's own morph: 0.6s on an ease-out cubic */
  transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1),
              background-color var(--t-tint) ease;
}
.burger span:nth-child(1) { transform: translateY(9px); }
.burger span:nth-child(2) { transform: translateY(21px); }

/* Open: both bars travel to the same line and cross, exactly as the reference
   does it — translateY(15px) with a ∓45° rotation. One control, two states,
   rather than a burger and a separate X. */
body.is-locked .burger span:nth-child(1) { transform: translateY(15px) rotate(-45deg); }
body.is-locked .burger span:nth-child(2) { transform: translateY(15px) rotate(45deg); }
/* over the drawer's ground, which inverts with the theme */
body.is-locked .burger span { background: var(--bone); }
/* The drawer has its own close control, so the masthead tools simply step out
   of the way rather than morphing into a second one. */
/* The burger stays put and becomes the cross; only the theme toggle steps aside,
   since it has nothing to say while the drawer is open. */
body.is-locked .theme-toggle {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-fast) ease;
}
/* The wordmark stays visible over the open drawer, as the reference's logo does.
   That needs the header above the drawer, so it gives up pointer events while
   locked — otherwise it swallows clicks meant for the drawer's close control. */
body.is-locked .masthead {
  z-index: 70;
  pointer-events: none;
}
body.is-locked .mark,
body.is-locked .burger { pointer-events: auto; }

/* The drawer inverts the page: --obsidian ground, --bone type. The wordmark
   and the close control sit over it but take their colour from the masthead,
   so on the dark site they stayed white and vanished into a light drawer.
   While it is open they belong to the drawer, not to the page behind it. */
body.is-locked .mark,
body.is-locked .burger { color: var(--bone); }

/* While the drawer is open the bar has nothing to sit on: the drawer is its
   own ground, and the blurred plate from `is-past` painted a dark band across
   the top of it. Clear the ground and the tuck — the bar belongs to the drawer
   until it closes. */
body.is-locked .masthead {
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  transform: none;
}

/* --- 8. Menu -------------------------------------------------------------
   A drawer, not a full-screen overlay: 50vw pinned to the right edge, sliding
   in on the --t-big token. Measured off the reference at 1440 — panel 720px
   wide at x=720, black ground, items 40/600 at 40px leading.
   ------------------------------------------------------------------------ */

.menu {
  position: fixed;
  inset: 0 0 0 auto;
  /* above the masthead (60), not below it: the close control sits at the
     burger's coordinates, which are inside the masthead's band, and a header
     painting over the drawer swallows every click on the X. */
  z-index: 65;
  width: 50vw;
  /* Items start at 34px, as the reference's do. The close control does not sit
     in the flow above them — it overlays at the burger's exact coordinates, so
     the X reads as the burger toggling rather than as a second control. */
  /* --gutter, not --gap: the drawer's items should stand on the same left
     edge as the page's content behind it. On desktop the two are both 40px
     and nothing moves; on a phone the gutter is 24 and the items were sitting
     16px further in than everything else on the site. */
  padding: 34px var(--gutter) var(--gutter);
  background: var(--obsidian);
  color: var(--bone);
  display: grid;
  grid-template-rows: 1fr auto;
  transform: translateX(100%);
  transition: transform var(--t-big) var(--e-move);
}
.menu.is-open { transform: none; }


.menu-list { align-self: start; }

/* The reference runs its drawer items at 40/600 on a 50px pitch. NB has no bold
   cut, so 600 renders at 400 and 40px reads noticeably lighter than the
   reference does; the items take 48px here to carry the same weight on the page,
   on the reference's 1.25 pitch ratio. Restore 40px once the Bold cut lands. */
.menu-list li { padding-bottom: 12px; }
.menu-list a {
  display: block;
  width: max-content;
  /* 48/48, not the reference's 40/40. NB ships one cut at 400, and 40px at
     book weight reads too light to hold the panel — the size is carrying what
     a heavier cut would. It drops to 40 the day a Medium or Bold exists. */
  font-size: 48px;
  font-weight: 600;
  line-height: 48px;
  letter-spacing: normal;
  color: var(--bone);
  transition: transform var(--t-fast) ease, color var(--t-tint) ease;
}
/* Measured off the reference's own recording: hovering a row slides it 16px
   right over 0.3s while every other row drops to grey — rgb(128,128,128) in
   the capture, which is --steel. A dim, not a fade: opacity would land near
   #d9d9d9 on this ground and read far too bright. */
.menu-list:hover a { color: var(--steel); }
.menu-list a:hover { color: var(--bone); transform: translateX(16px); }

/* the reference closes the drawer with two labelled contact columns */
.menu-foot {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap-sm);
  /* start, not end: the two blocks hold different numbers of lines, so
     aligning their bottoms left the labels on visibly different baselines. */
  align-items: start;
}
.menu-foot .menu-label {
  font-size: var(--fs-meta);
  font-weight: 400;
  line-height: 16px;
  letter-spacing: normal;
  color: var(--bone);
  margin-bottom: 8px;
}
.menu-foot p, .menu-foot a {
  font-size: var(--fs-meta);
  line-height: 16px;
  color: var(--bone);
}
.menu-foot a:hover { opacity: .6; }

@media (max-width: 860px) {
  /* full width, so the rows start below the masthead instead of colliding with
     the wordmark, and the close control follows the narrow gutter to stay on
     top of the burger */
  .menu { width: 100vw; padding-top: 88px; }
}

/* --- 9. Hero ------------------------------------------------------------- */

.hero {
  position: relative;
  height: 100svh;
  min-height: 560px;
  overflow: hidden;
  background: var(--film-back);
}

.slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity var(--t-big) var(--e-move);
}
.slide.is-active { opacity: 1; }
.slide > .media { position: absolute; inset: 0; border-radius: 0; }

.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(to top, rgba(0,0,0,.5) 0%, rgba(0,0,0,.10) 40%, rgba(0,0,0,.16) 100%);
}

/* The claim runs the full width on its own row, so it is never squeezed by
   the controls; below it, the count left and the arrow right. (An earlier
   flex version and a grid version both lived here, the second silently
   replacing the first.) */
.hero-bar {
  position: absolute;
  z-index: 2;
  left: var(--gutter);
  right: var(--gutter);
  bottom: clamp(28px, 4vw, 44px);
  display: grid;
  grid-template-areas:
    "caption caption"
    "count   next";
  grid-template-columns: 1fr auto;
  align-items: end;
  row-gap: var(--gap);
  column-gap: var(--gap);
  color: var(--on-film);
}

/* The studio's own claim, at section-heading size — mechanism 2.
   It is constant; only the credit beneath it changes with the slide. */
.hero-title {
  display: block;
  font-size: var(--fs-display);
  font-weight: 600;
  line-height: var(--lh-display);
  letter-spacing: normal;
}



/* a bare arrow, not a bordered circle; it steps right on hover as theirs does */
.hero-next {
  justify-self: end;
  align-self: end;
  display: block;
  width: 48px;
  height: 40px;
  color: var(--on-film);
  transition: transform var(--t-fast) ease, opacity var(--t-fast) ease;
}
.hero-next svg { display: block; width: 100%; height: 100%; }
.hero-next:hover { transform: translateX(6px); }

/* --- 10. Feed ------------------------------------------------------------ */

.feed-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--band) var(--gap);
}

.feed-card { display: block; position: relative; }
.feed-card .frame { position: relative; overflow: hidden; border-radius: var(--r-card); }
.feed-card .media { transition: transform var(--t-move) var(--e-move); }
.feed-card:hover .media { transform: scale(1.03); }

/* hairline centred in the 40px gutter, running the full height of the card */
.feed-card::before {
  content: "";
  position: absolute;
  left: calc(var(--gap) / -2);
  top: 0; bottom: 0;
  width: 1px;
  background: var(--fog);
}
.feed-grid > .feed-card:nth-child(4n + 1)::before { display: none; }

.frame .badge {
  position: absolute;
  top: 18px; left: 18px;
  z-index: 1;
}

.feed-card time { display: block; margin-top: 18px; }
.feed-card h3 {
  margin-top: 2px;
  font-size: var(--fs-body);
  font-weight: 600;
  line-height: var(--lh-body);
  letter-spacing: normal;
}
.feed-card p {
  margin-top: 20px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--steel);
}
.feed-card .ulink { margin-top: 20px; }

/* Mechanism 6: sibling dim is subtle. */
.dimmable:hover > * { opacity: .85; transition: opacity var(--t-fast) ease; }
.dimmable:hover > *:hover { opacity: 1; }

/* --- 11. Services -------------------------------------------------------- */

/* padded, not leaded, so the counter aligns to the service name
   without borrowing the card token's line-height */
/* --- 12. Projects -------------------------------------------------------- */

/* Mechanism 7: two columns, 40px gutter. */
.grid-work {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--row-gap) var(--gap);
}

.card { display: block; }
.card .frame { position: relative; overflow: hidden; border-radius: var(--r-card); }
.card .media { transition: transform var(--t-move) var(--e-move); }
.card:hover .media { transform: scale(1.03); }

.card-title {
  margin-top: var(--card-gap);
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
}
.card-desc {
  margin-top: 2px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--steel);
}

.card.is-hidden,
.vent-card.is-hidden { display: none; }

/* Full-width feature card. The reference breaks its two-column rhythm with a
   1360x880 (17:11) card spanning both columns, then returns to pairs. */
.card--wide { grid-column: 1 / -1; }
.media--wide { aspect-ratio: 17 / 11; }

/* The reference caps image height rather than letting the ratio run on:
   max-height 600px on a standard card, 880px on the feature. A ratio alone
   keeps growing with the viewport — the feature reaches 1190px at 1920 wide and
   swallows the screen. The 80vh term is ours: 880px is still the whole of a
   900px-tall laptop screen, so the cap also leaves air on short viewports.
   The image is absolutely positioned and object-fit: cover, so capping the box
   crops it centrally instead of squashing it. The explicit width matters: with
   aspect-ratio alone the cap shrinks the element's WIDTH to preserve the ratio,
   so the feature card stops spanning the grid. */
.card .media { width: 100%; max-height: 600px; }
.card--wide .media { width: 100%; max-height: min(880px, 80vh); }

/* The project page's stills take the same caps, so a gallery image is never
   taller than the card that led the reader to it: the paired 4:5 stills match a
   standard card, the full-measure and bleed figures match the feature. */
/* Every paired still takes the standard card's cap, wherever it appears — the
   project galleries, the profile page, the contact portraits — so an image is
   never taller than the card that led the reader to it. */
.duo .media { width: 100%; max-height: 600px; }
.proj-figs > figure .media { width: 100%; max-height: min(880px, 80vh); }

/* Filter drawer behind the chip */
.filter-drawer {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--t-move) var(--e-move);
}
.filter-drawer.is-open { grid-template-rows: 1fr; }
.filter-drawer > div { overflow: hidden; }
.filter-inner { padding-block: var(--gap-sm) var(--gap); }
.filter-group { padding-block: 14px; border-top: var(--hairline); }
.filter-group h3 {
  margin: 0 0 12px;
  font-size: var(--fs-meta);
  font-weight: 400;
  line-height: 16px;
  letter-spacing: normal;
  color: var(--steel);
}
.filter-set { display: flex; flex-wrap: wrap; gap: 6px; }

.result-note { margin-bottom: var(--gap); }

/* --- 13. Marquee --------------------------------------------------------- */

.marquee {
  overflow: hidden;
  border-block: var(--hairline);
  padding-block: 22px;
  position: relative;
}
.marquee::before,
.marquee::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  width: 8%;
  z-index: 2;
  pointer-events: none;
}
.marquee::before { left: 0;  background: linear-gradient(90deg, var(--bone), transparent); }
.marquee::after  { right: 0; background: linear-gradient(270deg, var(--bone), transparent); }
.marquee-track {
  display: flex;
  gap: var(--gap);
  width: max-content;
  animation: marquee 38s linear infinite;
}
.marquee:hover .marquee-track { animation-play-state: paused; }
.marquee-track span {
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
  white-space: nowrap;
}
@keyframes marquee { to { transform: translateX(-50%); } }

/* --- 14. Quotes ---------------------------------------------------------- */

.quote-card { border-top: var(--hairline); padding-top: 20px; }
.quote-card p {
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
}
.quote-card footer { margin-top: 16px; }

/* --- 15. FAQ ------------------------------------------------------------- */

.faq-item { border-bottom: var(--hairline); }
.faq-item:first-child { border-top: var(--hairline); }

.faq-q {
  width: 100%;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 40px;
  gap: var(--gap-sm);
  align-items: center;
  text-align: left;
  padding-block: 24px;
}
.faq-q h3 {
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
  transition: color var(--t-tint) ease;
}
.faq-q:hover h3 { color: var(--steel); }

.faq-sign { position: relative; justify-self: end; }
.faq-sign::before, .faq-sign::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  background: currentColor;
  transition: transform var(--t-move) var(--e-move), opacity var(--t-fast) ease;
}
.faq-sign::before { width: 12px; height: 1px; }
.faq-sign::after  { width: 1px; height: 12px; }
.faq-q[aria-expanded="true"] .faq-sign::after { transform: rotate(90deg); opacity: 0; }

.faq-a {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--t-move) var(--e-move);
}
.faq-q[aria-expanded="true"] + .faq-a { grid-template-rows: 1fr; }
.faq-a > div { overflow: hidden; }
.faq-a .inner {
  max-width: 62ch;
  padding-bottom: 28px;
  color: var(--steel);
}
.faq-a .inner > * + * { margin-top: 12px; }
.faq-a strong { color: var(--obsidian); font-weight: 600; }

/* --- 16. Forms / definition lists ---------------------------------------- */

.dl-row {
  display: grid;
  grid-template-columns: 160px minmax(0, 1fr);
  gap: var(--gap-sm);
  padding-block: 14px;
  border-top: var(--hairline);
}
.dl-row dt { color: var(--steel); }

/* --- 17. Footer ---------------------------------------------------------- */

/* No rule along the top edge: the foot runs in the opposite mode to the page
   (section 26), and the inverted ground is the separation. */
.footer { padding: var(--band) var(--gutter) 24px; }
/* Four equal columns: contact, visit, and two of navigation — Sidor and
   Tjänster, restored so every page carries internal links for crawlers. Below
   1080 they fold to two. */
/* The logo heads the footer. It takes the footer's ink: black on the white
   footer of the default theme, light on the dark footer of the light one. */
.footer-logo { display: inline-block; margin-bottom: var(--gap); color: inherit; }
.footer-logo svg { display: block; height: 40px; width: auto; }
.footer-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--gap) var(--gap);
}
/* The supplied logo file is the word set in Brockmann Bold — a face this site
   no longer uses, and could not render at Bold even if it did. It is live text in
   the SVG, not outlines, so the file itself cannot be used as an image — an
   <img> cannot reach our web font and would fall back to a system face, and its
   fill is hardcoded white, which disappears on the light ground. Rendered as
   real text it stays selectable, themeable and sharp; the file's contribution
   is the weight. */
.wordmark {
  font-size: var(--fs-display);
  font-weight: 600;
  line-height: var(--lh-display);
  letter-spacing: normal;
}
.footer h3 {
  margin-bottom: 12px;
  font-size: var(--fs-meta);
  font-weight: 400;
  line-height: 16px;
  letter-spacing: normal;
  color: var(--steel);
}
.footer li + li { margin-top: 4px; }
.footer li a { transition: color var(--t-tint) ease; }
.footer li a:hover { color: var(--steel); }

.footer-base {
  margin-top: var(--band);
  padding-top: 18px;
  border-top: var(--hairline);
  display: flex;
  flex-wrap: wrap;
  gap: 8px var(--gap);
  justify-content: space-between;
}

/* --- 18. Case study ------------------------------------------------------ */

.page-hero { padding-top: clamp(110px, 13vw, 170px); }
.page-body > * + * { margin-top: 18px; }
.page-body { max-width: 62ch; }
/* Questions on the city pages sit under a band-head; the browser's h3 default
   (1.17em) would make them larger than the heading above them. */
.page-body h3 { font-size: var(--fs-body); line-height: var(--lh-body); color: var(--steel); }

.duo { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--gap); }

/* --- 19. Reveal ----------------------------------------------------------
   Mechanism 5: 0.4s, 20px of travel. Stagger is 60ms, capped at three steps.
   ------------------------------------------------------------------------ */

.js .reveal { opacity: 0; transform: translateY(20px); }
.js .reveal.is-in {
  opacity: 1;
  transform: none;
  transition: opacity 0.4s ease, transform 0.4s var(--e-move);
}
.js .reveal.d1.is-in { transition-delay: 0.06s; }
.js .reveal.d2.is-in { transition-delay: 0.12s; }
.js .reveal.d3.is-in { transition-delay: 0.18s; }

/* --- 20. Responsive ------------------------------------------------------ */

@media (max-width: 1080px) {
  /* The reference steps the card token and the project rhythm together before
     the grid ever collapses: 24/28 becomes 22/25 and the 140px row gap becomes
     80px. 22px is a size the scale already owns (--fs-sub), so this step costs
     no new size. */
  :root { --fs-card: 22px; --lh-card: 25px; --row-gap: 80px; }
  .feed-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .feed-grid > .feed-card:nth-child(4n + 1)::before { display: block; }
  .feed-grid > .feed-card:nth-child(odd)::before { display: none; }
}

/* The reference steps its whole display scale at 1700: h1/.h1 is 50/600 with a
   line-height of 1, dropping to 40px at or below 1700px. Matching the step means
   wide screens get the size the reference actually shows there. */
@media (min-width: 1701px) {
  :root { --fs-display: 50px; --lh-display: 50px; }
}

@media (max-width: 860px) {
  :root {
    /* --fs-card already stepped to 22/25 at 1080 and cannot differ here.
       Display steps to 35/45: two independent measurements of the reference at
       390 read 35px, not the 40px an earlier pass recorded. The leading stays
       45, so the step closes the size without changing the rhythm. */
    --fs-display: 35px;
    --band: 64px; --row-gap: 45px; --gutter: 24px;
  }
  /* collapse to one column — 12 tracks plus 11×40px gaps cannot fit a phone */
  .grid12 { grid-template-columns: minmax(0, 1fr); }
  .col-6, .start-7 { grid-column: auto; }
  .intro-stats { grid-column: auto; margin-top: var(--gap); }
  .grid-work { grid-template-columns: minmax(0, 1fr); gap: var(--row-gap) var(--gap); }
  .quotes, .duo { grid-template-columns: minmax(0, 1fr); }
  .footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--gap-sm) var(--gap); }
  .hero-bar { grid-template-columns: 1fr; row-gap: 20px; }
  .dl-row { grid-template-columns: minmax(0, 1fr); gap: 2px; }
}

@media (max-width: 600px) {
  .feed-grid { grid-template-columns: minmax(0, 1fr); }
  .feed-card::before { display: none !important; }
  .footer-grid { grid-template-columns: minmax(0, 1fr); }
  .sec-head { flex-wrap: wrap; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .js .reveal { opacity: 1; transform: none; }
  .marquee-track { animation: none; }
}


/* --- 21. Project page ----------------------------------------------------
   The reference sets its project pages in the RIGHT half of the grid: title,
   grey body copy, then the work-type and sector pills. Images run full width
   beneath, either 16:10 across the measure or paired 4:5 portraits.
   ------------------------------------------------------------------------ */

.proj-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-sm);
}

.proj-body { color: var(--steel); max-width: 56ch; }
.proj-body + .proj-body { margin-top: 20px; }

.proj-figs > * + * { margin-top: var(--gap); }

/* --- 22. Services page ---------------------------------------------------
   Three bands, measured off the reference at 1440: the name in cols 1-4 and
   sticky, the image across 5-12, then the item list across 5-12. The process
   cards that sat in 9-12 ("Så arbetar vi") were removed at the owner's request.
   See bar-services.md.
   ------------------------------------------------------------------------ */

.svc-block {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
  row-gap: var(--gap);
  align-items: start;
  padding-block: var(--band) 0;
}

/* the name holds while its own detail scrolls past */
.svc-head {
  grid-column: 1 / span 4;
  grid-row: 1 / span 3;
  position: sticky;
  top: 96px;
}
.svc-head .h-section { margin-bottom: 4px; }
.svc-tagline {
  font-size: var(--fs-body);
  font-weight: 600;
  line-height: var(--lh-body);
  letter-spacing: normal;
}

.svc-media { grid-column: 5 / -1; grid-row: 1; }
/* The services take the whole right-hand span now that the "Så arbetar vi"
   column is gone (the owner's call); the intro keeps a reading measure. */
.svc-services { grid-column: 5 / -1; grid-row: 2; }

.band-head {
  font-size: var(--fs-body);
  font-weight: 600;
  line-height: var(--lh-body);
  letter-spacing: normal;
  margin-bottom: var(--gap-sm);
}

.svc-intro { color: var(--steel); margin-bottom: var(--gap-sm); max-width: 62ch; }

/* hairline rows, each opening with an en dash — not pills, not prose */
/* Two columns, filled top to bottom: the first half of the offers under the
   intro, the rest beside it where the process cards used to stand (the
   owner's call) — so Filmproduktion's eight read four and four. --rows is set
   per list in the markup (half the items, rounded up). A column-gap of one
   grid gap puts the two halves exactly on columns 5-8 and 9-12, and the rows
   share heights, so the hairlines line up across. One column on a phone. */
.svc-items {
  border-top: var(--hairline);
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  grid-template-rows: repeat(var(--rows, 4), auto);
  grid-auto-flow: column;
  column-gap: var(--gap);
}
@media (max-width: 600px) {
  .svc-items {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
    grid-auto-flow: row;
  }
}
.svc-items li {
  padding-block: 12px;
  border-bottom: var(--hairline);
  /* Full contrast, not the muted grey. These are the things you can actually
     buy — they should read as a list of offers, not as supporting text.
     --obsidian, so it is black on the light ground and near-white on the dark
     one rather than literally black in both.

     Semibold cannot be honoured again: NB ships one cut at 400 and
     font-synthesis is off, so a font-weight here would render identically to
     400. The colour is what carries these now. */
  color: var(--obsidian);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

@media (max-width: 1080px) {
  /* Collapse the track, not just the spans: twelve columns keep eleven 40px
     gaps, so the grid cannot get narrower than 440px however the children
     span, and the page can be dragged sideways below that width. */
  .svc-block { grid-template-columns: minmax(0, 1fr); }
  .svc-head, .svc-media, .svc-services { grid-column: 1 / -1; }
  .svc-head { position: static; grid-row: auto; }
  .svc-media, .svc-services { grid-row: auto; }
}

/* --- 23. Video player ----------------------------------------------------
   The first card on a film project. Real <video> with custom controls, so it
   works the moment a file is dropped into cases.json; until then it shows the
   poster and says the clip is not attached rather than failing silently.
   ------------------------------------------------------------------------ */

.player {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--r-card);
  background: var(--film-back);
  isolation: isolate;
}

.player video,
.player .media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 0;
}

/* scrim so white controls hold over any footage */
.player::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 40%;
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(to top, rgba(0, 0, 0, .55), transparent);
  opacity: 0;
  transition: opacity var(--t-fast) ease;
}
.player:hover::after,
.player.is-playing::after { opacity: 1; }


/* Back by request. No ring — the glyph alone, so nothing is left hanging over
   the first second of the film when it fades out on play. The drop shadow is
   what keeps a white triangle legible over a bright frame. Clicking the picture
   still works; this is the affordance that says so. */
.player-play {
  position: absolute;
  inset: 0;
  margin: auto;
  z-index: 3;
  width: 72px;
  height: 72px;
  display: grid;
  place-items: center;
  color: var(--on-film);
  filter: drop-shadow(0 2px 12px rgba(0, 0, 0, .45));
  transition: transform var(--t-fast) ease, opacity var(--t-fast) ease;
}
.player-play:hover { transform: scale(1.08); }
.player.is-playing .player-play { opacity: 0; pointer-events: none; }

.player-bar {
  position: absolute;
  inset: auto 0 0 0;
  z-index: 4;
  display: flex;
  align-items: center;
  gap: var(--gap-sm);
  padding: 18px 20px;
  color: var(--on-film);
  opacity: 0;
  transition: opacity var(--t-fast) ease;
}
.player:hover .player-bar,
.player.is-playing .player-bar,
.player:focus-within .player-bar { opacity: 1; }

.player-bar button {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  color: var(--on-film);
  transition: opacity var(--t-fast) ease;
}
.player-bar button:hover { opacity: .7; }

/* One button, two glyphs — the sound control had a single icon, so pressing it
   muted the film with nothing on screen changing and read as a dead button. */
[data-mute] .snd-off { display: none; }
[data-mute][aria-pressed="true"] .snd-on { display: none; }
[data-mute][aria-pressed="true"] .snd-off { display: block; }

/* Same idea on the transport: the glyph has to say what the film is doing, not
   what the button is called. */
[data-toggle] .pse { display: none; }
.player.is-playing [data-toggle] .ply { display: none; }
.player.is-playing [data-toggle] .pse { display: block; }

/* With no button in the middle, the picture itself is the control. */
.player video { cursor: pointer; }

.player-time {
  font-size: var(--fs-meta);
  line-height: 16px;
  letter-spacing: normal;
  color: var(--on-film);
  font-variant-numeric: tabular-nums;
}

/* The track reads as a hairline but is not one to the pointer. At 1px it was
   a 1px target, which is unhittable in practice; the box is 16px and the line
   is drawn inside it, so the look is unchanged and the aim is forgiving. */
.player-scrub {
  flex: 1;
  height: 16px;
  position: relative;
  cursor: pointer;
  /* the rail owns the drag: without this a scrub on a phone scrolls the page */
  touch-action: none;
}
.player-scrub:focus-visible {
  outline: 2px solid var(--on-film);
  outline-offset: 4px;
}
.player-scrub::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 2px;
  margin-top: -1px;
  background: var(--bone-35);
  transition: height var(--t-fast) ease, margin-top var(--t-fast) ease;
}
.player-bar:hover .player-scrub::before { height: 4px; margin-top: -2px; }
.player-scrub span {
  position: absolute;
  left: 0;
  top: 50%;
  height: 2px;
  margin-top: -1px;
  width: 0;
  background: var(--on-film);
  transition: height var(--t-fast) ease, margin-top var(--t-fast) ease;
}
.player-bar:hover .player-scrub span { height: 4px; margin-top: -2px; }

/* shown when no file is attached yet */
.player-note {
  position: absolute;
  inset: auto 20px 64px 20px;
  z-index: 4;
  color: var(--on-film);
  font-size: var(--fs-meta);
  line-height: 16px;
  letter-spacing: normal;
}
.player-note a { text-decoration: underline; }

/* prev / next project */
.proj-nav {
  display: flex;
  justify-content: space-between;
  gap: var(--gap-sm);
  padding-top: var(--gap-sm);
  border-top: var(--hairline);
}

/* "Vad vi gjorde" reuses the definition row, with no term column */
/* The work list is one value per row, so it is a list, not a description
   grid — it only ever needed the rule and the rhythm. */
.proj-work li { padding-block: 14px; border-top: var(--hairline); }

/* --- 24. Theme toggle ----------------------------------------------------
   Inherits the masthead's colour, so it is white over the hero and ink once
   the page is on canvas. Two glyphs, one shown per state.
   ------------------------------------------------------------------------ */

.theme-toggle {
  width: 38px;
  height: 38px;
  display: grid;
  place-items: center;
  color: inherit;
  transition: opacity var(--t-fast) ease;
}
.theme-toggle:hover { opacity: .7; }
.theme-toggle svg { display: block; }
.theme-toggle .moon { display: none; }
:root[data-theme="dark"] .theme-toggle .moon { display: block; }
:root[data-theme="dark"] .theme-toggle .sun { display: none; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .moon { display: block; }
  :root:not([data-theme="light"]) .theme-toggle .sun { display: none; }
}

.masthead-tools { display: flex; align-items: center; gap: 8px; }

/* the placeholder stills are stand-ins for photography, which does not invert;
   in dark mode they simply sit a little lower so they do not glare */
:root[data-theme="dark"] .media { filter: brightness(.82); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .media { filter: brightness(.82); }
}

/* full-bleed still: breaks the gutter, as the reference does on project pages */
.bleed {
  margin-inline: calc(var(--gutter) * -1);
  border-radius: 0;
}
.bleed .media { border-radius: 0; }

/* --- 25. Real imagery ----------------------------------------------------
   The gradient placeholder stays behind as the loading state; a real photo
   covers it. Swapping one in means adding an <img> inside the .media box —
   no other change.
   ------------------------------------------------------------------------ */

.media > img,
.media > video {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
}

/* a real photo is already graded; do not dim it in dark mode */
:root[data-theme="dark"] .media:has(> img) { filter: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .media:has(> img) { filter: none; }
}

/* --- 26. Hero background film -------------------------------------------
   A muted, looping, self-hosted clip per slide. The still sits underneath as
   the poster, so the hero reads instantly and still reads when a slide has no
   film yet, when the file is slow, or under reduced-motion.

   Note: the studio's Vimeo videos are domain-locked to streetstudios.se, so an
   embed only plays from that origin. Self-hosted files play anywhere, which is
   why the hero uses them. `heroLoop` in cases.json keeps the Vimeo ids for a
   deploy on the live domain.
   ------------------------------------------------------------------------ */

.hero-film {
  position: absolute;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--t-big) var(--e-move);
}
.slide.is-active .hero-film.is-ready { opacity: 1; }

/* footage is graded already — never dim it with the theme */
:root[data-theme="dark"] .slide .media { filter: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .slide .media { filter: none; }
}

@media (prefers-reduced-motion: reduce) {
  .hero-film { display: none; }
}

/* --- 27. Fixed hero ------------------------------------------------------
   The hero holds while the page scrolls over it. Everything after it needs an
   opaque ground of its own, or the hero shows through.
   ------------------------------------------------------------------------ */

.hero {
  position: sticky;
  top: 0;
  z-index: 0;
}

.hero ~ * {
  position: relative;
  z-index: 1;
  background: var(--bone);
}

[hidden] { display: none !important; }

/* --- 28. Client logos ----------------------------------------------------
   Real marks, rendered as flat ink so the marquee stays achromatic like the
   rest of the chrome. brightness(0) flattens any colour to black regardless of
   the source artwork; dark mode inverts that to white.
   ------------------------------------------------------------------------ */

/* Height governs, so every mark reads at the same optical weight; a fixed box
   instead squashed the wide marks to slivers. */
.marquee-track a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 26px;
  padding-right: var(--gap);
  transition: opacity var(--t-fast) ease;
}
.marquee-track a:hover { opacity: .55; }

.client-logo {
  height: 26px;              /* the band is taller than the marks it holds */
  width: auto;
  max-width: 240px;
  object-fit: contain;
  filter: brightness(0);
  opacity: .85;
}

:root[data-theme="dark"] .client-logo { filter: brightness(0) invert(1); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .client-logo { filter: brightness(0) invert(1); }
}

/* clients with no mark we could source keep their name set in type */
.marquee-track .client-name {
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
  white-space: nowrap;
}

/* an action inside every service block, carrying which service it came from */
.svc-cta { margin-top: 28px; }

/* Groups that enter together get the ladder by position, so it cannot be lost
   when markup is edited. Explicit .d1/.d2/.d3 still win where they are set. */
.js .faq > .faq-item.reveal.is-in:nth-child(2)   { transition-delay: 0.06s; }
.js .faq > .faq-item.reveal.is-in:nth-child(3)   { transition-delay: 0.12s; }
.js .faq > .faq-item.reveal.is-in:nth-child(n+4) { transition-delay: 0.18s; }

.js .proj-figs > .reveal.is-in:nth-child(2)   { transition-delay: 0.06s; }
.js .proj-figs > .reveal.is-in:nth-child(3)   { transition-delay: 0.12s; }
.js .proj-figs > .reveal.is-in:nth-child(n+4) { transition-delay: 0.18s; }


/* Card grids enter as one group; ladder them by position like the FAQ. */
.js .grid-work > .reveal.is-in:nth-child(2)   { transition-delay: 0.06s; }
.js .grid-work > .reveal.is-in:nth-child(3)   { transition-delay: 0.12s; }
.js .grid-work > .reveal.is-in:nth-child(n+4) { transition-delay: 0.18s; }

/* Logo walls are evened by eye, not by geometry: a lockup carrying a symbol or a
   tagline needs more height than a plain wordmark to read at the same weight. */
.marquee-track a { height: 44px; }
.client-logo--stacked { height: 42px; }   /* Armatec: symbol over wordmark + tagline */
.client-logo--lockup  { height: 34px; }   /* Bertling: small wordmark beside a symbol */


/* ------------------------------------------------------------------
   30. Enquiry form
   The reference carries a single form field: borderless, display-size, the
   placeholder doing the labelling, submitted with a pill. Same language here,
   at the card size rather than 40px because this form has six fields, not one.
   Labels stay in the DOM for screen readers.
   ------------------------------------------------------------------ */
/* The reference sets its one form field as borderless display-size text with the
     placeholder doing the labelling, and submits with a pill. Same language here,
     at the card size rather than 40px because this form has six fields, not one.
     Labels stay in the DOM for screen readers. */

  .field { display: block; border-bottom: var(--hairline); }
  .field label {
    position: absolute;
    width: 1px; height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .field input,
  .field select,
  .field textarea {
    width: 100%;
    font-family: var(--sans);
    /* the body size, not the card size: six fields at 24px dominated the page,
       and 16px is also the floor below which iOS Safari zooms on focus */
    font-size: var(--fs-body);
    font-weight: 500;
    line-height: var(--lh-body);
    letter-spacing: normal;
    color: var(--obsidian);
    background: transparent;
    border: 0;
    border-radius: 0;
    padding: 18px 0;
    transition: color var(--t-tint) ease;
  }
  .field input::placeholder,
  .field textarea::placeholder { color: var(--steel); }
  /* the native chevron is the one piece of chrome the reference never shows */
  .field:has(select) { position: relative; }
  .field:has(select)::after {
    content: "";
    position: absolute;
    right: 2px;
    top: 50%;
    width: 8px; height: 8px;
    border-right: 1px solid var(--steel);
    border-bottom: 1px solid var(--steel);
    transform: translateY(-70%) rotate(45deg);
    pointer-events: none;
  }
  .field select {
    appearance: none;
    -webkit-appearance: none;
    line-height: var(--lh-body);
    color: var(--steel);
    padding-right: 24px;
    cursor: pointer;
  }
  .field select:valid { color: var(--obsidian); }
  .field input:focus,
  .field select:focus,
  .field textarea:focus { outline: none; }
  .field:focus-within { border-bottom-color: var(--obsidian); }
  .field textarea { resize: none; min-height: 118px; }

  .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--gap); }
  .form-grid .span-2 { grid-column: 1 / -1; }
  @media (max-width: 700px) { .form-grid { grid-template-columns: minmax(0, 1fr); } }


/* ------------------------------------------------------------------
   31. Project brief
   Uppdraget and Vad vi gjorde used to be two consecutive bands with
   identical structure — a heading over a col-6 list, twice — which read
   as the same section repeated. They share the services page's grid now:
   the section name holds at the left while the facts and the work list
   sit side by side on the right.
   ------------------------------------------------------------------ */

.proj-brief {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
  row-gap: var(--gap);
  align-items: start;
}
/* The services grid: the section name holds the left four columns and the two
   lists sit beside it. Leaving those columns empty read as a block shoved to
   one side rather than as space. */
.proj-brief-head { grid-column: 1 / span 4; grid-row: 1; }
.proj-brief-facts { grid-column: 5 / span 4; grid-row: 1; }
.proj-brief-work { grid-column: 9 / span 4; grid-row: 1; }

/* A fact row inside a four-column span is not the full measure, so the label
   column cannot be the 160px used elsewhere — it took nearly half the width and
   left the value cramped against it. */
.proj-brief .dl-row { grid-template-columns: minmax(0, 88px) minmax(0, 1fr); }

@media (max-width: 1080px) {
  .proj-brief { grid-template-columns: minmax(0, 1fr); }
  .proj-brief-head,
  .proj-brief-facts,
  .proj-brief-work { grid-column: 1 / -1; grid-row: auto; }
}


/* ------------------------------------------------------------------
   32. Title line reveal
   Titles are split into their rendered lines by reveal.js and each line is
   clipped, so it can rise its own height into place without anything being
   seen to travel further than the type itself. The 20px reveal-travel rule
   governs unmasked reveals; a masked line is a different mechanism.
   ------------------------------------------------------------------ */

.line { display: block; overflow: hidden; }
.line-inner { display: block; will-change: transform; }

/* The split runs after the font loads, so hold a split title until it has.
   .hero-title is NOT in this set: it stopped being split when it became a
   per-slide caption, so waiting for a [data-split] that never arrives left it
   invisible — present in the DOM, 135px tall, and blank. */
.js .h-section:not([data-split]),
.js .page-hero .h-sub:not([data-split]) { visibility: hidden; }
.js .h-section[data-split],
.js .page-hero .h-sub[data-split] { visibility: visible; }

@media (prefers-reduced-motion: reduce) {
  .js .h-section,
  .js .page-hero .h-sub { visibility: visible; }   /* nothing splits with motion reduced — the project h1 was left hidden */
  .line-inner { transform: none !important; }
}

/* GSAP missing: nothing will ever split these, so never hold them back */
.no-gsap .h-section,
.no-gsap .page-hero .h-sub { visibility: visible !important; }


/* ------------------------------------------------------------------
   33. Contact people
   Named contacts under the enquiry form, on the same two-column measure
   as the stills above them.
   ------------------------------------------------------------------ */

/* The name sits under its portrait on the project card's own rhythm: the same
   image-to-caption gap, then the role tight beneath the name. */
.person .media { margin-bottom: var(--card-gap); }
.person-name {
  font-size: var(--fs-card);
  font-weight: 600;
  line-height: var(--lh-card);
  letter-spacing: normal;
}
.person-role {
  margin-top: 2px;
  font-size: var(--fs-meta);
  line-height: 16px;
  letter-spacing: normal;
  color: var(--steel);
}
.person-links {
  margin-top: 16px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}


/* ------------------------------------------------------------------
   34. Contact block
   The services page's two-band grid: the invitation holds at the left while
   the form scrolls past it on the right.
   ------------------------------------------------------------------ */

.contact-block {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
  row-gap: var(--gap);
  align-items: start;
}
.contact-block .svc-head { grid-column: 1 / span 4; grid-row: 1; }
/* starts at column 6, not 5: one empty column sets the invitation and the form
   apart properly, where the 40px gutter alone left them crowded */
.contact-form { grid-column: 6 / -1; grid-row: 1; }

.contact-intro {
  margin-top: 20px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  letter-spacing: normal;
  color: var(--steel);
}

@media (max-width: 1080px) {
  /* collapse the track, not just the spans — twelve columns keep eleven 40px
     gaps and cannot go under 440px */
  .contact-block { grid-template-columns: minmax(0, 1fr); }
  .contact-block .svc-head,
  .contact-form { grid-column: 1 / -1; grid-row: auto; }
  .contact-block .svc-head { position: static; }
}


/* ------------------------------------------------------------------
   35. Project page arrival
   In the one-file build the router slides a project route up as a sheet and
   back down on close. The multi-page site cannot do that from CSS alone —
   it is a real navigation between documents — so it opts into a cross-document
   view transition and animates the incoming project document upward. Browsers
   without view transitions simply navigate, which is what they do today.
   ------------------------------------------------------------------ */

@view-transition { navigation: auto; }

@keyframes street-sheet-up {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}

@media (prefers-reduced-motion: no-preference) {
  html[data-page="projekt"]::view-transition-new(root) {
    animation: street-sheet-up 0.6s cubic-bezier(0.16, 1, 0.3, 1);
  }
  html[data-page="projekt"]::view-transition-old(root) {
    animation: none;
  }
}


/* ------------------------------------------------------------------
   36. Vertical cuts and the masonry gallery
   ------------------------------------------------------------------ */

/* A campaign ships more than the film: the 9:16 versions that actually ran sit
   below it, side by side, capped in height so two portraits do not tower over
   the page. Projects with no clips render nothing here. */
.clip-row {
  /* flex, not an auto-fit grid: auto-fit counts columns by the 338px maximum,
     so three clips wrapped to two rows at 1024px. Here they shrink side by side
     and only stack at the phone breakpoint below. */
  display: flex;
  justify-content: start;   /* on the page's own left edge, like everything else */
  gap: var(--gap);
}
.clip-row > .clip-item { flex: 0 1 338px; min-width: 0; }
.player.clip { aspect-ratio: 9 / 16; max-height: 600px; width: 100%; }

/* Two columns of pictures at their own proportions. Fixed slots forced every
   photograph into the same shape — a square frame lost its head and feet in a
   16:10 box — so here the height follows the image and the columns stagger. */
.proj-masonry {
  column-count: 2;
  column-gap: var(--gap);
  margin-bottom: var(--gap);
}
.mfig {
  break-inside: avoid;
  margin: 0 0 var(--gap);
  border-radius: var(--r-card);
  overflow: hidden;
  background: var(--mist);
}
.mfig img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: inherit;
}

@media (max-width: 860px) {
  .proj-masonry { column-count: 1; }
  .clip-row { flex-direction: column; }
  .clip-row > .clip-item { flex-basis: auto; }
  .player.clip { max-height: none; }
}

/* Honeypot — off-screen rather than display:none, because some bots skip
   fields they can tell are hidden. */
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

/* --- Scroll reveals -------------------------------------------------------
   Driven by IntersectionObserver adding .is-in; the motion is CSS. Nothing
   here hides anything until the script says so — `html.js` is set by the head
   script, so with JavaScript off every element is simply visible.
   0.4s over 20px, staggered 60ms by inline transition-delay.
   -------------------------------------------------------------------------- */

html.js .reveal {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity .4s cubic-bezier(0, 0, .2, 1), transform .4s cubic-bezier(0, 0, .2, 1);
}
html.js .reveal.is-in { opacity: 1; transform: none; }

/* A split title animates its own lines, so the element itself must not fade —
   otherwise the mask and the fade fight and the type greys out mid-rise. */
html.js .reveal.is-title { opacity: 1; transform: none; }

.line { display: block; overflow: hidden; }
.line-inner { display: block; }
html.js .is-title .line-inner {
  transform: translateY(110%);
  transition: transform .9s cubic-bezier(.16, 1, .3, 1);
}
html.js .is-title.is-in .line-inner { transform: none; }

/* The words only exist during measurement, between splitting and regrouping. */
.w { display: inline-block; }

@media (prefers-reduced-motion: reduce) {
  html.js .reveal,
  html.js .is-title .line-inner {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* --- Lightbox -------------------------------------------------------------
   A native <dialog>, so Escape, focus trapping and focus return are the
   browser's job rather than ours. It sits on film black in both themes: a
   picture being looked at full size wants the room dark, not the page's
   canvas following the theme.
   -------------------------------------------------------------------------- */

[data-zoom] { cursor: zoom-in; }

.lightbox {
  width: 100vw;
  max-width: 100vw;
  height: 100dvh;
  max-height: 100dvh;
  margin: 0;
  padding: var(--gap);
  border: 0;
  background: var(--film-back);
  color: var(--on-film);
  display: grid;
  place-items: center;
  overscroll-behavior: contain;
}
/* `display: grid` above outranks the browser's own `dialog:not([open])
   { display: none }`, so a closed lightbox stayed on screen — out of the top
   layer, at the end of <body>, a black block under the footer that the close
   button could no longer shut because the dialog already was. */
.lightbox:not([open]) { display: none; }
.lightbox::backdrop { background: rgba(0, 0, 0, .92); }

.lightbox-fig {
  margin: 0;
  display: grid;
  gap: var(--gap-sm);
  justify-items: center;
  /* The picture is the point: it may use the whole frame minus the gutter, and
     the caption sits under it rather than over it. */
  max-height: 100%;
}
.lightbox-fig img {
  display: block;
  max-width: 100%;
  max-height: calc(100dvh - var(--gap) * 2 - 40px);
  width: auto;
  height: auto;
  object-fit: contain;
  border-radius: var(--r-card);
}
.lightbox-fig figcaption {
  color: var(--bone-70);
  font-variant-numeric: tabular-nums;
}

.lightbox-close {
  position: absolute;
  top: var(--gap);
  right: var(--gap);
  z-index: 2;
  width: 37px;
  height: 30px;
  display: grid;
  place-items: center;
  color: var(--on-film);
  transition: opacity var(--t-fast) ease;
}
.lightbox-close:hover { opacity: .7; }
/* Every press lands on the button, never on the glyph inside it. */
.lightbox-close svg { pointer-events: none; }

@media (max-width: 860px) {
  .lightbox { padding: var(--gap-sm); }
  .lightbox-close { top: var(--gap-sm); right: var(--gap-sm); }
}


/* --- Hero: the mobile slide ------------------------------------------------
   Below the split's threshold the hero used to cross-fade, which the reference
   never does at any width. It now slides sideways: the outgoing frame leaves
   left while the next arrives from the right, both moving together.
   -------------------------------------------------------------------------- */

.hero.is-sliding .slide { transition: none; }
.hero.is-sliding .slide.is-active { opacity: 1; }

/* --- Work slider ----------------------------------------------------------
   The reference's Ventures pattern. A text panel holds the heading, the lead
   and the controls; the card track starts beside it and runs off the right
   edge of the page. The row deliberately never ends flush with the margin —
   the cut card is what says there is more to the right.
   -------------------------------------------------------------------------- */

.vent {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--gap);
  /* stretch, not start: the panel is short and the cards are tall, so aligning
     to the top left 250px of dead space beside them. The reference's panel is
     a block the full height of the card row with its controls at the foot. */
  align-items: stretch;
}

.vent-panel {
  grid-column: 1 / span 4;
  display: flex;
  flex-direction: column;
}
.vent-sub { font-size: var(--fs-body); font-weight: 600; margin-top: 8px; }
.vent-body { color: var(--steel); margin-top: var(--gap-sm); max-width: 34ch; }

.vent-nav {
  display: flex;
  gap: 12px;
  /* pushed to the foot of the panel, which is what closes the gap */
  margin-top: auto;
  padding-top: var(--gap);
}
.vent-arrow {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  /* Ink discs, like the pills above them. The grey disc they replaced was
     1.2:1 against the page and read as disabled even when it wasn't; the
     end of the rail is now the only state that looks empty. */
  background: var(--obsidian);
  color: var(--bone);
  transition: background var(--t-fast) ease, color var(--t-fast) ease, box-shadow var(--t-fast) ease;
}
.vent-arrow:hover { background: var(--graphite); }
.vent-arrow[disabled] {
  background: transparent;
  color: var(--steel);
  box-shadow: inset 0 0 0 1px var(--ash);
  pointer-events: none;
}

/* Bleeds one gutter past the shell, so the track reaches the viewport edge. */
.vent-track {
  grid-column: 5 / -1;
  margin-right: calc(var(--gutter) * -1);
  display: flex;
  gap: var(--gap);
  overflow-x: auto;
  /* overflow-y must be said out loud. Setting overflow-x alone makes overflow-y
     compute to `auto` rather than stay `visible`, which quietly turned this
     into a vertical scroll container with 20px of slack — so a scroll gesture
     started over a card was swallowed by the rail and the page stayed put.
     touch-action does the same job for a finger: pan-x keeps the horizontal
     swipe here and hands every vertical one to the page. */
  overflow-y: hidden;
  /* pan-x AND pan-y. pan-x alone does not "hand vertical to the page" — it
     forbids vertical panning outright for any touch that starts here, which
     is the trap it was meant to fix. Naming both allows each axis, and since
     the rail has no vertical overflow the vertical one falls through to the
     page, which is the behaviour we want. */
  touch-action: pan-x pan-y;
  /* Nothing inside a horizontal rail may be translated downward. The cards
     carried .reveal, which holds them 20px low until they animate in, and a
     downward translate inside an overflow box IS scrollable overflow — the
     rail became vertically scrollable by exactly the reveal's travel, and ate
     the gesture. The panel beside the track still reveals; the cards do not. */
  /* proximity, not mandatory: mandatory snapping refuses a programmatic
     scroll outright — measured, the arrows moved the track by 0px — and it
     forces a jump on every small drag. Proximity snaps when the reader lands
     near a card and otherwise leaves them alone. */
  scroll-snap-type: x proximity;
  scroll-behavior: smooth;
  scrollbar-width: none;
  /* -webkit-overflow-scrolling: touch is deliberately absent. It is obsolete —
     iOS has had momentum scrolling natively since 13 — and it creates a
     separate momentum layer that can keep hold of a gesture even when
     overflow-y is hidden. That is the same bug in a second place. */
}
.vent-track::-webkit-scrollbar { display: none; }

.vent-card {
  /* 426px is the reference's card, and it stays the ceiling. But at 1440 two of
     them fit the track exactly, so the row ended 41px short of the edge with
     nothing cut — and the cut card is the whole mechanism. Sizing to 2.4 cards
     guarantees the next one is sliced by the viewport at every width, which
     matters more than the last few pixels of width. */
  flex: 0 0 clamp(260px, calc((100% - var(--gap) * 2) / 2.4), 426px);
  scroll-snap-align: start;
  position: relative;
  display: block;
}
.vent-card .frame { position: relative; overflow: hidden; border-radius: var(--r-card); }
.vent-card .media { aspect-ratio: 3 / 4; }
.vent-card .media img {
  transition: transform var(--t-move) var(--e-move);
  /* A picture is draggable by default; on a rail that turns a scroll attempt
     into a drag of the image itself. */
  -webkit-user-drag: none;
  user-select: none;
  pointer-events: none;
}
.vent-card:hover .media img { transform: scale(1.03); }

/* A scrim under the label. The reference's own venture cards are dark images,
   so white type sits on them safely; ours are whatever the project happens to
   be, and "SKF" was disappearing into a bright head of hair. The gradient is
   confined to the top third and fades to nothing, so it reads as light falling
   off rather than as a panel laid over the photograph. */
.vent-card .media::after {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 38%;
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(to bottom, rgba(0, 0, 0, .55), rgba(0, 0, 0, 0));
}

/* The name sits under the picture, as it does on the grid cards, because the
   pill now occupies the top-left of the image — both absolutely positioned
   there put one word on top of the other. */
.vent-label {
  display: block;
  margin-top: var(--card-gap);
  color: var(--obsidian);
  font-size: var(--fs-card);
  line-height: var(--lh-card);
  font-weight: 500;
}

@media (max-width: 1080px) {
  .vent { grid-template-columns: minmax(0, 1fr); row-gap: var(--gap); }
  .vent-panel, .vent-track { grid-column: 1 / -1; }
  .vent-body { max-width: none; }
  /* stacked, the panel sits above the rail and has no height to fill */
  .vent-nav { margin-top: 0; }
}
@media (max-width: 860px) {
  .vent-card { flex-basis: 80%; }
}

/* Om oss: two customer films. Same rail as Fler projekt, but two portrait cards
   both fit the track, which leaves nothing to click between. Wide 16:9 cards,
   most of the track each, so the second is always cut and the arrows work. */
.vent--film .vent-card { flex: 0 0 85%; }
.vent--film .vent-card .media { aspect-ratio: 16 / 9; }
.vent--film .vent-card .media::after { content: none; }
/* The heading sits above the films at every width, not in a panel beside
   them (the owner's call): the stacked layout Fler projekt uses below 1080px. */
.vent--film { grid-template-columns: minmax(0, 1fr); row-gap: var(--gap); }
.vent--film .vent-panel,
.vent--film .vent-track { grid-column: 1 / -1; }
.vent--film .vent-nav { margin-top: 0; }
/* Full width, 85% would make each film ~1150px at 1440; cap it so the next
   card still shows at the edge without the first one filling the screen. */
.vent--film .vent-card { flex-basis: min(85%, 960px); }

/* --- Menu on a phone ------------------------------------------------------
   The reference sets its items about a quarter of the way down rather than
   under the masthead, and stacks the contact blocks instead of columning them
   into 165px. Measured at 390x844: its first item sits at y=200.
   -------------------------------------------------------------------------- */

@media (max-width: 600px) {
  /* The items stay high, under the masthead. The reference sets its own about a
     quarter of the way down and I moved ours to match; the owner prefers them
     where they were, and it is their site. The stacked footer below stays. */
  .menu-foot { grid-template-columns: minmax(0, 1fr); gap: var(--gap); }
}

/* --- Let's work together --------------------------------------------------
   The reference's closing call: the heading holds the left of the measure and
   the lead sits in the right half with the controls beneath it, not alongside
   the heading. Measured at 1440 — heading 40/45 at the gutter, right column
   beginning at x=740, lead at 24px.
   -------------------------------------------------------------------------- */

/* Stacked at every width: the owners want the lead under the heading, not
   beside it, so the narrow-screen layout is now the only one. */
.cta {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  row-gap: var(--gap-sm);
  align-items: start;
}
.cta > .h-section, .cta-side { grid-column: 1 / -1; }

/* The lead is a step above body. It is the last thing read before the footer,
   and at 16px it read as small print rather than as an invitation. */
.cta-lead {
  font-size: var(--fs-card);
  line-height: var(--lh-card);
  max-width: 34ch;
}
.cta-side .row-links { margin-top: var(--gap); }

@media (max-width: 1080px) {
  .cta-lead { max-width: none; }
}

/* --- Hero bar, on the reference's arrangement ------------------------------
   Bottom-left: a category pill, then the project on two lines — its name, then
   its tagline. Bottom line: the counter on the left, the arrow on the right.
   No ring; the reference does not carry one.
   -------------------------------------------------------------------------- */

/* The pill sits on film, so it cannot use --ash: that is the border for pills
   on the page ground and it disappears over footage. White stroke, white text,
   no fill — the reference's own. */
.hero-pill {
  /* The reference's own pill, measured off it: 13px type on a 25px box with
     11px of side padding, a 1px stroke and a 50px radius. Ours ran 12 on 24
     with 12px padding, which is the card's badge rather than the hero's.

     The one thing that cannot be matched is the weight. The reference sets
     every pill at 100; NB ships a single cut at 400 and font-synthesis is off,
     so asking for 100 would render at 400 and only mislead. */
  height: 25px;
  padding: 0 11px;
  font-size: 13px;
  line-height: 13px;
  margin-bottom: var(--gap-sm);
  border-color: var(--on-film);
  color: var(--on-film);
  background: transparent;
}
.hero-link { display: block; }

.hero-title {
  font-size: var(--fs-display);
  line-height: var(--lh-display);
  font-weight: 600;
  letter-spacing: normal;
  margin: 0;
}

/* The counter sits on the baseline row with the arrow, not floating centre.

   The reference sets its count at the display size, 40/40. Ours sat at body
   size, which read as a caption, and 40 read as too loud over our own hero —
   the owner's call, and the reference's hero is a different composition. It
   takes --fs-sub instead: 22px, a size the scale already owns, so the count
   is neither a caption nor a headline and no new size enters the system.

   The line box stays exactly one digit tall. That is what the roll relies on:
   the outgoing number travels 100% and lands precisely one line up. */
.hero-count {
  grid-area: count;
  font-size: var(--fs-sub);
  line-height: 1;
  font-weight: 600;
  letter-spacing: normal;
  font-variant-numeric: tabular-nums;
  /* The digit travels a full line, so the box has to crop it — otherwise the
     old number is still legible above the new one on its way out. */
  overflow: hidden;
}

.hero-caption { grid-area: caption; }
.hero-next { grid-area: next; justify-self: end; }

/* The caption and the counter change together when the slide does: both
   leave upward and both return from below, so the whole lower-left corner
   reads as one movement rather than three things blinking.

   A heading that rewrites itself cannot be split into lines once at load,
   which is why this is a fade on the block rather than the per-line reveal
   the rest of the site uses. */
.hero-caption,
.hero-count [data-hero-num] {
  transition: opacity var(--t-fast) ease, transform var(--t-move) var(--e-move);
}
/* leaving */
.hero-caption.is-swapping { opacity: 0; transform: translateY(-10px); }
.hero-count [data-hero-num].is-swapping { opacity: 0; transform: translateY(-100%); }
/* Coming back: an animation rather than a parked state, so the resting style
   stays legible on its own. Nothing here holds the element invisible. */
@keyframes hero-rise-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}
@keyframes hero-digit-in {
  from { opacity: 0; transform: translateY(100%); }
  to   { opacity: 1; transform: translateY(0); }
}
.hero-caption.is-entering { animation: hero-rise-in var(--t-move) var(--e-move); }
.hero-count [data-hero-num].is-entering { animation: hero-digit-in var(--t-move) var(--e-move); }

.hero-count [data-hero-num] { display: inline-block; }   /* the total beside it never moves */

@media (prefers-reduced-motion: reduce) {
  .hero-caption,
  .hero-count [data-hero-num] { transition: none; transform: none; animation: none; }
}

/* --- The footer scrolls; only the wordmark is revealed -------------------
   The whole footer used to be pinned under the page, as on the reference, so
   scrolling to the end slid the page off it. That only held while the footer
   fitted in 90% of the viewport, and on a laptop it often did not, so the same
   site behaved two ways. The links are content and now simply scroll; the
   reveal belongs to the wordmark at the foot alone (section 51), which works
   at every height.

   `main` keeps its own ground and stacking level: other rules have come to
   rely on it sitting above the page's fixed layers. */

main {
  position: relative;
  z-index: 1;
  background: var(--bone);
}

/* The hero arrow shrinks on a phone. Measured on the reference at 390: the
   same artwork renders 20px wide there against 48 on the desktop — ours stayed
   48 at every width and dominated the corner. The button keeps a 44px tap
   height; only the glyph shrinks. */
@media (max-width: 860px) {
  .hero-next {
    width: 24px;
    height: auto;
    /* the glyph shrinks, the target does not — 44px stays tappable */
    min-height: 44px;
    display: grid;
    place-items: center;
  }
  .hero-next svg { width: 24px; height: auto; }
}

/* A revealed footer is only worth revealing if there is page left above it.
   At 390 the band padding put it at 70% of the viewport; trimmed, it lands
   near the reference's 43%. */
@media (max-width: 860px) {
  .footer { padding-top: var(--gap); padding-bottom: 20px; }
  .footer-base { margin-top: var(--gap); }
}

/* --- 26. The footer follows the theme ------------------------------------
   The footer takes the page's own mode — dark on the dark theme, light on the
   light one — at the owner's request; only "Fler vi arbetar med" on the home
   page runs in the opposite mode (section 53), which is what sets the end of
   the page apart. So no token set here: the page's tokens apply as they are. */
.footer {
  color: var(--obsidian);
  background: var(--bone);
}


/* The summary's paragraphs run on rather than standing apart. Each one here
   is usually a single line, so a 20px gap between them did not read as two
   paragraphs — it read as one paragraph set on a wildly loose line. The
   reference runs its case copy continuously for the same reason. */
.page-hero .proj-body + .proj-body { margin-top: 0; }

/* A clip and its name travel together, so the grid cell is the pair rather
   than the player: the figure keeps its 9:16 box inside the cell and the name
   sits under it on the card's own rhythm. */
.clip-item { display: flex; flex-direction: column; }
.clip-title {
  margin-top: var(--card-gap);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-weight: 600;
}

/* The project's opening block reads flush left at every width, like every
   other block of text on the site. It was set against the right margin for a
   day; the owner's ruling is that everything is left aligned.




/* --- 31. Pictures settle as they arrive ------------------------------------
   A case picture starts a touch large inside its frame and eases to rest as it
   crosses into view, so a page of stills reads as something passing rather
   than something printed. 1.06 to 1: under the threshold where it registers
   as zoom, over the one where it registers at all.

   On the picture, not on .media — the card's hover already scales .media, and
   two animations writing one transform would fight. Nested, they compose.
   The frame clips it, so nothing grows past the card's edge.

   Scroll-driven, so it is CSS alone and costs nothing on the main thread.
   Where view timelines are not supported the picture simply sits at rest.
   Not on the slider's cards: that rail scrolls sideways, a vertical view
   timeline would read nothing useful there, and nothing inside a horizontal
   rail may be moved on the vertical axis.

   The timeline is declared on the card and read by the picture, not taken
   with view() on the picture itself. view() binds to the nearest scroll
   container, and the frame's overflow:hidden makes the frame one — so the
   picture was measuring its visibility inside a box that never scrolls, and
   sat at scale 1 the whole way down. Measured before the fix: 1.000 entering,
   1.000 a third in, 1.000 well in. The card is outside that box.
   ------------------------------------------------------------------------ */

@keyframes settle-in {
  from { transform: scale(1.06); }
  to   { transform: scale(1); }
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .card,
    .mfig { view-timeline: --case block; }
    .card .media > img,
    .card .media > .card-film,
    .mfig img {
      animation: settle-in linear both;
      animation-timeline: --case;
      animation-range: entry 0% cover 45%;
    }
  }
}

/* --- 32. Cards play their film on hover ----------------------------------
   The loop sits over the still and fades up once it has a frame to show. It
   is built on first hover by street.js, never at load. */
.card-film {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-fast) ease;
}
.media.is-playing .card-film { opacity: 1; }

/* --- 33. The card becomes the page ---------------------------------------
   Cross-document view transitions on the real site: the clicked card's
   picture travels into the project's film, and everything else cross-fades.
   The artifact runs the same transition through its router. Where view
   transitions are unsupported the navigation is simply immediate.

   One name. The lead player always holds it; a card holds it only once
   clicked (.vt-case, set by street.js). The two pictures differ in shape —
   a 3:2 card, a 16:9 film — so both snapshots are cropped rather than
   stretched while the box between them changes size. */
@view-transition { navigation: auto; }

.player--lead,
.vt-case { view-transition-name: case-hero; }
.player--lead.vt-off { view-transition-name: none; }

::view-transition-group(case-hero) {
  animation-duration: .5s;                          /* --t-move */
  animation-timing-function: cubic-bezier(.4, 0, .2, 1);   /* --e-move */
}
::view-transition-old(case-hero),
::view-transition-new(case-hero) {
  height: 100%;
  object-fit: cover;
  overflow: clip;
}
::view-transition-old(root),
::view-transition-new(root) { animation-duration: .3s; }   /* --t-fast */

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

/* --- 34. Spring -----------------------------------------------------------
   A third feel beside the two speeds: things the pointer touches overshoot a
   little and settle, as if they had weight. Only on hover affordances — the
   card, the arrow, the cursor tag. Page-level movement (the cut, the sheet,
   the reveals) keeps the calm curve; a whole page that bounces is a toy.
   linear() draws a real spring; older browsers get an overshooting bezier. */
:root { --e-spring: cubic-bezier(.34, 1.56, .64, 1); }
@supports (transition-timing-function: linear(0, 1)) {
  :root {
    --e-spring: linear(0, .009, .035 2.1%, .141 4.4%, .723 15.5%, .938 20.7%,
      1.017 24.5%, 1.061 28.3%, 1.077 32.4%, 1.067 36.9%, 1.016 50.4%,
      .996 58.8%, 1.001 81.5%, 1);
  }
}
.card .media,
.vent-card .media { transition: transform .7s var(--e-spring); }
.hero-next { transition: transform .6s var(--e-spring), opacity var(--t-fast) ease; }

/* --- 35. The cursor becomes a lens -------------------------------------
   Over something clickable that is a picture — a case, a film, a gallery
   still — the pointer turns into a translucent disc that says what the click
   will do. Everywhere else the system arrow stays, so text can still be
   selected and small links aimed at.

   Dark glass rather than light: it sits over footage in both themes, and a
   pale disc with dark type loses its contrast over every bright frame. The
   blur keeps the label legible over detail; the grain ties it to the hero's
   film surface. Centred on the pointer, because here it IS the pointer. */
.cursor-tag {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 90;
  pointer-events: none;
  will-change: transform;
}
.cursor-tag span {
  position: absolute;
  left: 0;
  top: 0;
  display: grid;
  place-items: center;
  width: 88px;
  height: 88px;
  border-radius: 50%;
  overflow: hidden;
  /* Safari draws a backdrop-filter as the element's square box when the
     element is transformed, ignoring border-radius and overflow: the "Se
     case" disc showed a pale blurred square around it. clip-path clips the
     backdrop too, in every engine. */
  -webkit-clip-path: circle(50%);
  clip-path: circle(50%);
  /* and the grain inside animates, so WebKit composites it on its own layer,
     which neither border-radius nor overflow clips; a mask does */
  -webkit-mask-image: radial-gradient(circle closest-side, #000 99%, transparent 100%);
  mask-image: radial-gradient(circle closest-side, #000 99%, transparent 100%);
  isolation: isolate;
  background: rgba(12, 12, 13, .42);
  -webkit-backdrop-filter: blur(10px) saturate(1.2);
  backdrop-filter: blur(10px) saturate(1.2);
  color: #fff;
  font-size: var(--fs-meta);
  line-height: 1;
  white-space: nowrap;
  transform: translate(-50%, -50%) scale(0);
  opacity: 0;
  transition: transform .5s var(--e-spring), opacity .15s ease;
}
/* the grain: the hero's own noise tile, jumping in steps, under the label */
.cursor-tag span::before {
  content: "";
  position: absolute;
  inset: -50%;
  z-index: -1;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 .7 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
  opacity: .22;
  mix-blend-mode: overlay;
  animation: grain .9s steps(6) infinite;
}
.cursor-tag.is-on span { transform: translate(-50%, -50%) scale(1); opacity: 1; }

/* while the lens is up the arrow steps aside — two pointers is one too many */
html.lens-on,
html.lens-on * { cursor: none !important; }

/* --- 36. Film grain in the hero ------------------------------
   The hero is the one surface that is film, so it is the one place grain
   belongs: over text it only dirties the type. The noise is a tile twice the
   hero's size, jumped between offsets in steps — a transform, so the
   compositor carries it and nothing repaints. */
.hero::before {
  content: "";
  position: absolute;
  inset: -50%;
  z-index: 1;
  pointer-events: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 .6 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
  opacity: .09;
  mix-blend-mode: overlay;
  animation: grain .9s steps(6) infinite;
}
@keyframes grain {
  0%   { transform: translate(0, 0); }
  17%  { transform: translate(-5%, -8%); }
  33%  { transform: translate(-12%, 4%); }
  50%  { transform: translate(7%, -11%); }
  67%  { transform: translate(-3%, 12%); }
  83%  { transform: translate(10%, 3%); }
  100% { transform: translate(0, 0); }
}

/* --- 37. Text drifts against the scroll ----------------------------------
   Headings and the statement travel a little slower than the page, and the
   hero's caption lifts away as you leave it. `translate`, not `transform`:
   the reveals already own transform on these elements, and the individual
   property composes with it instead of replacing it. Small numbers — 28px
   either side — so nothing ever overlaps what sits next to it. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    @keyframes drift {
      from { translate: 0 28px; }
      to   { translate: 0 -28px; }
    }
    .statement,
    .sec-head .h-section,
    .cta-lead {
      animation: drift linear both;
      animation-timeline: view();
      animation-range: cover;
    }
    @keyframes lift-away {
      to { translate: 0 -90px; opacity: 0; }
    }
    .hero-bar {
      animation: lift-away linear both;
      animation-timeline: scroll(root);
      animation-range: 0 80vh;
    }
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero::before { animation: none; }
  .cursor-tag { display: none; }
}

/* --- 38. The scene fades as the page covers it ---------------------------
   The page arrives over the hero the way the next scene arrives over the
   last: the footage pushes in 6% and goes down to 40% of its light while the
   content scrolls up over it, so the hero recedes instead of just being
   covered. Both run on the root scroller across the hero's own height, which
   is exactly the distance it takes to cover it.

   The push is on each slide with the individual `scale` property — GSAP owns
   `transform` there on a phone and on the split halves inside, and the two
   compose rather than fight. The fall to black is a layer of --film-back over
   the footage and the grain, under the caption, so only its opacity changes
   and the compositor carries it.

   Reduced motion keeps the fade and drops the push: a fade tied to the
   reader's own scroll is not movement, the push is. Where scroll timelines
   are unsupported the hero is simply covered, as before.
   ------------------------------------------------------------------------ */

.hero-dim {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: var(--film-back);
  opacity: 0;
}

@supports (animation-timeline: scroll()) {
  @keyframes scene-fade { to { opacity: .6; } }
  .hero-dim {
    animation: scene-fade linear both;
    animation-timeline: scroll(root);
    animation-range: 0 100vh;
  }

  @media (prefers-reduced-motion: no-preference) {
    @keyframes scene-push { to { scale: 1.06; } }
    .hero .slide {
      animation: scene-push linear both;
      animation-timeline: scroll(root);
      animation-range: 0 100vh;
    }
  }
}

/* --- 39. The statement reads itself --------------------------------------
   The intro paragraph is read like a voice-over: street.js wraps each word,
   and each one turns from --steel to the ink as the paragraph crosses from
   the fold to the middle of the screen. The reader's scroll is the playhead.
   One word's turn overlaps the next few, so it reads as a sweep, not a tick.

   --steel is the system's own secondary ink, so a reader who stops half-way
   is looking at two colours the page already uses. Colour is not movement,
   but the split only happens where motion is welcome; otherwise the
   paragraph is never touched. The whole sentence stays beside the words,
   visually hidden, for a screen reader.
   ------------------------------------------------------------------------ */

.vo-said {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  user-select: none;   /* a copied selection carries the words once, not twice */
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    [data-vo] { view-timeline: --vo block; }
    @keyframes vo-read {
      from { color: var(--steel); }
      to   { color: var(--obsidian); }
    }
    .vo-word {
      animation: vo-read linear both;
      animation-timeline: --vo;
    }
  }
}

/* --- 40. The frame opens as it arrives -----------------------------------
   A case picture comes in letterboxed, a widescreen band across its middle,
   and the matte opens to the picture's own shape as it settles — the aspect
   change a cut makes when a film opens up from scope to full frame. It rides
   the same timeline and range as the settle in section 31, so the two read
   as one movement: the frame opens while the picture comes to rest in it.

   On the picture's box, not the frame. The frame holds the badge, and a
   clipped frame cut the label in half on its way in — a sliver of pill
   hanging over the image, which read as a rendering fault. Left whole but
   unclipped, it floated alone in the matte above a picture not yet there.
   So the badge waits: it is held back until the matte has cleared the
   corner it sits in, and fades up over the picture as the frame finishes
   opening. That is `filter: opacity()`, not `opacity` — the hover already
   owns opacity to step the label aside, and an animation on the same
   property would override it for good. 14% a side turns a 3:2 card into a
   band of about 2.1:1 — widescreen without starting as a slit. On ease-out, so most of the opening happens as
   the card comes up and the last of it while it is already in full view — a
   sharper curve was measured opening 80% of the way before the card's lower
   edge was even on screen, where nobody saw it.

   clip-path moves nothing, so nothing gains scrollable overflow; the slider
   is left out anyway, as it is for the settle.
   ------------------------------------------------------------------------ */

@keyframes matte-open {
  from { clip-path: inset(14% 0 14% 0 round var(--r-card)); }
  to   { clip-path: inset(0 0 0 0 round var(--r-card)); }
}
@keyframes label-waits {
  0%, 60% { filter: opacity(0); }
  100%    { filter: opacity(1); }
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .card .media,
    .mfig {
      animation: matte-open ease-out both;
      animation-timeline: --case;
      animation-range: entry 0% cover 45%;
    }
    .card .frame > .badge--over {
      animation: label-waits linear both;
      animation-timeline: --case;
      animation-range: entry 0% cover 45%;
    }
  }
}

/* --- 42. A case with no film opens on its picture ------------------------
   build-cases.py writes .player--still in place of the player when a case has
   no `video` yet: the case's own image in the film's frame, no controls. The
   hover scrim exists for white controls, so it goes with them. */
.player--still::after { display: none; }

/* --- 43. Every picture enters the same way -------------------------------
   The settle (31) and the matte (40) were the case pictures' alone, so a
   still on the services page or a portrait on Om oss simply faded up with
   its block while every card on the home page opened and came to rest. Now
   the four service pictures and the paired portraits enter the same way.

   Each box is its own subject: `.media` names the timeline and reads it for
   the matte, the picture inside reads it for the settle. The box is a scroll
   container (overflow: hidden), which is exactly why the picture cannot use
   view() on itself — it would measure itself inside a box that never
   scrolls. A named timeline on the box sidesteps that, as it does for .mfig.
   A tone plate with no picture yet opens like any other.
   ------------------------------------------------------------------------ */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .svc-media .media,
    .duo .media {
      view-timeline: --case block;
      animation: matte-open ease-out both;
      animation-timeline: --case;
      animation-range: entry 0% cover 45%;
    }
    .svc-media .media > img,
    .svc-media .media > video,
    .duo .media > img {
      animation: settle-in linear both;
      animation-timeline: --case;
      animation-range: entry 0% cover 45%;
    }
  }
}

/* --- 44. Each service shows how far through it you are -------------------
   The service name holds in its column while its detail scrolls past (22).
   Under it, a hairline fills as the block goes by: empty as the name sticks,
   full as it lets go. The column already said which chapter you are in; now
   it says how much of it is left — the timeline of a film, laid under its
   title.

   The range is the sticky span itself, measured, not a round share of the
   block. The name sits a band's padding below the block's top, so it sticks
   at 96px when the block's top has passed the top of the screen by
   (--band − 96px): 19px at 1440. It lets go when the block's foot comes up
   under it — 96px plus the head's own 108px, so 204px from the top. Offsets
   in pixels on `contain` and `exit`, so the line holds on any viewport
   height. A first guess on round numbers ran the fill 115px early.

   Only where the name actually sticks: at 1080 and below the column
   collapses and the head scrolls with the page, so a progress line there
   would describe nothing. The track and the fill only exist where the
   animation runs, so without it the heading is exactly as it was.
   ------------------------------------------------------------------------ */

@keyframes chapter-read {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) and (min-width: 1081px) {
    .svc-block { view-timeline: --chapter block; }
    .svc-block > .svc-head {
      padding-bottom: 20px;
      border-bottom: var(--hairline);
    }
    .svc-block > .svc-head::after {
      content: "";
      position: absolute;
      left: 0;
      right: 0;
      bottom: -1px;
      height: 1px;
      background: var(--obsidian);
      transform-origin: 0 50%;
      animation: chapter-read linear both;
      animation-timeline: --chapter;
      animation-range: contain calc(var(--band) - 96px) exit calc(100% - 204px);
    }
  }
}

/* --- 45. A field's line draws in when you type in it ---------------------
   The enquiry fields have no boxes, only the hairline under each (30). On
   focus that hairline used to switch to ink in a single frame. Now an ink
   line draws over it from the left, over --t-move, and leaves to the right
   when you move on — the same direction of travel as the underline wipe on
   links (4), so the site has one way of drawing a line. Leaving is quicker
   than arriving: the reader is already looking at the next field.

   On ::before, because a select's field already uses ::after for its
   chevron. The hairline stays --fog underneath, so the ink is what moves.
   With motion reduced the transition collapses and the line simply appears,
   which is still the state change the reader needs to see.
   ------------------------------------------------------------------------ */

.field { position: relative; }
.field::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 1px;
  background: var(--obsidian);
  pointer-events: none;
  transform: scaleX(0);
  transform-origin: 100% 50%;
  transition: transform var(--t-fast) ease;
}
.field:focus-within { border-bottom-color: var(--fog); }
.field:focus-within::before {
  transform: scaleX(1);
  transform-origin: 0 50%;
  transition: transform var(--t-move) var(--e-move);
}

/* --- 46. Stills get a heading and room above it --------------------------
   The gallery sat flush under the vertical clips with no gap at all, while
   the film above the clips had a band of air. It takes the same band now,
   under its own heading as Omdöme has. After a quote the band is already
   there, so it is not doubled. */
.proj-stills { padding-top: var(--band); }
.band + .proj-stills { padding-top: 0; }

/* --- 47. Landscape clips --------------------------------------------------
   A clip marked "landscape" in cases.json keeps the vertical clips' column
   width and runs 16:9, so two of them sit side by side from the left under
   the film, as the vertical ones do. */
.player.clip.clip--wide { aspect-ratio: 16 / 9; max-height: none; }
/* Landscape clips run 30% wider than the vertical column — 440px at most —
   at the owner's request: at 338px a 16:9 frame read as a thumbnail. */
@media (min-width: 861px) {
  .clip-row--wide > .clip-item { flex-basis: 440px; }
}

/* --- 48. Volume -----------------------------------------------------------
   A hairline slider beside the sound button, drawn like the scrubber: the
   --bone-35 track, --on-film filled to the level, a small round head. Built
   by street.js. Hidden on touch devices, where the phone's own buttons set
   the volume and a script cannot. */
.player-vol {
  -webkit-appearance: none;
  appearance: none;
  width: 64px;
  height: 16px;
  margin: 0;
  background: transparent;
  cursor: pointer;
  flex: none;
}
.player-vol::-webkit-slider-runnable-track {
  height: 2px;
  background: linear-gradient(var(--on-film), var(--on-film)) 0 / var(--vol, 100%) 100% no-repeat, var(--bone-35);
}
.player-vol::-moz-range-track { height: 2px; background: var(--bone-35); }
.player-vol::-moz-range-progress { height: 2px; background: var(--on-film); }
.player-vol::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 10px;
  height: 10px;
  margin-top: -4px;
  border-radius: 50%;
  background: var(--on-film);
}
.player-vol::-moz-range-thumb {
  width: 10px;
  height: 10px;
  border: 0;
  border-radius: 50%;
  background: var(--on-film);
}
.player-vol:focus-visible { outline: 2px solid var(--on-film); outline-offset: 4px; }
@media (pointer: coarse) {
  .player-vol { display: none; }
}

/* Fullscreen shows the whole frame. In the page a player crops to its box
   (object-fit: cover), but fullscreen makes the box the screen, so a 9:16 clip
   on a 16:9 monitor lost two thirds of its picture. Contain letterboxes
   instead: vertical clips get bars at the sides, 2:1 films at top and bottom. */
.player:fullscreen { aspect-ratio: auto; max-height: none; background: #000; }
.player:-webkit-full-screen { aspect-ratio: auto; max-height: none; background: #000; }
.player:fullscreen video { object-fit: contain; }
.player:-webkit-full-screen video { object-fit: contain; }


/* --- Load screen --------------------------------------------------------
   The home page opens on films and a wall of case pictures. Without this the
   visitor saw it arrive piece by piece; now it waits behind the wordmark on
   black, and the wordmark itself is the progress: it sits in light grey and
   fills to white from left to right as the pictures and the first film land,
   then the screen fades away. Two copies of the logo, stacked; the white one
   is clipped from the right and the clip opens with the progress. Always
   black, whichever theme the page is in: it is the studio's ident, not part
   of the page. */
.loader {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: grid;
  place-items: center;
  background: #101012;
  color: #eeeeee;
  transition: opacity .7s ease, visibility 0s linear .7s;
}
.loader.is-done { opacity: 0; visibility: hidden; pointer-events: none; }
.loader-inner { display: grid; justify-items: center; gap: 22px; }
.loader-mark { display: grid; width: clamp(140px, 18vw, 220px); }
.loader-mark svg { grid-area: 1 / 1; display: block; width: 100%; height: auto; overflow: visible; }
.loader-mark svg:first-child { color: rgba(255, 255, 255, .4); }
/* No CSS transition: street.js eases the clip every frame itself, so the
   fill runs at an even pace instead of jumping with each file that lands. */
.loader-mark-fill { color: #eeeeee; clip-path: inset(0 100% 0 0); }
html.is-loading { overflow: hidden; }
/* Arriving from another page of the site: no load screen (index.html head). */
html.skip-loader .loader { display: none; }
@media (prefers-reduced-motion: reduce) {
  .loader { transition: none; }
}

/* --- 49. The invitation heads the footer ----------------------------------
   "Låt oss göra film tillsammans." used to be its own band on the home page,
   just above the foot. Moved into the footer at the owner's request, it is
   the first thing on the inverted ground on every page but Kontakt (which is
   the form it points to). Heading on the left at display size, the lead and
   the two actions on the right — the same 6+6 split the page grid uses — and
   a hairline under it, so the directory below reads as a separate register.
   On a phone the two halves stack. */
.footer-cta {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap-sm) var(--gap);
  align-items: start;
  padding-bottom: var(--band);
  margin-bottom: var(--band);
  border-bottom: var(--hairline);
}
.footer-cta-title {
  font-size: var(--fs-display);
  line-height: var(--lh-display);
  font-weight: 400;
  max-width: 14ch;
}
.footer-cta-lead {
  font-size: var(--fs-card);
  line-height: var(--lh-card);
  max-width: 34ch;
}
.footer-cta .row-links { margin-top: var(--gap); }
@media (max-width: 860px) {
  .footer-cta { grid-template-columns: minmax(0, 1fr); }
  .footer-cta-lead { max-width: none; }
}

/* --- 50. Stills as a contact sheet ----------------------------------------
   "stillsLayout": "sheet" in cases.json. The masonry gave every still the full
   half-measure, so six pictures ran 3.3 screens on a laptop and 2.9 on a
   phone, one after another at near-full height.

   Rows of equal height, filling the measure edge to edge, at every width.
   Each figure grows in proportion to its own ratio (--r) from a zero basis,
   which is what keeps a row's heights equal. The rows are cut by .sheet-brk
   elements (a full-width, zero-height flex item forces the wrap);
   build-cases.py writes one set for desktop (rows near 440px at 1440) and one
   for phones (rows near 160px, mostly two abreast), and each width hides the
   other's. A swipe rail was tried first for phones and dropped: with
   portrait and landscape frames mixed, the heights jumped from one picture
   to the next.

   Row spacing is a bottom margin on the figures, not row-gap: a break sits on
   a line of its own, and row-gap would count that line twice. */
.sheet-track {
  display: flex;
  flex-wrap: wrap;
  column-gap: 12px;
  margin-bottom: -12px;
}
.sheet-fig {
  flex: var(--r) 1 0;
  min-width: 0;
  margin: 0 0 12px;
  aspect-ratio: var(--r);
  border-radius: var(--r-card);
  overflow: hidden;
  background: var(--mist);
}
.sheet-fig img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.sheet-brk { flex: 0 0 100%; height: 0; }
.sheet-brk--m:not(.sheet-brk--d) { display: none; }

@media (max-width: 860px) {
  .sheet-track { column-gap: 8px; margin-bottom: -8px; }
  .sheet-fig { margin-bottom: 8px; }
  .sheet-brk--m:not(.sheet-brk--d) { display: block; }
  .sheet-brk--d:not(.sheet-brk--m) { display: none; }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .sheet-fig { view-timeline: --case block; animation: matte-open ease-out both; animation-timeline: --case; animation-range: entry 0% cover 45%; }
    .sheet-fig img { animation: settle-in linear both; animation-timeline: --case; animation-range: entry 0% cover 45%; }
  }
}

/* --- 52. The footer's layout, after inspirux.com ---------------------------
   At the owner's request the footer takes the reference's layout as well as
   its motion and colours. Top to bottom: a small invitation on the left with
   the social link (Instagram only) on the right; three columns — contact,
   pages, services — without labels; then the copyright row. The large
   wordmark that closed it was taken out at the owner's request.
 */
.footer { padding-top: clamp(48px, 6vw, 88px); padding-bottom: 24px; }
/* Sentence case and the site's own sizes — body for the links, meta for the
   copyright — rather than the reference's 14px capitals, so the footer speaks
   in the same typographic voice as every page above it. */
.footer-top,
.footer-grid {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}
.footer-top {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--gap);
}
.footer-lede { max-width: 36ch; }
.footer-lede p { margin: 0 0 14px; }
.footer-social {
  display: flex;
  gap: var(--gap);
  margin: 0;
  padding: 0;
  list-style: none;
}
.footer-social a,
.footer-col a { transition: color var(--t-tint) ease; }
.footer-social a:hover,
.footer-col a:hover { color: var(--steel); }


.footer-grid {
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--gap-sm) var(--gap);
  margin-top: clamp(40px, 5vw, 72px);
  padding-top: clamp(20px, 2.4vw, 36px);
  border-top: var(--hairline);
}
.footer-col,
.footer-col ul { margin: 0; padding: 0; list-style: none; }
.footer-col li + li { margin-top: 6px; }

.footer-base {
  margin-top: clamp(48px, 6vw, 88px);
  padding-top: 0;
  border-top: 0;
  font-size: var(--fs-meta);
  color: var(--steel);
}
.footer-base a { color: inherit; }


@media (max-width: 860px) {
  .footer-top { flex-direction: column; gap: var(--gap-sm); }
  .footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .footer-grid > .footer-col:first-child { grid-column: 1 / -1; }
}

/* --- 53. "Fler vi arbetar med" runs in the opposite mode -----------------
   At the owner's request the slider band on the home page inverts against the
   page: light on the dark theme, dark on the light one — the one section in
   the opposite mode, a change of ground between the project grid and the
   footer (which follows the theme, section 26). The theme's opposite
   tokens on the section, so every child (the copy, the arrows, the hairlines)
   follows without its own colours. */
#mer-arbete {
  --obsidian: #eeeeee;
  --graphite: #a8a9ae;
  --steel:    #8a8b90;
  --ash:      #33343a;
  --fog:      #232326;
  --mist:     #18181b;
  --bone:     #101012;
  color: var(--obsidian);
  background: var(--bone);
  --hairline: 1px solid var(--fog);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) #mer-arbete {
    --obsidian: #101012;
    --graphite: #55565b;
    --steel:    #636366;
    --ash:      #cbcbce;
    --fog:      #dddde0;
    --mist:     #e6e6e8;
    --bone:     #eeeeee;
  }
}

:root[data-theme="dark"] #mer-arbete {
  --obsidian: #101012;
  --graphite: #55565b;
  --steel:    #636366;
  --ash:      #cbcbce;
  --fog:      #dddde0;
  --mist:     #e6e6e8;
  --bone:     #eeeeee;
}

/* --- 54. A service can show film -------------------------------------------
   AI-content carries a short loop in place of a still (tjanster.html). Like a
   photograph it is already graded, so the dark theme's dimming of tone plates
   does not apply to it. */
:root[data-theme="dark"] .media:has(> video.svc-film) { filter: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .media:has(> video.svc-film) { filter: none; }
}

/* --- 55. No page scrollbar -------------------------------------------------
   At the owner's request the page's scrollbar is hidden; the page still
   scrolls by wheel, trackpad, touch and keyboard. Firefox reads
   scrollbar-width, WebKit/Blink the pseudo-element. On macOS with overlay
   scrollbars this changes nothing visible; on Windows, and on a Mac set to
   always show scrollbars, it removes the gutter. */
html { scrollbar-width: none; }
html::-webkit-scrollbar,
body::-webkit-scrollbar { display: none; width: 0; height: 0; }

/* --- 56. Every service picture takes the AI film's ratio -------------------
   At the owner's request the four service boxes share the AI preview's own
   proportions (3422 × 1510, ~2.27:1) so the film is not cropped and the
   column reads as one set. The photographs are 3:2 and 5:3, so a third of
   their height goes; each is placed by hand (object-position on the <img>)
   so no head is cut. */
.media--svc { aspect-ratio: 3422 / 1510; }
