/* ============================================================
   ADAM KUDUR — styles
   Mobile-first. Every layout rule starts at phone width and scales up.
   ============================================================ */

:root {
  --bg:        #0a0a0b;
  --bg-lift:   #131315;
  --fg:        #f2f0ed;
  --fg-dim:    #8a8782;
  --fg-faint:  #46443f;
  --line:      rgba(242, 240, 237, 0.14);
  --accent:    #f2f0ed;

  --ease:      cubic-bezier(0.22, 1, 0.36, 1);
  --ease-io:   cubic-bezier(0.65, 0, 0.35, 1);

  --pad:       20px;
  --nav-h:     56px;

  --fs-nav:    11px;
  --fs-meta:   10.5px;
}

@media (min-width: 900px) {
  :root { --pad: 40px; --nav-h: 68px; --fs-nav: 12px; --fs-meta: 11px; }
}

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  background: var(--bg);
  color-scheme: dark;

  /* Kill the rubber-band. It has to be on the element that actually
     scrolls the document, which is html — putting it only on body does
     nothing for the page scroller. */
  overscroll-behavior: none;
}

body {
  margin: 0;
  background: var(--bg);
  overscroll-behavior: none;

  /* The grey past the ends of the page was the soft-light grain layer
     rendering raw. Its base colour is mid-grey — that's what soft-light
     reads as "change nothing" — so it's invisible while there's page
     behind it, and shows as literal grey where there isn't.

     `isolation: isolate` puts both grain layers into a blending group
     that body owns, so they always composite against body's black
     instead of against nothing. Belt and braces with the line above. */
  isolation: isolate;

  /* And body has to actually reach the bottom of the screen, or the
     isolation above works against us: below body's box there is nothing
     in the group to blend with, so the soft-light layer composites over
     transparency and its mid-grey base shows through as a solid band.
     That was the grey on the shorter category pages.

     `min-height: 100%` did nothing here, because html has no height set
     for the percentage to resolve against. Viewport units do.
     100dvh so the iOS toolbars can't reintroduce the same gap. */
  min-height: 100vh;
  min-height: 100dvh;

  color: var(--fg);
  font-family: "Space Grotesk", "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

/* Keep the scrollbar's space reserved at all times.

   The intro locks scrolling, so opening it took the scrollbar away and
   every fixed, centred thing on the page jumped sideways by its width.
   A reserved gutter means nothing moves. */
html { scrollbar-gutter: stable; }
body.no-scroll { overflow: hidden; }

img, video { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0;
         padding: 0; cursor: pointer; }

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

/* Keyboard focus only — no rings on mouse clicks. */
:focus { outline: none; }
:focus-visible {
  outline: 1px solid var(--fg);
  outline-offset: 3px;
}

.mono {
  font-family: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
  font-size: var(--fs-meta);
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---------- GRAIN ---------------------------------------- */
/* soft-light keeps the deepest blacks clean and only lifts the mids,
   which is how grain behaves on a real print. overlay is too hot. */
/* Two stacked grain layers. The blend mode of each is set by grain.js:
   soft-light for the grain you see over the work, screen for the far
   fainter pass that puts speckle into the black background. */
.grain-layer {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 9999;
  pointer-events: none;
  transition: opacity 0.6s var(--ease);
}

/* Film playing — the player rises above the grain layer, so the film
   itself is clean while the grain carries on everywhere else. The grain
   canvas sits at z-index 9999, so this just needs to clear it. */
body.is-playing .film__stage {
  position: relative;
  z-index: 10000;
}

/* ---------- NAV ------------------------------------------ */
.nav {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: var(--nav-h);
  display: flex;
  align-items: center;
  justify-content: space-between;

  /* Aligned to the grid, which runs full bleed.

     I put the bar on a centred measure first, on the reasoning that it
     should line up with the content — but the work grid isn't on a
     measure, it goes edge to edge. So the links stopped a hundred and
     eighty pixels short of where the pictures ended, which is the thing
     that looked wrong: not too far out, too far in.

     Same gutter as the grid, so the mark sits over the left edge of the
     first film and the links over the right edge of the last one. */
  padding: 0 clamp(12px, 1.4vw, 20px);
  z-index: 200;
  mix-blend-mode: difference;
  transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
}

/* Going back to the intro is a cut, so the nav goes on the same frame
   rather than sliding away over the top of it. Coming out of the intro
   it still eases in, which is where the movement belongs. */
#intro:not(.is-gone) ~ .nav[data-hidden="true"] { transition: none; }
.nav[data-hidden="true"] {
  opacity: 0;
  transform: translateY(-100%);
  pointer-events: none;
}

/* Mark and categories on one line, with a proper gap between them so
   the two don't run together into one long string of capitals. */

/* Mark and categories on one line.

   The gap used to be up to 40px, which left ADAMK on its own out at the
   left and the categories adrift in the middle — two things on a line
   rather than one line. Close together, the eye reads the mark and the
   live category as a single label: ADAMK, commercial. */
.nav__left {
  display: flex;
  align-items: center;
  gap: clamp(10px, 1.2vw, 16px);
  min-width: 0;
}

.nav__logo {
  font-weight: 600;
  font-size: 13px;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  white-space: nowrap;
  display: flex;
  align-items: center;
}
@media (min-width: 900px) { /* Mark and categories on one line, with a proper gap between them so
   the two don't run together into one long string of capitals. */

.nav__logo { font-size: 14px; } }


/* .nav__link is gone with the WORK / ABOUT group — About is a tab in
   the left-hand row now, styled as .filter--about. */


/* ---------- VIEWS ---------------------------------------- */
.view { display: none; }
.view.is-active { display: block; }

/* ============================================================
   INTRO — film strip scrubber
   ============================================================ */
/* Stacking order, bottom to top:
     0–300    page content and nav
     400      the intro takeover
     9999     grain — so it falls over the intro as well as the grid
     10000    a film that's actually playing, and only while it plays

   The intro deliberately sits UNDER the grain. It doesn't need to clear
   a playing film by z-index, because replaying tears the player down
   first, which drops it back out of 10000. */
#intro {
  position: fixed;
  inset: 0;
  z-index: 400;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  /* Same footing as About: the block floats in the room available and
     the one small line sits at the foot of the window. Auto margins
     rather than justify-content, because the hint has to be pinned to
     the bottom while everything above it stays optically centred —
     centring the lot would have left the hint hanging in mid-air
     underneath the reel. */
  padding: clamp(18px, 3.5vh, 42px) 0 clamp(20px, 3.5vh, 44px);
  /* Short. It's a dissolve onto the page underneath, not a
     departure to be sat through. */
  transition: opacity 0.5s var(--ease), filter 0.5s var(--ease);
}
#intro.is-leaving {
  opacity: 0;
  filter: blur(6px);
  pointer-events: none;
}
/* One fade, not two.

   The intro used to be held at zero opacity until the loading screen
   had gone — but the loading screen is opaque and sitting on top of it,
   so both were fading at the same moment and you could see the intro
   ghosting up through the screen that was still lifting off it. That
   double exposure is the odd fade.

   The loading screen already hides everything behind it, so the intro
   just sits there at full strength and the only thing that moves is the
   screen coming off. */
#intro.is-leaving { opacity: 0; }
#intro.is-gone { display: none; }

/* Head and strip are one centred stack, so the gap between the mark and
   the reel is a single explicit value rather than whatever falls out of
   two absolutely positioned blocks. */
#intro { align-items: center; }
/* The mark sits high and the free space falls below the reel.

   Sharing the space evenly above and below put the mark almost on the
   centre line, with the reel crowded against it and a large gap under
   both. Fixing the top and letting the hint's auto margin soak up the
   rest gives the reel room to sit in and the whole thing a top-down
   reading order — name, then work. */
/* The three pieces travel together and the group is centred.

   Pinning the hint to the foot of the window put it a long way from
   the reel it describes, marooned on its own at the bottom of the
   screen. It belongs directly under the thing it's telling you about,
   so it keeps a normal margin and the spare room is split evenly above
   the mark and below the hint instead. */
/* Mark and reel, centred as one. With the hint line gone there are
   only two pieces left, and equal air above and below the pair is all
   the composition needs. */
#intro > .intro__head { margin-top: auto; }
#intro > .strip { margin-bottom: auto; }

/* ---- parallax ----------------------------------------------------
   Three planes that answer the pointer by different amounts, each with
   its own perspective, so moving across the intro tilts the stack
   rather than sliding it. The rotation is what reads as "the angle is
   changing" — a flat translate alone just looks like drift.

   Depth of field is focused on the mark: the strapline behind it and
   the reel in front of it soften as you move off centre, and everything
   returns to sharp and square when the pointer is in the middle or has
   left the panel. --par-d is quantised in JS so the blur recalculates
   about ten times rather than sixty times a second.

   .intro__sub is NOT transformed here — its riseIn entrance owns the
   transform property, and writing a second one would kill it. It rides
   the head's plane and only takes the blur. */
#intro { --par-x: 0; --par-y: 0; --par-d: 0; --par-v: 0; }

.intro__head,
.brandgate,
.strip {
  will-change: transform;
}

.intro__head {
  transform:
    perspective(1000px)
    rotateY(calc(var(--par-x) * 7deg))
    rotateX(calc(var(--par-y) * -4.8deg))
    translate3d(calc(var(--par-x) * -34px), calc(var(--par-y) * -21px), 0);
}

/* Compounds on top of the head, so the mark travels furthest of the two
   and sits visibly forward of its own strapline. The shallow perspective
   is deliberate: at 800px the near edge of the mark grows noticeably as
   it swings towards you, which is most of what sells this as a lens
   looking at an object rather than a picture sliding about. The scale
   term is the lens breathing — it swells very slightly off axis. */
.brandgate {
  transform:
    perspective(800px)
    rotateY(calc(var(--par-x) * 5.8deg))
    rotateX(calc(var(--par-y) * -3.9deg))
    translate3d(calc(var(--par-x) * -27px), calc(var(--par-y) * -17px), 0)
    scale(calc(1 + var(--par-d) * 0.022));
}

/* Motion blur and warp, driven from script. See the #lens filter in
   index.html. On the mark itself rather than the button, so the smear
   follows the artwork's own edges. */
.brand--hero, .intro__title span { filter: url(#lens); }

/* The reel is the front plane: most movement, opposite lean to the
   head, which is what opens the gap between them. */
.strip {
  transform:
    perspective(1600px)
    rotateY(calc(var(--par-x) * -2.6deg))
    rotateX(calc(var(--par-y) * 1.4deg))
    translate3d(calc(var(--par-x) * 22px), calc(var(--par-y) * 9px), 0);
}

/* While the cursor is marking the strapline, the head plane keeps its
   parallax travel but gives up its tilt. A highlight is a flat, square
   thing laid over words — it can't follow type that's leaning away in
   three dimensions, and trying to make it do so only ever looks like
   the rectangle is the thing that's wrong. */
#intro.is-marking .intro__head {
  transform: translate3d(calc(var(--par-x) * -34px), calc(var(--par-y) * -21px), 0);
  transition: transform 0.3s var(--ease);
}

.intro__sub {
  /* Softens while it's travelling and comes back sharp when it stops.
     A plain blur rather than the mark's directional one — at this size
     the direction wouldn't read, and an SVG filter on live text costs
     considerably more than it's worth. No transition: --par-v is already
     the eased velocity, so the browser tweening it as well would only
     add lag. */
  filter: blur(calc(var(--par-v) * 2.4px));
  transition: color 0.35s var(--ease);
}
.strip__rail {
  filter: blur(calc(var(--par-d) * 0.9px));
  transition: filter 0.5s var(--ease);
}
/* Scrubbing wants the frames sharp — the defocus is for standing still
   and looking, not for finding a film. */
.strip.is-dragging .strip__rail { filter: none; }

@media (prefers-reduced-motion: reduce), (hover: none) {
  .intro__head, .brandgate, .strip { transform: none; }
  .intro__sub, .strip__rail, .brand--hero, .intro__title span { filter: none; }
}

.intro__head {
  width: 100%;
  padding: 0 var(--pad);
  text-align: center;
  z-index: 1;
}

.intro__title {
  margin: 0;
  /* One word, so it can afford to be bigger than the two-line version. */
  font-size: clamp(3rem, 16vw, 11rem);
  line-height: 0.86;
  font-weight: 600;
  letter-spacing: -0.045em;
  text-transform: uppercase;
  color: var(--fg);
  z-index: 1;
}
.intro__title span {
  display: block;
  opacity: 0;
  transform: translateY(0.5em);
  animation: riseIn 1.1s var(--ease) forwards;
}
.intro__title span:nth-child(2) { animation-delay: 0.09s; }

/* ---- logo file variant ---- */
/* margin-inline:auto is doing real work here. The mark is display:block
   with a max-width, and a block narrower than its container sits hard
   left unless you tell it otherwise — text-align on the parent doesn't
   touch it. That's why the logo was off to one side. */
.brand { display: block; height: auto; margin-inline: auto; }
.brand--hero {
  width: 100%;
  opacity: 0;
  transform: translateY(0.4em);
  animation: riseIn 1.1s var(--ease) forwards;
}

/* ---- the cursor -----------------------------------------------

   A white circle in `difference` blend mode. Difference against white
   is an exact inversion, so the blob doesn't tint or darken what's
   under it — it flips it. Black page goes white, the work goes to its
   negative, and text passing through reads as a punch-out.

   It has to sit above the grain (9999) or the grain would compose over
   the top and grey the inversion out.

   Everything about its motion is set from script: this only describes
   what it looks like. */
.blob {
  position: fixed;
  top: 0; left: 0;
  width: var(--blob-size, 26px);
  height: var(--blob-size, 26px);
  margin: calc(var(--blob-size, 26px) / -2) 0 0 calc(var(--blob-size, 26px) / -2);
  border-radius: 50%;

  /* Hard-edged, but no longer flat white.

     Under mix-blend-mode: difference, whatever colour sits here decides
     what the inversion comes out as: pure white flips everything
     exactly, and pulling a channel down leaves that channel closer to
     the backdrop — so the disc picks up a cast instead of being a clean
     negative. Warm here, because the films underneath are graded cool
     and the tint has to be the other way to register at all.

     Nudge --blob-tint to move it; the grain on top is unaffected. */
  /* Hue, saturation and lightness kept apart so the hue can be driven
     from script while the other two stay put. Saturation is high and
     lightness is off the top: at 100% white the difference blend is an
     exact negative and the disc has no colour of its own at all, which
     is what made it read as a plain cut-out. Coming down off white lets
     one channel sit closer to the backdrop than the others, and that
     gap is the colour. */
  --blob-h: 28;
  --blob-s: 92%;
  --blob-l: 88%;
  background-color: hsl(var(--blob-h) var(--blob-s) var(--blob-l));

  mix-blend-mode: difference;
  overflow: hidden;

  /* And clipped again, properly.

     `overflow: hidden` with a border-radius does not reliably clip a
     descendant that has been promoted to its own compositing layer —
     and the film inside carries a blur and an SVG filter, which
     promotes it. The result was the blurred layer's square bounding box
     showing as a faint edge around the disc: not the disc leaking, the
     film's rectangle sitting behind it.

     clip-path clips the composited result rather than the layout box,
     so it holds regardless of what the children are doing. */
  clip-path: circle(50% at 50% 50%);

  pointer-events: none;
  /* Above the boot screen too — the cursor is live from the first
     frame, so it has to be visible on it. */
  z-index: 20050;
  will-change: transform;
  /* One clock for the whole shape. The radius isn't in here because it
     no longer changes — see .blob.is-mark. */
  transition: width 0.42s var(--ease), height 0.42s var(--ease),
              margin 0.42s var(--ease),
              background-color 0.4s var(--ease);
}


/* ---- the portal ----------------------------------------------------

   Open, over the mark, the disc stops being a cursor and becomes a hole
   through to the work: the film under the playhead runs inside it, the
   tint shifts to somewhere else entirely, and the grain comes up. It
   should read as another place rather than a bigger cursor. */
.blob.is-open {
  /* Neutral on purpose. Any saturation here would mix with the film's
     and pull it off — the disc's job in this state is to be the light
     the film is coloured against, nothing more. The colour you see is
     the film's.

     Lightness down off the top. At 94% the disc was very nearly a white
     hole punched in the page and the film's colour had almost no room
     to show; bringing it down gives the picture somewhere to live and
     lets the whole thing sit into the page rather than on top of it.
     ENTER inverts against it, so it stays legible as this moves. */
  --blob-s: 0%;
  --blob-l: 78%;
}

.blob__film {
  position: absolute;
  /* Inset by the blur radius rather than sitting flush.

     A blur samples outward from the edge, so a film filling the disc
     exactly goes translucent around its rim and the disc's own colour
     shows through as a ring. Pulling the film in and letting it
     overscale means the blur has material to work with everywhere
     inside the circle. */
  inset: -12%;
  width: 124%; height: 124%;
  object-fit: cover;
  border-radius: 50%;
  z-index: 1;

  /* Its own fixed aberration — the glass the disc looks through — and
     a heavy defocus.

     The blur is doing two jobs. It's what makes the disc read as depth
     rather than a thumbnail stuck to the cursor: you're looking through
     something at a film some distance behind it. And it clears the
     ground for ENTER, which has to sit legibly on top of whatever
     happens to be on screen — a sharp cut of a crowd or a car park will
     eat a word every time, and the word is the whole point of the
     state. Defocused, the picture becomes colour and movement, which is
     what it should be contributing anyway.

     Seven pixels. Nine lost the film entirely — you couldn't tell what
     you were pointing at — and four kept too much edge detail for the
     word to sit on comfortably. Seven is where the frame still reads as
     itself but arrives as light and colour rather than as a picture.

     Saturation only just above neutral. It was at 2.6, on the reasoning
     that blur averages colour away so you have to put it back — true,
     but the `color` blend below is already taking the film's saturation
     wholesale, so the two compounded and everything came out neon. The
     film's own colour is enough; this is only topping up what the
     defocus took.

     Scaled up a touch so the blur's soft edge never shows inside the
     circle — a blurred image at exactly 100% leaves a faded rim. */
  /* Saturation well up. The disc is what carries the film's colour
     around the word, and a defocused frame that's only been blurred
     comes out grey — the blur averages the picture, and averaging
     colour is how you lose it. Pushing saturation back afterwards
     recovers what the defocus took. */
  filter: url(#portal-ca) blur(7px) contrast(1.12) saturate(1.15) brightness(1.06);
  transform: scale(1.24);

  opacity: 0;
  transition: opacity 0.34s var(--ease), transform 0.5s var(--ease);
  pointer-events: none;
}
/* Blended into the disc, not laid on top of it.

   At full opacity the film replaced the disc, and the disc is the only
   reason you can see the mark through it — difference needs the disc to
   stay light to invert the letterforms underneath. A solid film made it
   dark and arbitrary, so ADAMK disappeared behind the very thing that
   was supposed to be a window onto it.

   Overlay keeps the disc light where it was light and lets the film
   modulate it, so the letterforms still come through cleanly and the
   film contributes colour and movement rather than replacing the view. */
.blob.is-open .blob__film {
  /* `color` rather than `overlay`.

     Overlay mixes the film's lightness into the disc as well as its
     colour, so what you saw was the disc's own tint pushed about by the
     film rather than the film's colours — which is why they never
     matched what was on the reel. `color` takes hue and saturation from
     the film and leaves lightness alone: the disc stays light enough to
     keep inverting the letterforms underneath, and every colour in it
     is genuinely the colour of the frame you're pointing at. */
  /* A shade under full, so the disc's neutral shows through and takes
     the edge off. At 1 the hue was entirely the film's, which on a
     strongly graded frame reads as a colour swatch rather than a
     picture seen through something. */
  opacity: 0.88;
  mix-blend-mode: color;
  transform: scale(1.12);
}
.blob.is-play .blob__film,
.blob.is-mark .blob__film { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  .blob__film { transition: opacity 0.2s linear; transform: none; }
}

/* Grain, inside the disc.

   A real texture rather than a filter: an SVG feTurbulence baked into a
   data URI, tiled small and animated by stepping the tile around. The
   blob is difference-blended, so the noise inverts along with the disc
   and reads as emulsion in the negative rather than dirt laid over it.

   Two layers at different scales and speeds, because one tile at one
   size reads as a pattern the moment it stops moving. */
.blob::after {
  content: '';
  position: absolute;
  inset: 0;
  /* inherit, not 50%.

     Hard-coded, the grain stayed a circle when the disc squared off
     into a highlight bar — so a marked line of text had an oval patch
     of noise sitting inside a rectangle, which is most of what looked
     wrong about it. It has to be whatever shape its parent currently
     is, and the parent's radius is animated. */
  border-radius: inherit;
  pointer-events: none;
  /* Three tiles at three scales. One tile at one size reads as a
     pattern the moment it stops moving, and two still beat faintly
     against each other; three at these ratios never resolve into
     anything you can name. The finest one carries most of the bite. */
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Cfilter id='m'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='60' height='60' filter='url(%23m)'/%3E%3C/svg%3E");
  background-size: 34px 34px, 96px 96px, 52px 52px;
  mix-blend-mode: overlay;
  opacity: 0.9;
  animation: blobGrain 0.6s steps(1) infinite;
}
/* The disc is small, so the grain has to be coarse and busy to read at
   all — this is heavier than the page grain on purpose. */
.blob.is-play::after { opacity: 0.85; }
/* The portal is grainier than the cursor — it's meant to feel like
   something being projected, and it has a picture under it now that
   can carry the texture. Sits above the film, below the label. */
/* The portal carries a second pass of it.

   ::after is the overlay grain, which sits on the film and modulates
   it. ::before is a harder, coarser layer on top in hard-light, which
   is what actually reads as emulsion rather than as a texture. Two
   passes at different scales, blend modes and speeds is how you get
   grain that looks photographed instead of applied — one layer at any
   opacity always looks like a layer. */
.blob.is-open::after {
  opacity: 1;
  z-index: 1;
  mix-blend-mode: overlay;
  animation-duration: 0.42s;
}
.blob.is-open::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: inherit;
  pointer-events: none;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='90'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.72' numOctaves='5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='90' height='90' filter='url(%23g)'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Cfilter id='h'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.4' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='30' height='30' filter='url(%23h)'/%3E%3C/svg%3E");
  background-size: 78px 78px, 26px 26px;
  mix-blend-mode: hard-light;
  opacity: 0.34;
  animation: blobGrain2 0.28s steps(1) infinite;
}
/* Its own offsets and its own rate, so it never falls into step with
   the layer underneath. */
@keyframes blobGrain2 {
  0%, 100% { background-position:   0px   0px,   0px   0px; }
  25%      { background-position:  19px -23px, -11px   7px; }
  50%      { background-position: -27px  13px,   9px -13px; }
  75%      { background-position:  13px  29px, -17px  17px; }
}
@media (prefers-reduced-motion: reduce) {
  .blob.is-open::before { animation: none; opacity: 0.22; }
}

/* Prime-ish offsets, and all three layers moving by different amounts
   each step, so the stack never lands back in register. */
@keyframes blobGrain {
  0%   { background-position:   0px   0px,   0px   0px,   0px   0px; }
  20%  { background-position:  17px -11px, -31px  17px,  13px -21px; }
  40%  { background-position: -23px  19px,  23px -29px, -19px  11px; }
  60%  { background-position:  11px  29px, -13px -19px,  27px  23px; }
  80%  { background-position: -29px -17px,  29px  13px, -23px -17px; }
  100% { background-position:   0px   0px,   0px   0px,   0px   0px; }
}
@media (prefers-reduced-motion: reduce) {
  .blob::after { animation: none; opacity: 0.4; }
}

/* The label sits above the grain and the film, not in them. */


/* No pool behind the word.

   It worked, but it read as a glow — a lit patch stuck on the front of
   the disc, which is the opposite of a hole through to something. The
   word has to earn its contrast from its own value instead, so it goes
   white in the portal: under difference white is a full inversion, and
   a full inversion of a defocused film is about as far from that film
   as the word can get. */

/* Bigger and tighter in the portal. It has more to compete with than it
   did on a flat disc, and the extra weight is what keeps it an
   instruction rather than a caption. */




/* Sized to the open blob and scaled from script to whatever state the
   blob is in, so the two always agree. Below the blob but above the
   grain — under the grain it would be blurring the wrong layer. */
.blob-lens {
  position: fixed;
  top: 0; left: 0;
  /* A touch wider than the blob it sits under, because the edge is no
     longer a hard cut. */
  --lens-size: 230px;
  width: var(--lens-size); height: var(--lens-size);
  margin: calc(var(--lens-size) / -2) 0 0 calc(var(--lens-size) / -2);
  border-radius: 50%;
  background: transparent;

  /* Soft rim. A circular clip left a visible disc of blur with a hard
     boundary — the shape read as an object in front of the page rather
     than the page going soft. The mask holds full strength out to 62%
     of the radius and then falls away, so the blur has somewhere to go
     instead of stopping dead. The grain layer is a child, so it's
     masked by the same curve and fades out with it. */
  -webkit-mask-image: radial-gradient(circle closest-side,
    #000 0%, #000 34%, rgba(0,0,0,0.62) 58%, rgba(0,0,0,0.22) 78%, transparent 100%);
  mask-image: radial-gradient(circle closest-side,
    #000 0%, #000 34%, rgba(0,0,0,0.62) 58%, rgba(0,0,0,0.22) 78%, transparent 100%);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  pointer-events: none;
  overflow: hidden;
  z-index: 20030;   /* over the grain, under the desat disc and the blob */
  opacity: 0;
  transition: opacity 0.4s var(--ease), background-color 0.4s var(--ease),
              width 0.45s var(--ease), height 0.45s var(--ease),
              margin 0.45s var(--ease);
  will-change: transform;
}
.blob-lens.is-on { opacity: 1; }

/* Grain inside the circle, heavier than the page's own.

   The site-wide grain sits at 9999, below this, so the lens blurs it
   along with everything else — soften the picture and you soften its
   texture with it, which is exactly what you don't want. This puts the
   grain back on top of the blur, at its full hardness.

   Static noise animated by moving it rather than regenerating it: the
   tile is four times the lens and gets jumped around on eighths, which
   is the same 8fps boil the mark uses. Cheaper than a new noise field
   every frame and, at this size, indistinguishable. */
.blob-lens::after {
  content: "";
  position: absolute;
  inset: -50%;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  opacity: 0.42;
  mix-blend-mode: overlay;
  animation: lensGrain 0.5s steps(1) infinite;
  pointer-events: none;
}
@keyframes lensGrain {
  0%   { transform: translate3d(0, 0, 0); }
  25%  { transform: translate3d(-27px, 13px, 0); }
  50%  { transform: translate3d(19px, -22px, 0); }
  75%  { transform: translate3d(-11px, -8px, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .blob-lens::after { animation: none; }
}


/* The greyscale plate. Mirrors the blob exactly — script writes the
   same transform and the same border-radius to both — and carries no
   colour of its own; all it does is desaturate its own backdrop. Hard
   edged on purpose, unlike the lens: a soft edge here would leave a
   ring where the blob is still inverting colour the plate has already
   let through. */
.blob-desat {
  position: fixed;
  top: 0; left: 0;
  width: var(--blob-size, 26px);
  height: var(--blob-size, 26px);
  margin: calc(var(--blob-size, 26px) / -2) 0 0 calc(var(--blob-size, 26px) / -2);
  border-radius: 50%;
  backdrop-filter: grayscale(1);
  -webkit-backdrop-filter: grayscale(1);
  pointer-events: none;
  z-index: 20040;
  will-change: transform;
  transition: width 0.42s var(--ease), height 0.42s var(--ease),
              margin 0.42s var(--ease);
}
.blob-desat.is-open { --blob-size: 168px; }
/* Play state: one triangle, cut clean out of the disc.

   A disc with a triangular hole in it — that's all. The disc inverts
   what's behind it, the triangle lets it through untouched, and the
   edge between the two is the drawing.

   fill-rule evenodd is the whole trick, and it took a while to find. A
   CSS mask taken from an image reads that image's ALPHA, not its
   brightness, so painting the triangle black did nothing at all: black
   paint is every bit as opaque as white. As one path with two subpaths
   and evenodd, the triangle is a genuine hole in the alpha.

   Both discs take the same mask, so the greyscale plate isn't sitting
   under the hole draining the colour out of it. Around the mark the
   plate is intact and the inversion stays black and white. */
.blob.is-play,
.blob-desat.is-play {
  --blob-size: 74px;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='74' height='74' viewBox='0 0 74 74'%3E%3Cpath fill='%23fff' fill-rule='evenodd' d='M0 37a37 37 0 1 0 74 0a37 37 0 1 0-74 0M28 21L52 37L28 53Z'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='74' height='74' viewBox='0 0 74 74'%3E%3Cpath fill='%23fff' fill-rule='evenodd' d='M0 37a37 37 0 1 0 74 0a37 37 0 1 0-74 0M28 21L52 37L28 53Z'/%3E%3C/svg%3E");
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}







/* Bigger than it was. The disc has to clear the mark's stroke widths
   for the word inside it to sit on solid ground rather than in the
   middle of a letterform. */
.blob.is-open { --blob-size: 168px; }

/* Highlighting the strapline.

   The disc becomes a rectangle the size of the line of type and parks
   on it, so `difference` inverts the words the way a text selection
   does. Script writes width, height and margin — the size can't come
   from --blob-size, which only knows how to make squares — and the
   transitions here are what turn the change into a morph rather than a
   swap. No label and no glyph: the highlight is the whole message. */
/* The disc no longer becomes the highlight — see .mark. It only steps
   out of the way while one is up. */
.blob.is-mark,
.blob-desat.is-mark { opacity: 0; }



/* No ring.

   There used to be two, drawn as stacked box-shadows — a hairline on
   the disc and a second one thirteen pixels out. They were meant to
   make the disc read as a control rather than a shape. What they
   actually did was put a white outline around it and, because there
   were two, a circle inside a circle. The disc has a film in it now;
   it doesn't need help being read as a thing. */

/* Play-button state. Same disc, bigger, with the triangle cut out of
   the inversion exactly like ENTER is — black on white, which
   `difference` then flips along with everything behind it.

   Deliberately smaller than the ENTER state: that one has to hold a
   word and announce the way in, this one only has to say "this opens".
   The glyph scales up from 0.55 rather than fading in flat, so the two
   halves of the morph — the disc growing and the mark arriving — land
   together. */


/* The wobble, only while it's open over the mark.

   This is on border-radius rather than transform, deliberately — the
   transform is written every frame by script for the chase and the
   stretch, so a CSS animation on it would be overwritten instantly.
   border-radius is untouched by any of that, and morphing the four
   corner pairs against each other is what gives the surface-tension
   look rather than a circle changing size.

   Smooth easing here, unlike the mark's boil: the shake is what's cut
   on eights, the wobble is fluid underneath it. */
@keyframes blobWobble {
  0%, 100% { border-radius: 50% 50% 50% 50% / 50% 50% 50% 50%; }
  25%      { border-radius: 58% 42% 46% 54% / 44% 56% 44% 56%; }
  50%      { border-radius: 44% 56% 55% 45% / 57% 43% 57% 43%; }
  75%      { border-radius: 53% 47% 41% 59% / 47% 52% 48% 53%; }
}

.blob.is-open {
  animation: blobWobble 1.8s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .blob.is-open { animation: none; }
}

/* With the blob standing in for the pointer, the arrow is just noise. */
html.has-blob, html.has-blob * { cursor: none !important; }

/* ---- the mark as the way in ---------------------------------- */
/* Holds the SVG filter definition only. NOT .filters — that's already
   the category bar, and hiding it would have taken All/Commercial/
   Music/Social off the page with it. */
.svg-defs { position: absolute; width: 0; height: 0; overflow: hidden; }

.brandgate {
  position: relative;
  /* Shrink-wrapped to the mark, not stretched across the head.

     At width:100% the button was the full width of the intro, so the
     black either side of ADAMK was still inside it — the cursor opened
     and said ENTER while pointing at nothing. The word should only
     appear over the thing it acts on. */
  display: block;
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* The boil.

   Hand-drawn animation is shot on twos or threes, so the line never
   sits perfectly still between frames — it wanders by a hair. That
   wander is the life in it. This reproduces it: six positions, each
   held for exactly one eighth of a second, snapping between them rather
   than easing.

   steps(1, end) is the whole trick. A normal keyframe animation would
   interpolate and give a smooth drift, which reads as floating. Holding
   each position and cutting to the next is what makes it read as
   drawn, frame by frame.

   The numbers are deliberately small — a pixel or two and a third of a
   degree. Any more and a clean geometric mark starts to look broken
   rather than alive. */
@keyframes boil {
  0%   { transform: translate3d(0,      0,      0) rotate(0deg);      }
  16%  { transform: translate3d(-1.1px, 0.7px,  0) rotate(-0.22deg);  }
  33%  { transform: translate3d(0.9px, -0.9px,  0) rotate(0.3deg);    }
  50%  { transform: translate3d(-0.6px, -1.1px, 0) rotate(0.12deg);   }
  66%  { transform: translate3d(1.2px,  0.5px,  0) rotate(-0.3deg);   }
  83%  { transform: translate3d(-0.9px, 1px,    0) rotate(0.18deg);   }
  100% { transform: translate3d(0,      0,      0) rotate(0deg);      }
}

.brandgate .intro__title,
.brandgate .brand--hero {
  transition: filter 0.4s var(--ease), opacity 0.4s var(--ease);
}

/* 6 frames over 0.75s = 8 a second, and only while you're on it.

   The boil goes on the button, not on the mark inside it. Putting an
   animation on the image means writing the `animation` shorthand, which
   wipes whatever was already there — and what was already there is
   riseIn, the entrance fade from opacity 0. That's how the logo ended
   up boiling away invisibly earlier.

   Animating the wrapper sidesteps it entirely: the image keeps its own
   animation untouched and just comes along for the ride. */
@media (hover: hover) {
  .brandgate:hover,
  .brandgate:focus-visible {
    animation: boil 0.75s steps(1, end) infinite;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Only the boil. `.brand--hero` keeps riseIn — the global
     reduced-motion rule already collapses its duration to nothing, so
     it lands at opacity 1 instantly rather than never running. */
  .brandgate:hover,
  .brandgate:focus-visible { animation: none; }
}

.brandgate__enter {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  letter-spacing: 0.42em;
  text-indent: 0.42em;      /* optical: kill the trailing gap */
  font-size: clamp(12px, 1.1vw, 15px);
  color: var(--fg);
  opacity: 0;
  transform: scale(0.94);
  transition: opacity 0.4s var(--ease), transform 0.5s var(--ease);
  pointer-events: none;
}

@media (hover: hover) {
  .brandgate:hover .brandgate__enter,
  .brandgate:focus-visible .brandgate__enter {
    opacity: 1;
    transform: none;
  }
}

/* Once the blob is running it carries ENTER, so the fixed one pinned to
   the middle of the mark would be a second copy of the same word. The
   keyboard path still needs it though — there's no pointer to follow —
   so it's only suppressed on hover, not on focus.

   The mark itself is left alone entirely: the lens passing over it is
   the effect, and blurring the logo on top of that was two treatments
   doing the same job. */
html.has-blob .brandgate:hover .brandgate__enter { opacity: 0; }

/* Keyboard users get the same thing without needing a pointer. */
.brandgate:focus-visible { outline: none; }

@media (prefers-reduced-motion: reduce) {
  .brandgate .intro__title,
  .brandgate .brand--hero { filter: none; }
}
.brand--nav { width: auto; }
.intro__title--img { line-height: 0; }
/* A wordmark PNG is usually pure white on transparent; if yours has a
   baked-in dark background, uncomment the next line to knock it out.
   .brand { mix-blend-mode: screen; } */

/* Sits well clear of the mark rather than tucked under it, and is a
   link through to About. Inline-block so the clickable area is the
   words themselves, not the full width of the centred container. */
.intro__sub {
  display: inline-block;
  margin: 0 0 clamp(18px, 3.2vh, 34px);
  padding: 6px 4px;
  color: var(--fg-dim);
  opacity: 0;
  animation: riseIn 1s var(--ease) 0.35s forwards;
  transition: color 0.35s var(--ease);
  position: relative;
}
/* No rule, no arrow, and no highlight — just the line of type going
   from grey to white. Sitting directly under the mark, that's all the
   invitation it needs. */
@media (hover: hover) {
  .intro__sub:hover { color: #fff; }
}

/* A forgiving hit area, without a bigger box.

   The link's own box is drawn tight to the words, and the highlight is
   measured from that box — so growing the padding to make it easier to
   hit would have grown the highlight along with it. This extends only
   what the pointer can reach: an invisible pad that catches you before
   you're quite on the text, so the rectangle snaps on once and stays
   on, instead of flickering along the edge. */
.intro__sub::before {
  content: "";
  position: absolute;
  inset: -16px -22px;
}

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

/* ---- threading up -------------------------------------------------

   The reel doesn't arrive, it gets laced. A hairline draws out from the
   playhead to both edges, the gate opens along it, the playhead drops
   in, and the slate above fades up last. Held until the loading screen
   has lifted (html.is-booted), so none of it plays out of sight.

   Everything here is opacity and clip-path — deliberately. The strip's
   transform belongs to the parallax and the track's belongs to the
   scrub, and an entrance animation on either would be overwritten on
   the first frame. */
/* Opens to well outside its own box, not to zero.

   The rail deliberately runs 120px wider than the gate so its ends sit
   off-screen. Finishing this wipe at inset(0) clipped that overhang
   straight back to the window edge — which put a hard vertical cut
   exactly where the fade hadn't finished yet, and that cut is the edge
   you could see. */
/* ---- the reel arriving ---------------------------------------------

   Nothing here. No fade, no wipe, no stagger.

   Three goes at animating this in and all three had the same fault:
   they announced that an element was appearing. A reel doesn't appear,
   it comes up to speed — so the entrance is in the motion instead, in
   app.js, where the strip starts fast and winds down to its running
   pace. The only thing you see is film moving, slowing to a crawl.

   The intro as a whole still dissolves in behind the loading screen,
   which is all the fade this needs. */

/* Readout sits ABOVE the rail, ranged left and right like a slate.
   Everything else in the intro is centred; this deliberately isn't.

   Brought in off the window edges and held to a measure: ranged hard
   left and right, the title and the timecode read as two labels pinned
   to opposite corners. On a narrower measure, centred over the reel,
   they read as one slate — which is what they are. */
.strip__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  max-width: min(1040px, 88vw);
  margin-inline: auto;
  padding: 14px 4px 0;
}
/* Title and credit on one line — the credit set dimmer and smaller so
   it reads as an aside rather than a second heading. It's only ever the
   film under the playhead, so this is the slate for what's selected,
   not a label on everything. */
.strip__now {
  display: flex;
  align-items: baseline;
  gap: 10px;
  min-width: 0;
  overflow: hidden;
}
.strip__now strong {
  flex: 0 1 auto;
  font-size: clamp(13px, 1.5vw, 16px);
  font-weight: 500;
  letter-spacing: 0.01em;
  /* Off full white. At --fg the title was the brightest thing on the
     page after the mark, and it sits directly under the reel — it kept
     pulling the eye down off the films. Dimmed, it reads as a caption
     on the picture rather than competing with it. */
  color: var(--fg-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The credit gives way first: when the line runs out it's the thing
   that shortens, never the title. */
.strip__dir {
  flex: 0 1 auto;
  min-width: 0;
  /* Brought up out of the floor. At --fg-faint and 0.6 opacity this was
     effectively invisible — the director's name was on the page but
     nobody was reading it, which is worse than not printing it. The
     size and tracking still keep it an aside. */
  color: var(--fg-dim);
  font-size: 10.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  opacity: 0.92;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.strip__tc {
  flex: 0 0 auto;
  color: var(--fg-dim);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ---- the rail, the reel and the frames ----------------------------

   Restored after a botched edit of mine took the whole run of rules
   out. Without .strip__track's flex the sixty frames stacked into a
   column nineteen thousand pixels tall, which shoved the mark and the
   slate far off the top of the window — hence a black screen with, on
   inspection, everything present and correct in the DOM. */

/* The strip itself. Its transform belongs to the parallax, so nothing
   here may touch it.

   The gap above is deliberately large: the mark and the reel are two
   separate statements, and at fifty pixels apart the reel read as
   something attached to the bottom of the logo. */
.strip {
  position: relative;
  z-index: 2;
  width: 100%;
  margin-top: clamp(46px, 11vh, 136px);
  cursor: grab;
  touch-action: pan-y;          /* let the page scroll vertically */
  user-select: none;
  -webkit-user-select: none;
}
.strip.is-dragging { cursor: grabbing; }

/* Wrapper so the playhead can span exactly the rail, now that the
   caption sits above it. The rail itself clips, this doesn't. */
.strip__gate { position: relative; }

.strip__rail {
  position: relative;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  background: linear-gradient(180deg,
    rgba(255,255,255,0.035), rgba(255,255,255,0.012));
  padding: clamp(16px, 1.7vw, 28px) 0;
  overflow: hidden;

  /* Run the rail wider than the window and let #intro clip it, so its
     two ends sit safely off-screen where the parallax can't walk them
     into view. */
  width: calc(100% + 120px);
  margin-left: -60px;

  /* The frames dissolve rather than being cut, and the fade is set in
     pixels: as a percentage it scaled with the rail, so on a wide
     window it hadn't finished by the time it reached the edge of the
     screen and the last of it was chopped off. */
  --rail-fade: 190px;
  -webkit-mask-image: linear-gradient(90deg,
    transparent 0 40px, #000 var(--rail-fade),
    #000 calc(100% - var(--rail-fade)), transparent calc(100% - 40px));
  mask-image: linear-gradient(90deg,
    transparent 0 40px, #000 var(--rail-fade),
    #000 calc(100% - var(--rail-fade)), transparent calc(100% - 40px));
}

/* Sprocket holes, drawn rather than drawn-in: one repeating radial
   gradient per row, punched in the page's own background colour. */
.strip__perf {
  position: absolute;
  left: 0; right: 0;
  height: 9px;
  background-image: radial-gradient(
    ellipse 5px 4px at 10px 50%,
    var(--bg) 0 96%, transparent 100%);
  background-size: 28px 100%;
  background-repeat: repeat-x;
  opacity: 0.85;
  pointer-events: none;
  z-index: 3;
}
.strip__perf--top { top: 3px; }
.strip__perf--bot { bottom: 3px; }
@media (min-width: 900px) {
  .strip__perf { height: 11px; background-size: 34px 100%; }
}

/* The reel. Script writes the transform every frame, so nothing else
   may touch it. */
.strip__track {
  display: flex;
  align-items: center;
  gap: 6px;
  width: max-content;
  will-change: transform;
}
@media (min-width: 900px) { .strip__track { gap: 8px; } }

/* Fluid rather than stepped — the reel grows smoothly with the window
   instead of jumping at two breakpoints. Measured via offsetLeft, so
   the scale on the centred frame never affects the maths. */
.frame {
  position: relative;
  flex: 0 0 auto;
  width: clamp(175px, 27vw, 470px);
  aspect-ratio: 16 / 9;
  background: var(--bg-lift);
  overflow: hidden;
  filter: grayscale(1) contrast(1.1) brightness(0.55);
  transition: filter 0.5s var(--ease), transform 0.5s var(--ease);
}
.frame img,
.frame video {
  width: 100%; height: 100%;
  object-fit: cover;
}
/* The one live frame lies over its own still, so there's no gap while
   it loads and nothing moves when it arrives. */
.frame video {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.18s linear;
}
.frame video.is-live { opacity: 1; }



/* Pointing at a frame brings it up out of the grade, but stays a step
   below the one under the playhead so it's still obvious which is
   selected and which you're only pointing at. */
@media (hover: hover) {
  .frame:hover,
  .frame.is-peek {
    filter: grayscale(0.15) contrast(1.02) brightness(0.88);
  }
}
.frame.is-current,
.frame.is-current:hover,
.frame.is-current.is-peek {
  filter: grayscale(0) contrast(1) brightness(1);
  transform: scale(1.02);
}

/* A faint rule around whichever film is under the playhead. An inset
   box-shadow rather than a border, so it takes up no layout — a real
   border would add a pixel to the frame's box and shove the whole reel
   along by one every time the selection moved. */
.frame.is-current::after {
  content: "";
  position: absolute;
  inset: 0;
  box-shadow: inset 0 0 0 1px rgba(242, 240, 237, 0.55);
  pointer-events: none;
  z-index: 2;
}
.frame__no { opacity: 0; }

/* Title on the frame you're pointing at, centred in it — but never on
   the playhead frame, which already has its name on the slate. */
.frame__cap {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 10px;
  text-align: center;
  /* Darkened only — no blur. The frames are small enough that softening
     them as well made the picture disappear behind its own label. */
  background: rgba(8, 8, 9, 0.52);
  font-family: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
  text-transform: uppercase;
  line-height: 1.25;
  opacity: 0;
  transition: opacity 0.35s var(--ease);
  pointer-events: none;
}
@media (hover: hover) {
  .frame:not(.is-current):hover .frame__cap,
  .frame.is-peek:not(.is-current) .frame__cap { opacity: 1; }
}
.frame__brand {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.12em;
  color: var(--fg);
}
.frame__name {
  font-size: 10px;
  letter-spacing: 0.1em;
  color: var(--fg-dim);
}

/* The playhead. Spans the gate, clear of the rail top and bottom, with
   a diamond at each end. */
.strip__head {
  position: absolute;
  left: 50%;
  top: -6px; bottom: -6px;
  width: 1px;
  background: var(--fg);
  transform: translateX(-50%);
  z-index: 3;
  pointer-events: none;
  opacity: 0.9;
}
.strip__head::before,
.strip__head::after {
  content: "";
  position: absolute;
  left: 50%;
  width: 5px; height: 5px;
  background: var(--fg);
  transform: translateX(-50%) rotate(45deg);
}
.strip__head::before { top: -2px; }
.strip__head::after  { bottom: -2px; }

/* Every frame answers a click now — the ones off the playhead by
   coming to it — so they all take the pointer. */
.frame { cursor: pointer; }

/* Hint sits quietly below the reel */
/* Visible by default. It used to hide itself and rely on its own
   riseIn to bring it back — but the lacing animation replaces that
   animation, and when the lacing finished the hint dropped back to the
   base rule and vanished. Same lesson as the intro: hold things back,
   don't reveal them. */
/* Sits with the mark now, not at the foot of the window. */
.intro__hint {
  margin: clamp(16px, 2.6vh, 30px) 0 0;
  color: var(--fg-faint);
  text-align: center;
}

/* Enter: a piece of type with a rule that draws in, not a UI pill.
   Still a 44px target — the padding does that without showing. */
.enter {
  display: inline-flex;
  align-items: center;
  gap: 11px;
  position: relative;
  margin-top: clamp(18px, 2.6vh, 30px);
  padding: 14px 2px;
  min-height: 44px;
  color: var(--fg-dim);
  letter-spacing: 0.2em;
  transition: color 0.4s var(--ease);
  opacity: 0;
  animation: riseIn 1s var(--ease) 0.8s forwards;
}
.enter::after {
  content: "";
  position: absolute;
  left: 2px; right: 2px; bottom: 10px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.5s var(--ease);
}
.enter__arrow { transition: transform 0.45s var(--ease); }
@media (hover: hover) {
  .enter:hover { color: var(--fg); }
  .enter:hover::after { transform: scaleX(1); }
  .enter:hover .enter__arrow { transform: translateX(5px); }

  /* With a pointer, the mark itself is the way in, so this second Enter
     under the strip is just clutter. It stays on touch, where there's no
     hover to reveal the one over the logo and you'd otherwise have to
     guess that the mark is tappable. */
  .enter { display: none; }
}
.enter:focus-visible::after { transform: scaleX(1); }

/* The frames invite a click, so say so with the cursor. */
/* Only the playhead frame is clickable, so only it gets the pointer.
   The rest keep the grab cursor the strip already has, which says
   "drag me" — which is all they do. */
.frame { cursor: inherit; }
.frame.is-current { cursor: pointer; }

/* ============================================================
   WORK GRID
   ============================================================ */
.page { padding-top: calc(var(--nav-h) + 8px); }

/* The filters, in the bar.

   They used to be their own row under the nav, which put a wordmark and
   four words of type on top of each other in the top-left corner with
   no air between them, and pushed the first row of films further down
   the page than it needed to be. On the same line as the mark they read
   as a heading for what you're looking at — ADAMK, commercial — and the
   work starts higher.

   Shown on every route, not just the work page: from About they're the
   way back into the films, and a bar whose contents change as you move
   around is a bar you have to re-read every time. */
/* The row that actually spaces them. */
.nav__tabs {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.nav__tabs::-webkit-scrollbar { display: none; }

/* Lays nothing out itself — its buttons become children of .nav__tabs,
   which is what puts them on the same gap as About. */
.filters { display: contents; }

/* Back to how they were: bordered pills, and the live one reversed out.

   I replaced these with plain words, then a filled block, then an
   underline, chasing "more obvious" — and every version lost something
   the pill already had. A bordered chip is a control at a glance
   whether it's on or not, which is the actual job here; the plain-word
   versions all made you work out that the row was even clickable.

   Only change kept is the size: the row lives in the nav bar now, so
   the chips come down to fit a 48px line rather than a row of their
   own. */
.filter {
  flex: 0 0 auto;
  padding: 7px 14px;
  min-height: 32px;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--fg-dim);
  font-family: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
  font-size: var(--fs-nav);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  white-space: nowrap;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease),
              background 0.3s var(--ease);
}
.filter[aria-pressed="true"] {
  color: var(--bg);
  background: var(--fg);
  border-color: var(--fg);
}
@media (hover: hover) {
  .filter:not([aria-pressed="true"]):hover {
    color: var(--fg);
    border-color: var(--fg-dim);
  }
}

/* ---- the whole row has to fit a phone ------------------------------

   ALL · COMMERCIAL · MUSIC · SOCIAL · ABOUT, plus the mark, comes to
   about 437px at desktop sizing. An iPhone gives you 390 less the
   gutters, so ABOUT — the last chip, and the one nobody would think to
   look for — sat off the right-hand edge behind a sideways swipe. A
   navigation item you have to discover by scrolling is a navigation
   item most people never find.

   Nothing is renamed or hidden; the row is tightened until it fits.
   Padding does most of the work, letter-spacing and the gap do the
   rest, and COMMERCIAL — the one long word — sets the limit. That comes
   to roughly 360px, which clears a 375px SE with the gutters on.

   overflow-x stays as a safety valve for a narrower phone than exists
   today, but on everything current there is nothing to scroll. */
@media (max-width: 620px) {
  .nav__tabs { gap: 6px; }
  .filter {
    padding: 7px 9px;
    letter-spacing: 0.055em;
    font-size: 10.5px;
  }
  .nav__left { gap: 8px; }
}

/* And a genuinely small screen gets one more notch rather than a
   scrollbar. */
@media (max-width: 380px) {
  .nav__tabs { gap: 5px; }
  .filter { padding: 7px 7px; font-size: 10px; letter-spacing: 0.03em; }
}

/* Collage. A 12-column grid with dense packing; each card claims a
   different number of columns and keeps its own natural height, so the
   result is uneven and interlocking rather than a uniform lattice. */
/* Justified rows — the same structure your Wix grid used. Each row is
   filled left to right and scaled so it lands exactly on the container
   width; every item in a row shares one height, and its width comes from
   the true shape of the file. Nothing is cropped, nothing balloons, and
   because the row is solved as a whole, every gap is identical.

   Widths and heights are set in JS (Work.justify). CSS just holds the
   shell and the fallback for before that runs. */
.grid {
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: var(--gap);
  padding: 0 var(--gap) clamp(70px, 9vw, 130px);
  --gap: clamp(4px, 0.55vw, 9px);
}

.card {
  position: relative;
  display: block;
  width: 100%;
  background: var(--bg-lift);
  overflow: hidden;
  text-align: left;
  /* Entrance animation, driven by IntersectionObserver */
  opacity: 0;
  transform: translateY(26px);
  transition: opacity 0.75s var(--ease), transform 0.75s var(--ease);
}
.card.is-in { opacity: 1; transform: none; }

.card__media {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: var(--bg-lift);
  line-height: 0;
}

/* The box is derived from each file's own proportions, so "cover" lands
   exactly on the edges — it fills the frame without ever cutting into it.
   Before layout runs, the ratio hint from projects.js holds the slot. */
.card__media img,
.card__media video {
  display: block;
  width: 100%;
  height: 100%;
  aspect-ratio: var(--ratio, 16 / 9);
  object-fit: cover;
  transition: transform 0.9s var(--ease), filter 0.6s var(--ease);
}
.grid.is-justified .card__media img,
.grid.is-justified .card__media video { aspect-ratio: auto; }

@media (hover: hover) {
  .card:hover .card__media img,
  .card:hover .card__media video { transform: scale(1.045); }
}

/* ---------- POINTING AT ONE PIECE ------------------------

   Hovering a card should feel like leaning in on it. So everything
   else recedes: desaturated, softened, dimmed — and the one under the
   pointer lifts, and drifts very slightly against the pointer, which
   is the parallax. The drift is subtle on purpose. Enough to feel
   like the picture has depth, not enough to look like it's sliding
   about.

   The recede is on the sibling's media rather than the whole card so
   the caption underneath is unaffected, and blur+grayscale sit on
   their own compositor layer so this stays cheap with forty of them.

   Pointer devices only, and off entirely under reduced-motion. */
@media (hover: hover) {
  /* Only the one under the pointer changes. The rest of the grid is
     left exactly as it is — full colour, sharp.

     Blur and darken on the card you've selected, behind its caption, so
     the title reads cleanly over a softened still. Colour is kept: the
     work stays the work. */
  .card.is-hover { z-index: 3; }

  .card.is-hover .card__media img,
  .card.is-hover .card__media video {
    filter: blur(4px) brightness(0.62);
    transform:
      scale(1.06)
      translate3d(var(--par-x, 0px), var(--par-y, 0px), 0);
    /* Faster than the 0.9s idle transition, so the parallax tracks the
       pointer instead of lagging behind it like treacle. */
    transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
                filter 0.4s var(--ease);
  }
}

@media (prefers-reduced-motion: reduce) {
  .card.is-hover .card__media img,
  .card.is-hover .card__media video { transform: none; }
}

/* The veil: blurs and darkens the artwork behind the caption. Using
   backdrop-filter rather than filtering the <img> keeps it on its own
   compositor layer, so it animates without re-rasterising the photo. */
.card__veil {
  position: absolute;
  inset: 0;
  /* The still itself is now greyscaled and blurred on hover, so the
     veil only has to carry a little extra darkening for the text to sit
     on. Leaving the old blur(14px) here would stack on top of that and
     turn the card to soup. */
  background: rgba(8, 8, 9, 0.42);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  opacity: 0;
  transition: opacity 0.55s var(--ease);
  pointer-events: none;
}

/* No hardware backdrop-filter (older Firefox, some Android): fall back
   to a plain scrim so the text is still readable. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .card__veil { background: rgba(8, 8, 9, 0.72); }
}

/* Caption — hidden until revealed, then centred in the card */
.card__body {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 16px;
  text-align: center;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.5s var(--ease), transform 0.55s var(--ease);
  pointer-events: none;
}

/* Revealed: on hover, on keyboard focus, or — on touch, where hover
   doesn't exist — when the card scrolls into the middle of the screen. */
.card:hover .card__veil,
.card:focus-visible .card__veil,
.card.is-focus .card__veil { opacity: 1; }

.card:hover .card__body,
.card:focus-visible .card__body,
.card.is-focus .card__body { opacity: 1; transform: none; }

/* Two lines: who it was for, then what it was.

   Same monospaced face as the intro's timecode and film name, so the
   grid and the opening read as one piece of typography rather than two
   different sites. Brand carries the weight; the title sits under it,
   lighter and dimmer, the way a slate reads. */
/* Deliberately the same type as the film strip's hover caption — same
   sizes, weights and tracking, not merely similar. A card and a frame
   are the same object at two scales, so the label on them should be
   identical rather than each having its own idea. If you change one,
   change the other: .frame__brand / .frame__name are the pair. */
.card__title {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  max-width: 26ch;
  font-family: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
  text-transform: uppercase;
  line-height: 1.25;
}

.card__brand {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.12em;
  color: var(--fg);
}

.card__name {
  font-size: 9px;
  font-weight: 400;
  letter-spacing: 0.14em;
  color: var(--fg-dim);
}

@media (min-width: 900px) {
  .card__brand { font-size: 13px; }
  .card__name  { font-size: 10.5px; }
}

/* No badge on the cards. The cursor is the play button everywhere else
   on the site, and a corner glyph appearing on every thumbnail you pass
   was saying the same thing a second time, less well. */

.empty {
  grid-column: 1 / -1;
  padding: 60px var(--pad);
  text-align: center;
  color: var(--fg-dim);
}

/* ============================================================
   FILM PAGE
   ============================================================ */
.film {
  padding: clamp(14px, 2vw, 28px) var(--pad) clamp(70px, 9vw, 120px);
  max-width: 1500px;
  margin: 0 auto;
}

/* The stage takes each film's own shape, not a blanket 16:9.

   A 4:3 film in a 16:9 hole doesn't sit on black — Vimeo's player fills
   the leftover with white, so the page grew two bright bars either side
   of the work. Nothing in our stylesheet can reach inside their iframe,
   so the frame has to be the right shape to begin with. --stage-ratio is
   set per film from the ratio already recorded in projects.js. */
.film__stage {
  position: relative;
  width: 100%;
  aspect-ratio: var(--stage-ratio, 16 / 9);

  /* Cap the height with max-WIDTH, never max-height.

     max-height fights aspect-ratio and wins: the width stays at 100%,
     the height gets clipped, and the box quietly stops being the shape
     you asked for. That was the white — a 1.36 film in a 1.68 frame,
     with Vimeo's player painting the leftover white. Capping the width
     instead lets the ratio hold and the height follows from it.
     --stage-ar is the same number as --stage-ratio, pre-divided, because
     calc() can't do anything with "497 / 364".

     --stage-chrome is the vertical room the page needs under the film
     for the title, credits and step links. It changes with the layout
     rather than being a single compromise number: when the type sits
     beside the frame there's almost nothing to reserve. */
  max-width: calc((100dvh - var(--nav-h) - var(--stage-chrome, 9rem))
                  * var(--stage-ar, 1.7778));
  margin-inline: auto;

  background: #000;
  overflow: hidden;
}
.film__stage iframe,
.film__stage video,
.film__stage img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  border: 0;
  object-fit: cover;
  display: block;
  background: #000;   /* belt and braces if a player still letterboxes */
}

/* Credits. A definition list in the instrument face, the same
   treatment as the suite block on About — label left, value right,
   nothing shouting. On a portrait film this is what fills the column
   beside the frame; on a landscape one it sits under the title. */
.film__credits {
  margin: clamp(22px, 3vw, 34px) 0 0;
  padding-top: 16px;
  border-top: 1px solid var(--line);
  max-width: 420px;
}
.film__credit {
  display: flex;
  gap: 14px;
  align-items: baseline;
  padding: 4px 0;
}
.film__credit dt {
  flex: 0 0 auto;
  width: 76px;
  margin: 0;
  color: var(--fg-faint);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
.film__credit dd {
  margin: 0;
  color: var(--fg-dim);
  font-size: 12px;
  letter-spacing: 0.02em;
}
@media (max-width: 620px) {
  .film__credit { flex-direction: column; gap: 2px; }
  .film__credit dt { width: auto; }
}

/* The one row in the list that goes somewhere. Reads as part of the
   credits until you're on it, then admits it's a link. */
.film__ext {
  color: var(--fg-dim);
  text-decoration: none;
  border-bottom: 1px solid rgba(242,240,237,0.22);
  padding-bottom: 1px;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease);
}
.film__ext span { font-size: 0.9em; opacity: 0.7; }
@media (hover: hover) {
  .film__ext:hover { color: var(--fg); border-bottom-color: var(--fg); }
}

/* ---- films narrower than 16:9 --------------------------------------

   Anything squarer than widescreen — 4:3, 1:1, 4:5, 9:16 — is limited
   by the height of the window, not its width, so the stacked layout
   wastes it twice over: the stage is capped as if it had a wide film in
   it, and the title sits underneath, where a tall film has already
   pushed it off the screen.

   So those films get a two-column page: film on the left, everything
   that was under it moved alongside. The empty space either side of a
   narrow film stops being empty and starts being where the type lives.
   A 4:3 gets the same treatment as a 9:16 — it just ends up with a
   wider frame and a narrower column, which the grid handles on its own.

   Stacked on a phone, a narrow film needs much more room reserved
   underneath it than a widescreen one, or the title lands below the
   fold. That's the only thing --stage-chrome has to say. */
@media (max-width: 899px) {
  .page.is-sided .film__stage { --stage-chrome: 15rem; }
}

@media (min-width: 900px) {
  /* An explicit width, not a percentage. The stage sits in an `auto`
     track, so a percentage would be measured against a column whose
     size depends on the stage — and a circular measurement resolves to
     zero, which is exactly what it did. Deriving the width from the
     height cap breaks the loop.

     min() against a viewport width is the second half of that: on a
     tall window a 4:3 would otherwise want more width than the page
     has, and max-width:100% can't help for the same circular reason.
     68vw always leaves a column for the type. */
  .page.is-sided .film__stage {
    --stage-chrome: 5rem;
    width: min(
      (100dvh - var(--nav-h) - 5rem) * var(--stage-ar, 0.8),
      68vw
    );
    max-width: none;
    margin-inline: 0;
  }

  .page.is-sided .film {
    display: grid;
    grid-template-columns: auto minmax(260px, 1fr);
    grid-template-rows: auto auto 1fr;
    gap: clamp(28px, 4vw, 64px);
    align-items: start;
    max-width: 1500px;
  }
  .page.is-sided .film__stage { grid-row: 1 / span 3; }
  .page.is-sided .film__credits { margin-top: 0; align-self: start; }
  .page.is-sided .film__head {
    padding-top: 0;
    flex-direction: column;
    align-items: flex-start;
    gap: 14px;
  }
  /* Down the right-hand column the step links read as a list, not as a
     bar pinned across the bottom of a film. */
  .page.is-sided .film__nav {
    align-self: end;
    flex-direction: column;
    align-items: flex-start;
    gap: 10px;
    margin-top: 0;
  }
  .page.is-sided .film__step--next { text-align: left; }
}

/* ---- collage films --------------------------------------------------

   A live visualiser isn't one film, so it doesn't get one frame. Four
   loops overlapping, in the arrangement Adam built on the old site.

   The stage becomes the collage box: no black fill, no clipping — the
   tiles are meant to run over each other and over the edge, and a
   background would put a rectangle behind an arrangement whose whole
   point is that it isn't one. */
.film__stage.is-collage {
  background: none;
  overflow: visible;
  --stage-chrome: 11rem;
}
.collage__tile {
  position: absolute;
  overflow: hidden;
  background: #000;
}
.collage__tile img,
.collage__tile video {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
  border: 0;
}
/* The film tile carries the play control; the loops around it are
   decoration and shouldn't invite a click that does nothing. */
.collage__tile:not(.is-film) { pointer-events: none; }

/* Title inside the picture, bottom-left, the way it sat on the old
   site — overlapping the tall tile rather than clearing it. */
@media (min-width: 900px) {
  /* Stage and title share one grid cell rather than the title being
     absolutely positioned. Overlapping through the grid keeps the row
     as tall as the collage, so the credits below still know where the
     bottom is — which an absolutely positioned title would have hidden
     from them. */
  .page.is-collage .film {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    max-width: 1400px;
    position: relative;
  }
  /* The way out goes to the empty top-right of the arrangement rather
     than under the title, where it would have landed on the picture.
     pointer-events comes back on for it — the header above is inert so
     that clicks fall through to the film behind the type. */
  .page.is-collage .film__back {
    position: absolute;
    top: 0; right: 0;
    z-index: 6;
    pointer-events: auto;
  }
  .page.is-collage .film__stage,
  .page.is-collage .film__head { grid-area: 1 / 1; }

  .page.is-collage .film__head {
    align-self: end;
    justify-self: start;
    z-index: 5;
    padding-top: 0;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    /* Percentage margins resolve against width, not height, so this is
       the 11% of the collage's height it looks like, divided by the
       collage's 1.154 aspect. */
    margin-bottom: 9.5%;
    pointer-events: none;
  }
  /* Everything under the arrangement, clear of it. */
  .page.is-collage .film__credits { margin-top: clamp(30px, 5vw, 56px); }
}
/* Stacked on a phone the overlap still reads, but the title can't sit
   on top of it — there isn't the room. It goes back underneath. */
@media (max-width: 899px) {
  .film__stage.is-collage { --stage-chrome: 16rem; }
}

/* The player, laid over the poster and faded up once it's ready. */
.film__embed {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.5s var(--ease);
}
.film__embed.is-ready { opacity: 1; }

/* The last hairline of white.

   Even with the frame at the film's exact ratio, the browser rounds the
   box to whole pixels and the player rounds the video inside it, and the
   two don't always round the same way. That leaves a sub-pixel seam —
   too thin to be a bar, plenty bright enough to notice against black.

   So the player is given a pixel of overhang on every side and the stage
   clips it. Nothing of the film is lost: the overhang lands in the
   player's own letterbox area, not the picture. Paired with
   transparent=1 on the Vimeo embed, which lets our black show through
   instead of their white. */
.film__stage iframe {
  inset: -1px;
  width: calc(100% + 2px);
  height: calc(100% + 2px);
}

/* Poster state — a real play control over the still, so nothing from
   Vimeo or YouTube is fetched until it's actually wanted. */
/* No badge and no veil. The cursor becomes the play button over the
   stage, so putting a second one on the picture as well says it twice
   and leaves a mark parked on the loop the whole time you're reading the
   page. This is now just an invisible hit area covering the still. */
.film__play {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: transparent;
  border: 0;
  cursor: pointer;
}
/* Stacks the icon over a label when the film opens elsewhere */
.film__play {
  flex-direction: column;
  gap: 14px;
  text-decoration: none;
  color: var(--fg);
}
.film__playlabel {
  font-style: normal;
  color: var(--fg);
  letter-spacing: 0.14em;
  text-shadow: 0 1px 12px rgba(0,0,0,0.7);
}

/* On every film, not just the ones that open elsewhere.

   It used to be hidden whenever the film could play inline, on the
   reasoning that the cursor already becomes a play button over the
   stage. That reasoning fails on a phone, where there is no cursor at
   all — so a still sat there with no indication it was a film. And it
   was too clever even on a desktop: you had to move onto the picture to
   discover it was playable. A play button is the one piece of interface
   nobody needs explained. */
.film__play span {
  display: grid;
  place-items: center;
  width: clamp(58px, 6vw, 84px);
  aspect-ratio: 1;
  border: 1px solid rgba(242,240,237,0.6);
  border-radius: 50%;
  background: rgba(10,10,11,0.3);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  transition: transform 0.4s var(--ease), background 0.4s var(--ease);
}
@media (hover: hover) {
  .film__play:hover span { transform: scale(1.06); background: rgba(10,10,11,0.5); }
}
/* It goes once the film is running — the button has done its job, and
   leaving it over a playing picture is just a mark on the work. */
.film__stage.is-live .film__play { opacity: 0; pointer-events: none; }
.film__play { transition: opacity 0.35s var(--ease); }

.film__soon {
  position: absolute;
  inset: auto 0 0 0;
  padding: 14px var(--pad);
  background: linear-gradient(180deg, transparent, rgba(10,10,11,0.85));
  color: var(--fg-dim);
  text-align: center;
}

.film__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 20px;
  padding-top: clamp(16px, 2vw, 26px);
}
.film__title {
  margin: 0;
  font-size: clamp(1.4rem, 3.4vw, 2.6rem);
  line-height: 1.05;
  font-weight: 600;
  letter-spacing: -0.025em;
  text-transform: uppercase;
}
.film__meta { margin: 8px 0 0; color: var(--fg-dim); }
.film__back {
  flex: 0 0 auto;
  color: var(--fg-dim);
  padding: 10px 0;
  transition: color 0.3s var(--ease);
}
@media (hover: hover) { .film__back:hover { color: var(--fg); } }

.film__nav {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  margin-top: clamp(30px, 4vw, 54px);
  padding-top: 20px;
  border-top: 1px solid var(--line);
}
.film__step {
  color: var(--fg-dim);
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  max-width: 46%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.3s var(--ease);
}
.film__step--next { text-align: right; }
@media (hover: hover) { .film__step:hover { color: var(--fg); } }

/* Grid cards with no poster yet — a labelled placeholder rather than a
   broken image, so the layout still solves correctly. */
.card__blank {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 14px;
  text-align: center;
  color: var(--fg-faint);
  background:
    repeating-linear-gradient(45deg,
      rgba(242,240,237,0.03) 0 10px, transparent 10px 20px),
    var(--bg-lift);
}

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

   Two columns — words on the left, picture on the right — and the pair
   of them held together as one block in the middle of the page. So the
   text ranges left against its own edge rather than the window's, which
   is what stops a wide screen from stretching one line of type across
   a metre of nothing.

   It collapses to a single column below 760px, picture first, because
   at that width two columns would leave neither enough room. */
/* The page fills the window, and the client strip sits at the foot of
   it. Before, everything was packed into the top third and the bottom
   half of the screen was empty — which read as a page that had failed
   to load the rest of itself rather than a page that was finished. */
/* One screen, no scroll.

   Everything on About is meant to be taken in at once — a face, three
   paragraphs, a spec and a way to get in touch. Scrolling would mean
   something is hidden below the fold, and none of it is worth hiding.

   The page is locked to the window rather than the content being
   allowed to run past it: exactly the height between the nav and the
   bottom, with the card centred in whatever room that leaves. The
   client marquee sits under it as a strip, not as more page. */
/* The view is the screen; the card takes what's left after the strip.

   .about used to be the full height itself with the client strip inside
   it. Now the view holds both — card first, taking all the room going,
   and the strip under it at its own height. That's what lets the strip
   reach the window edges: it's no longer inside a padded, clipped box. */
#view-about {
  flex-direction: column;
  height: calc(100dvh - var(--nav-h));
  overflow: hidden;
}
/* display ONLY when it's the active view.

   `.view { display: none }` is what hides the pages you aren't on, and
   it's a class — so an id selector setting `display: flex` outranked it
   and About was being laid out on every route. You'd never see it on
   the work page until you scrolled to the bottom of the grid, and there
   it was. Anything that sets display on a view has to be scoped to
   .is-active or it defeats the mechanism that hides it. */
#view-about.is-active { display: flex; }
.about {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  padding: clamp(18px, 3.5vh, 42px) var(--pad) clamp(28px, 5vh, 56px);
  /* Narrower than it was. The type was capped at a 52-character measure
     inside a much wider column, so a dead gutter opened up between the
     end of the line and the picture. Bringing the container in means
     the column IS the measure, and the only gap left is the one that
     was designed. */
  max-width: 940px;
  margin-inline: auto;
}
/* auto margins top and bottom: the block floats in whatever room is
   left between the nav and the strip, so it stays optically centred at
   any window height without being fixed to one. */
.about__card { margin-block: auto; }

.about__card {
  width: 100%;
  max-width: 940px;
  margin-inline: auto;
}
/* Tighter, and the picture takes a bigger share. The two columns are
   set to end level with each other, so the gutter between them reads as
   one gap rather than the text stopping short of a photograph that
   carries on. */
.about__inner {
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(22px, 3vw, 40px);
  padding: 0;
}
@media (min-width: 760px) {
  .about__inner {
    grid-template-columns: minmax(0, 1fr) clamp(280px, 36%, 360px);
    /* Start, not stretch.

       Stretching made the picture as tall as whatever the copy happened
       to be, and the copy is long — so a 4:5 portrait was being forced
       into a box nearer 2:3 and object-fit: cover ate the sides to fill
       it. That's why it looked cropped in and too vertical: nothing was
       wrong with the photograph, it was being asked to be a shape it
       isn't. Let the column be its own height and the crop goes away. */
    align-items: start;
  }
}

/* The right-hand column: picture, then contact under it. */
.about__side {
  display: flex;
  flex-direction: column;
  gap: 22px;
  min-width: 0;
}

.about__name {
  margin: 0 0 6px;
  font-size: clamp(1.8rem, 6vw, 3rem);
  line-height: 0.95;
  font-weight: 600;
  letter-spacing: -0.03em;
  text-transform: uppercase;
}
.about__role { color: var(--fg-dim); margin: 0 0 18px; }
.about__bio p {
  margin: 0 0 12px;
  font-size: 16px;
  line-height: 1.6;
}
@media (min-width: 900px) { .about__bio p { font-size: 17px; } }

/* The picture fills its column, whatever shape that column ends up.

   It was locked to the photograph's own 4:5, which meant a fixed block
   sitting next to a variable column of type — on a short window the two
   ended at completely different heights and the page had to scroll to
   fit them. Letting it stretch and crop means the layout defines the
   block and the photograph does as it's told. object-position holds the
   figure in frame as it crops rather than trimming him off one edge. */
.about__portrait {
  display: block;
  width: 100%;
  height: auto;

  /* The file's own shape, 1000 x 1250. Declared rather than left to the
     image's intrinsic size so the space is reserved before it loads and
     nothing under it jumps when it arrives.

     object-fit is still cover as a backstop — if the portrait is ever
     swapped for one that isn't 4:5 it will fill this box rather than
     distort — but with the ratio matching the file, it has nothing to
     crop. */
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: 50% 30%;
  filter: grayscale(1) contrast(1.05);
  transition: filter 0.7s var(--ease);
}
@media (max-width: 759px) {
  /* Above the copy on a phone, and capped so it can't take the whole
     screen before you've read a word. */
  .about__side { order: -1; }
  .about__portrait { max-height: 42vh; width: auto; margin-inline: auto; }
}
@media (hover: hover) { .about__portrait:hover { filter: grayscale(0); } }

/* The masthead. Name on the left, tally on the far right, sitting on
   the same baseline and separated by a hairline that runs the whole
   width of the card — so the two are one line of information rather
   than a heading with something stuck next to it. */
.about__masthead {
  padding-bottom: 14px;
  margin-bottom: clamp(18px, 2.5vw, 28px);
  border-bottom: 1px solid var(--line);
}

/* The tally.

   Two goes at this were too loud. Set large in the display sans it was
   a headline making a claim; set large in the mono it was a readout
   demanding to be read. It's a fact you happen to notice, so it's the
   size of body copy, on one quiet line, with the number a shade
   brighter than the word after it and no other emphasis at all. */
.about__views {
  display: flex;
  align-items: baseline;
  gap: 7px;
  margin-top: 10px;
  font-size: 14px;
  line-height: 1.4;
  transition: opacity 0.55s var(--ease), transform 0.7s var(--ease);
}
/* Twenty-two pixels, not ten. On a fourteen-pixel line ten is a twitch;
   this reads as the figure arriving. */
.about__views.is-waiting {
  opacity: 0;
  transform: translateY(22px);
}
.about__viewsN {
  color: var(--fg-dim);
  font-weight: 500;
  letter-spacing: -0.01em;
  font-variant-numeric: tabular-nums;
  display: inline-flex;
  align-items: baseline;
}
.about__viewsL {
  color: var(--fg-faint);
  font-weight: 400;
}
@media (prefers-reduced-motion: reduce) {
  .about__views, .about__views.is-waiting {
    opacity: 1; transform: none; transition: none;
  }
}

/* The suite. A rule above it and nothing else — no box, no heading.
   It's a footnote to the copy, and anything more would give it the
   weight of a section it doesn't deserve. */
.about__setup {
  margin-top: 26px;
  padding-top: 18px;
  border-top: 1px solid var(--line);
}
.about__spec {
  display: flex;
  gap: 14px;
  align-items: baseline;
  padding: 3px 0;
}
.about__spec dt {
  flex: 0 0 auto;
  width: 86px;
  margin: 0;
  color: var(--fg-faint);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
.about__spec dd {
  margin: 0;
  color: var(--fg-dim);
  font-size: 12px;
  letter-spacing: 0.02em;
}
@media (max-width: 620px) {
  .about__spec { flex-direction: column; gap: 2px; padding: 6px 0; }
  .about__spec dt { width: auto; }
}

/* Sits under the picture now, not at the foot of the copy — so no top
   margin pushing it away from what it belongs to. */
.about__contact { margin-top: 0; }
.about__label { color: var(--fg-faint); display: block; margin-bottom: 10px; }
.about__email {
  font-size: clamp(0.95rem, 3vw, 1.1rem);
  letter-spacing: -0.01em;
  display: inline-block;
  color: var(--fg);
  transition: color 0.4s var(--ease);
  overflow-wrap: anywhere;
}
/* No rule under it. It's the only address on the page and it sits under
   a label that says CONTACT — underlining it as well is belt, braces and
   a second belt. It brightens on hover, which is enough. */
@media (hover: hover) { .about__email:hover { color: #fff; } }

.about__links {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
/* Round targets, glyph only. Kept at 42px so they're still a proper
   tap target on a phone even though the mark inside is 16px. */
.about__links a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 42px;
  height: 42px;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--fg-dim);
  transition: color 0.35s var(--ease), border-color 0.35s var(--ease);
}
/* No fill. The cursor being swallowed is the whole event here — a
   button that also lights up is two things announcing the same
   moment, and the quieter one was right. */
@media (hover: hover) {
  .about__links a:hover,
  .about__links a:focus-visible { border-color: var(--fg); color: var(--fg); }
}

/* The glyph tracks the label's colour, so it lifts with it on hover
   rather than being a separate thing that stays put. */
.about__icon {
  flex: 0 0 auto;
  display: block;
  overflow: visible;
}


/* (the filled hover lives with the rule above — this duplicate used to
   set the colour back to --fg and quietly cancel it) */

/* ---- client marquee -------------------------------------------
   Two identical copies of the list sit side by side and the track
   slides exactly one copy's width, then snaps back. Because copy two
   is at that moment exactly where copy one started, the reset is
   invisible and the scroll never appears to end.
   ---------------------------------------------------------------- */
/* Full bleed. The strip is pulled out of the About column and run the
   whole width of the window — 100vw, offset by half the difference
   between that and its container. It's the one thing on the page that
   should feel like it has no edges. calc with 50% rather than a fixed
   value so it stays honest at any container width, and 100vw rather
   than 100% so a scrollbar can't shift it. */
/* Full bleed, and now actually able to be.

   100vw with the margins that pull it back out to the window edges —
   the same trick as before, it just never worked while it was sitting
   inside a box with padding and overflow:hidden. */
.clients {
  flex: 0 0 auto;
  margin-top: 0;
  width: 100vw;
  max-width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  padding-bottom: clamp(14px, 2.5vh, 28px);
}

.marquee {
  position: relative;
  overflow: hidden;
  margin-top: 18px;
  /* Fade the ends so names slide in and out rather than being chopped
     off at a hard edge. */
  -webkit-mask-image: linear-gradient(90deg,
    transparent, #000 5%, #000 95%, transparent);
  mask-image: linear-gradient(90deg,
    transparent, #000 5%, #000 95%, transparent);
}

.marquee__track {
  display: flex;
  width: max-content;
  align-items: center;
  gap: clamp(44px, 5.5vw, 86px);
  animation: marquee var(--speed, 34s) linear infinite;
  will-change: transform;
}
/* It doesn't stop for anyone. A strip that halts under the pointer
   turns into a control, and this isn't one — it's a run of names going
   past, and stopping it draws far more attention to it than letting it
   run. */

@keyframes marquee {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-50%, 0, 0); }
}

/* No heading at all. A row of eighteen client logos doesn't need
   labelling — it's self-evident, and a title over it only added
   ceremony to something that reads instantly on its own. */

/* No chips, no rules — the marks are the row.

   The outline made sense while these were all names in type and needed
   something to hold them together. With real logos in there it was a
   frame round a frame, and the one thing a client strip shouldn't do is
   draw attention to its own furniture. The names that are still type
   just sit in the line at the same weight as the marks beside them. */
.client {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  color: var(--fg-dim);
  font-family: "IBM Plex Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace;
  font-size: clamp(12px, 1.5vw, 15px);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
  transition: color 0.4s var(--ease);
}

/* One at a time. Lighting the whole row on hover meant the strip
   changed state wholesale the moment the pointer went anywhere near it,
   which is a lot of movement to report one logo. */
@media (hover: hover) {
  .client:hover { color: var(--fg); }
  .client:hover img { opacity: 1; }
}

/* Every file is now a 120px-tall band with the width trimmed hard to
   the artwork, already white, already scaled so each mark covers the
   same share of that band. All the fitting was done to the files, which
   is the only way a wordmark five times wider than a roundel ever sits
   at the same weight as it.

   Because the width is tight to the ink, the gap set on the track is
   the gap you actually see. It wasn't before: every file was the same
   width, so each mark carried a different amount of built-in air and
   the spacing only looked even in the CSS.

   The white filter stays as a safety net — drop a black or full-colour
   logo straight in and it still comes out right, just without the
   optical balancing. */
.client img {
  display: block;
  height: clamp(32px, 3.9vw, 42px);
  width: auto;
  filter: brightness(0) invert(1);
  opacity: 0.78;
  transition: opacity 0.4s var(--ease);
}
.client img + span { display: none; }

@media (hover: hover) { .client:hover img { opacity: 1; } }

/* Reduced motion: no scrolling. The names simply wrap and sit still. */
@media (prefers-reduced-motion: reduce) {
  .marquee { -webkit-mask-image: none; mask-image: none; }
  .marquee__track {
    animation: none;
    width: auto;
    flex-wrap: wrap;
    gap: 14px 34px;
  }
  .marquee__track .is-dupe { display: none; }
}

/* ============================================================
   LIGHTBOX
   ============================================================ */
.lb {
  position: fixed;
  inset: 0;
  z-index: 500;
  background: rgba(6,6,7,0.96);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  display: flex;
  flex-direction: column;
  padding: max(14px, env(safe-area-inset-top, 0px)) 14px
           max(14px, env(safe-area-inset-bottom, 0px));
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.45s var(--ease), visibility 0.45s;
}
.lb.is-open { opacity: 1; visibility: visible; }
@media (min-width: 900px) { .lb { padding: 28px 40px; } }

.lb__bar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  flex: 0 0 auto;
  padding-bottom: 14px;
}
.lb__title { margin: 0; font-size: 15px; font-weight: 500; }
@media (min-width: 900px) { .lb__title { font-size: 18px; } }
.lb__sub { color: var(--fg-dim); margin-top: 3px; display: block; }

.lb__close {
  flex: 0 0 auto;
  width: 44px; height: 44px;
  border: 1px solid var(--line);
  border-radius: 50%;
  display: grid;
  place-items: center;
  transition: background 0.3s var(--ease), transform 0.3s var(--ease);
}
@media (hover: hover) {
  .lb__close:hover { background: rgba(242,240,237,0.1); transform: rotate(90deg); }
}

.lb__stage {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  place-items: center;
}
.lb__frame {
  width: 100%;
  max-height: 100%;
  aspect-ratio: 16 / 9;
  background: #000;
  position: relative;
  overflow: hidden;
}
.lb__frame iframe,
.lb__frame video,
.lb__frame img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  border: 0;
  object-fit: contain;
}
.lb__note {
  color: var(--fg-dim);
  text-align: center;
  padding: 24px;
  max-width: 40ch;
  margin: 0 auto;
}

/* ============================================================
   MOTION PREFERENCES
   Everything above degrades to instant, no exceptions.
   ============================================================ */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
  .card { opacity: 1; transform: none; }
  .intro__title span, .intro__sub, .intro__hint, .enter { opacity: 1; transform: none; }
}

/* Link out to the film's home — the escape hatch when a channel has
   embedding switched off. */
.film__out {
  color: var(--fg-dim);
  border-bottom: 1px solid var(--line);
  padding-bottom: 1px;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease);
}
@media (hover: hover) {
  .film__out:hover { color: var(--fg); border-color: var(--fg); }
}

/* The nav mark, AK opening out to ADAMK.

   Two images stacked in a column inside a window one image tall; the
   column slides up by half its height to bring the second into view.
   The window's width changes at the same time, because the two marks
   are nothing like the same shape — hold the width still and the short
   one sits in a pool of empty space waiting for the long one.

   Both are cut from the same logo file, so this is one piece of
   artwork lengthening rather than two logos swapping. */
.navmark {
  display: block;
  height: var(--nav-h-mark, 22px);
  /* AK is 1.785:1, the full mark 4.56:1. */
  width: calc(var(--nav-h-mark, 22px) * 1.785);
  overflow: hidden;
  transition: width 0.55s var(--ease);
}
.navmark__reel {
  display: block;
  height: 200%;
  transform: translateY(0);
  transition: transform 0.55s var(--ease);
}
.navmark__i {
  display: block;
  height: 50%;
  width: auto;
  max-width: none;
}
@media (hover: hover) {
  .nav__logo:hover .navmark { width: calc(var(--nav-h-mark, 22px) * 4.56); }
  .nav__logo:hover .navmark__reel { transform: translateY(-50%); }
}
.navmark.is-open { width: calc(var(--nav-h-mark, 22px) * 4.56); }
.navmark.is-open .navmark__reel { transform: translateY(-50%); }

@media (prefers-reduced-motion: reduce) {
  .navmark, .navmark__reel { transition: none; }
}

/* ============================================================
   BOOT  —  an edit suite opening a project
   ============================================================

   Every change lands on a whole frame — steps(1) throughout, nothing
   eased. The moment any of this gets a smooth curve it stops reading as
   equipment and starts reading as branding. */
/* Sits UNDER the grain canvas at 9999, not over it.

   It had its own grain before, and its own grain is exactly the
   problem: the texture on the loading screen was not the texture on the
   site, so the handover was a visible change of surface even though the
   colour underneath was identical. Slide the whole thing beneath the
   real grain layer and the same film runs over both — the loading
   screen and the intro are now the same picture, and all that happens
   at the end is that some type fades off it.

   Opaque, and painted in exactly the same colour the body is. Leaving
   it transparent did make it one surface, but it also meant the page
   was visible through it — which is worse than a seam. Because it sits
   under the grain, the same texture runs over both it and the intro, so
   a solid fill of --bg is indistinguishable from the page it covers. */
.boot {
  position: fixed;
  inset: 0;
  z-index: 9990;
  background: var(--bg);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(20px, 3.5vh, 32px);
  overflow: hidden;
}
/* A cut, not a dissolve. The intro is sitting right behind this in the
   same colour, so fading the screen off it only ever showed you two
   things crossing over. Straight from one to the other. */
.boot.is-done { display: none; }

/* ---- the mark, holding and glitching -----------------------------

   It doesn't move and it never settles. Small, heavily softened and
   sitting at the bottom of the range, with a jitter that runs on whole
   frames — position knocked sideways, opacity dropping out, the blur
   breathing. Every value steps; nothing here is eased, because the
   moment any of it gets a curve it reads as a logo animating rather
   than a picture refusing to lock.

   No colour separation. The mark tears and dims, it doesn't fringe. */
.boot__mark {
  position: relative;
  z-index: 1;
  animation: bootJitter 1.1s steps(1) infinite;
}
.boot__mark img {
  display: block;
  width: min(13vw, 68px);
  height: auto;
  filter: blur(1.6px);
  opacity: 0.2;
  animation: bootBreathe 0.75s steps(1) infinite;
}
@keyframes bootJitter {
  0%, 22%   { transform: translate3d(0, 0, 0); }
  24%       { transform: translate3d(-3px, 0, 0); }
  26%       { transform: translate3d(2px, -1px, 0); }
  28%, 61%  { transform: translate3d(0, 0, 0); }
  63%       { transform: translate3d(4px, 0, 0); }
  65%       { transform: translate3d(-1px, 1px, 0); }
  67%, 100% { transform: translate3d(0, 0, 0); }
}
@keyframes bootBreathe {
  0%        { opacity: 0.2;  filter: blur(1.6px); }
  30%       { opacity: 0.08; filter: blur(2.4px); }
  46%       { opacity: 0.26; filter: blur(1.1px); }
  62%       { opacity: 0.14; filter: blur(2px); }
  80%, 100% { opacity: 0.2;  filter: blur(1.6px); }
}

/* ---- the line ----------------------------------------------------

   24 discrete steps. A bar that fills smoothly is a designer's bar; one
   that jumps is a machine's, and this is a machine opening a job. */
.boot__line {
  position: relative;
  z-index: 1;
  width: min(74vw, 520px);
  height: 1px;
  background: rgba(242, 240, 237, 0.16);
}
/* The run is carried by a class so it can be restarted. Clicking the
   mark in the nav plays the whole thing again before the intro, and an
   animation that has already finished will not replay unless it is
   taken off the element and put back. --boot-ms lets the repeat be
   shorter than the first: nothing is actually loading the second time,
   so making anyone wait the full beat again would be a lie. */
.boot__fill {
  position: absolute;
  inset: 0;
  background: var(--fg);
  transform: scaleX(0);
  transform-origin: left;
}
.boot.is-run .boot__fill {
  animation: bootFill var(--boot-ms, 1.7s) steps(24) forwards;
}
@keyframes bootFill { to { transform: scaleX(1); } }

.boot__foot {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: baseline;
  gap: 14px;
  width: min(74vw, 520px);
  justify-content: space-between;
}
.boot__read {
  color: var(--fg-faint);
  letter-spacing: 0.16em;
  white-space: nowrap;
}
.boot__pct {
  color: var(--fg-dim);
  letter-spacing: 0.1em;
  font-variant-numeric: tabular-nums;
}
.boot__read::after {
  content: "";
  display: inline-block;
  width: 0.5em; height: 0.95em;
  margin-left: 0.4em;
  background: var(--fg);
  vertical-align: -0.12em;
  animation: bootCaret 0.5s steps(1) infinite;
}
@keyframes bootCaret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }

@media (max-width: 560px) {
  .boot__read { letter-spacing: 0.1em; }
  .boot__mark img { width: 48px; }
}

@media (prefers-reduced-motion: reduce) {
  .boot.is-run .boot__fill, .boot__mark, .boot__mark img,
  .boot__read::after { animation: none; }
}

/* ============================================================
   CURTAIN  —  between pages
   ============================================================

   Two panels closing from top and bottom and opening again on the far
   side of the swap. A fade would still show the old page leaving and
   the new one arriving; this shows neither, which is the whole reason
   it's here.

   Closes faster than it opens, and on a hard ease-in: a cut should
   arrive quickly and be let go of gently. Under the grain at 9998, so
   the texture runs straight through the cut and the two halves of the
   page feel like one piece of film. */
.curtain {
  position: fixed;
  inset: 0;
  z-index: 9998;
  pointer-events: none;
}
.curtain i {
  position: absolute;
  left: 0; right: 0;
  /* A shade over half, or a sub-pixel seam shows down the middle. */
  height: 50.4%;
  background: var(--bg);
  transform: scaleY(0);
  transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.curtain i:first-child { top: 0;    transform-origin: top; }
.curtain i:last-child  { bottom: 0; transform-origin: bottom; }
.curtain.is-in i {
  transform: scaleY(1);
  transition-duration: 0.17s;
  transition-timing-function: cubic-bezier(0.7, 0, 0.84, 0);
}
@media (prefers-reduced-motion: reduce) { .curtain i { display: none; } }


/* ============================================================
   MIXED TYPE  —  a face per letter
   ============================================================

   Ransom-note setting, done on purpose and kept on a leash.

   Four faces only, all of them heavy or high-contrast so the line still
   holds together as one word: a grotesque, a fat grotesque, a condensed
   sans and a serif. More than four and it stops reading as a decision.

   It is set by hand on the one word that carries it — ENTER, in the
   cursor over the mark. That word is the site's only piece of
   instruction, it appears for a second at a time, and it is the right
   place for the site to be playful. Everywhere else the type stays
   straight, because a client reading a credit doesn't need a joke.

   Each letter keeps the same font-size and sits on the same baseline;
   only the drawing changes. That is what makes it read as one word set
   in four faces rather than four words jammed together. */
.mixed { font-synthesis: none; }
.mixed i {
  font-style: normal;
  display: inline-block;
  vertical-align: baseline;
}
.mixed i.f0 { font-family: "Space Grotesk", sans-serif; font-weight: 700; }
.mixed i.f1 { font-family: "Archivo Black", sans-serif; font-weight: 400; }
.mixed i.f2 { font-family: "Anton", sans-serif; font-weight: 400; letter-spacing: 0.01em; }
.mixed i.f3 { font-family: "Instrument Serif", Georgia, serif; font-weight: 400; }
/* The serif runs small at the same size as the grotesques, and Anton
   runs tall — nudged so the caps line up rather than stepping. */
.mixed i.f3 { font-size: 1.14em; }
.mixed i.f2 { font-size: 1.08em; }

/* Inside the cursor the word is on a white disc that `difference` then
   flips, so the letters have to stay black like the rest of the label. */



/* ---- ENTER, outside the disc ---------------------------------------

   Painted normally rather than difference-blended, which is the whole
   reason it exists as a separate element: inside the blob, black is the
   identity and the word could only ever be a hole showing the page.
   Out here black is black, and it reads on anything the disc happens to
   be carrying.

   It takes the blob's position and none of its stretch or rotation, so
   there is no counter-transform to keep in step — the disc can squash
   and swing and the word simply sits in the middle of it. */
.blob-word {
  position: fixed;
  top: 0; left: 0;
  z-index: 20060;              /* above the disc */
  display: flex;
  gap: 0.14em;
  align-items: center;

  /* Silkscreen — a bitmap face, drawn on a grid.

     Two goes at this were wrong for the same reason. IBM Plex Mono is a
     mono designed for reading, so it reads as a caption; Anton is a
     display face, so it read as a poster. Neither is what the site
     sounds like. The loading screen is a machine talking to itself and
     ENTER is the prompt at the end of it — so the word should be the
     kind of type a machine draws, not the kind a designer sets.

     Silkscreen is made of square pixels with no curves and no optical
     correction at all, which is exactly the point: at this size every
     letter is a handful of hard blocks, and against a defocused film it
     stays crisp where anything with real letterforms goes soft. */
  font-family: "Silkscreen", "VT323", ui-monospace, monospace;
  font-size: 19px;
  font-weight: 700;
  line-height: 1;
  text-transform: uppercase;

  /* White, differenced against the disc below it.

     A fixed colour can't work here, because the disc isn't a fixed
     colour. Where it crosses the black of the page it comes out pale,
     and where it crosses the white letterforms of the mark difference
     drives it almost to black — so a black word vanished in the middle
     of ADAMK, which is precisely where you'd be pointing.

     White plus difference means the word is always the inverse of
     whatever is directly beneath it: dark on the pale disc, white over
     the black patch on the logo. It can't be lost, and it needs no
     outline, no shadow and no pool behind it to manage it. */
  color: #fff;
  mix-blend-mode: difference;

  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s var(--ease);
  will-change: transform;
}
.blob-word.is-on { opacity: 1; }

/* The word is typed, not animated.

   Each character is either there or it isn't — steps(1), no fade, no
   travel, no easing. A bitmap face sliding and blurring into place is a
   contradiction: the whole reason it looks like a machine drew it is
   that machines don't ease. So they arrive one after another, quickly,
   the way a line prints. */
.blob-word i {
  display: block;
  font-style: normal;
}
.blob-word i.sp { width: 0.44em; }
/* The block cursor. It waits for the word to finish, then sits there
   blinking — which is the single most computer thing a piece of type
   can do, and it does the job the breathing was trying to do without
   moving the word about. */
.blob-word__caret {
  display: block;
  width: 0.62em;
  height: 1em;
  background: currentColor;
  opacity: 0;
}
.blob-word.is-on .blob-word__caret {
  animation: caretBlink 1.06s steps(1) infinite;
}
/* While it's typing the caret holds solid — a cursor that blinks in the
   middle of a word being written looks like two things happening. */
.blob-word.is-typing .blob-word__caret { animation: none; opacity: 1; }
@keyframes caretBlink {
  0%, 49%   { opacity: 1; }
  50%, 100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .blob-word { transition: opacity 0.15s linear; }
  .blob-word .blob-word__caret,
  .blob-word i,
  .blob-word.is-on i,
  .blob-word.is-on .blob-word__caret {
    animation: none; opacity: 1; transform: none; filter: none;
  }
}

.blob-word.is-on { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .blob-word { transition: opacity 0.15s linear; }
}





/* ---- the highlight -------------------------------------------------

   A hard-edged rectangle laid over a line of type, differenced against
   it so the text knocks out white — a marker pen, which is what it was
   always meant to read as.

   Position and size are written straight on with no transition, and
   opacity is the only thing that moves. That's the whole point: a shape
   that never changes shape cannot snap, and the corners stay square
   because nothing is asking them to be anything else.

   It scales very slightly from the centre as it lands, which is enough
   to make it feel struck rather than switched on. Transform-only, so
   it costs a composite and nothing else. */
.mark {
  position: fixed;
  top: 0; left: 0;
  z-index: 20045;
  background: #fff;
  mix-blend-mode: difference;
  border-radius: 0;
  pointer-events: none;
  opacity: 0;
  transform: scale(0.94);
  transform-origin: 50% 50%;
  transition: opacity 0.16s var(--ease), transform 0.22s var(--ease);
  will-change: transform, opacity;
}
.mark.is-on { opacity: 1; transform: scale(1); }

@media (prefers-reduced-motion: reduce) {
  .mark { transition: opacity 0.12s linear; transform: none; }
  .mark.is-on { transform: none; }
}


/* Scroll off, for the one page that doesn't need it. `overflow: clip`
   rather than `hidden` so it can't create a scroll container of its own
   — hidden on <body> is still scrollable by script and by keyboard,
   which is how a locked page ends up half a line down and stuck. */
body.is-locked { overflow: clip; }


/* ---- the odometer --------------------------------------------------

   Deleted once already by a rewrite of the block above, which is why
   the whole 0-9 strip printed on one line and crushed the name. It
   lives down here on its own now, where nothing else is likely to be
   sliced out from around it.

   One window per digit, exactly one line tall, with a column of
   numerals behind it. Sliding the column IS the animation; the window
   never changes size, so nothing around it moves while it runs — which
   matters, because it sits in a line of type. */
.roll {
  display: inline-flex;
  /* Every numeral has to be exactly 1em tall for a translation of -Nem
     to land on one rather than between two. */
  line-height: 1;
}
.roll__col {
  display: block;
  height: 1em;
  overflow: hidden;          /* the window. without this, everything shows */
}
.roll__col > span {
  display: block;
  transform: translateY(0);
  /* Long, and eased hard out of the spin: linear reads as a slot
     machine, this reads as something coming to rest. */
  transition: transform 1.9s cubic-bezier(0.16, 0.84, 0.24, 1);
}
/* Right-hand digits finish last, the way a real counter does — the
   units wheel has turned a hundred times by the time the hundreds wheel
   has turned once. Wheels that stop together don't read as a counter. */
.roll__col:nth-child(2) > span { transition-duration: 2.15s; }
.roll__col:nth-child(3) > span { transition-duration: 2.4s; }
.roll__col > span i {
  display: block;
  height: 1em;
  font-style: normal;
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
  .roll__col > span { transition: none; }
}


/* ---- the scrollbar -------------------------------------------------

   Hidden, but the page still scrolls — this only takes the bar away,
   not the behaviour. On a site that's mostly full-bleed pictures on
   black, a system scrollbar is a grey strip down the right-hand edge
   of every frame.

   scrollbar-gutter stays as it is: the gutter is what stops the layout
   jumping sideways when a page that scrolls follows one that doesn't,
   and hiding the bar doesn't change that. */
html {
  scrollbar-width: none;            /* Firefox */
  -ms-overflow-style: none;         /* old Edge */
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {           /* Chrome, Safari */
  width: 0;
  height: 0;
  display: none;
}


/* About, set as one of the tabs.

   Same chip as the categories, and on exactly the same rhythm.

   It had an extra margin on the left, on the reasoning that it goes
   somewhere else and the distinction was worth a few pixels. It wasn't:
   in a row of five identical chips, one of them sitting further out
   than the rest just looks misaligned, and you read it as a mistake
   long before you read it as a category boundary.

   The other thing was the height. The categories are <button>s and this
   is an <a>, so it wasn't getting the button's line-height or its
   vertical centring — it sat a pixel or two high with a slightly
   shorter chip. Both are set explicitly here rather than inherited. */
.filter--about {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 32px;
  line-height: 1;
  text-decoration: none;

}


body[data-route="about"] .filter--about {
  color: var(--bg);
  background: var(--fg);
  border-color: var(--fg);
}
