/* ------------------------------------------------------------------
   Catalogue portfolio shell
   Motion token used everywhere: cubic-bezier(.16, 1, .3, 1) "expo out"
   ------------------------------------------------------------------ */

:root {
	--ease: cubic-bezier(.16, 1, .3, 1);
	/* Expo-out covers most of its distance almost at once, which reads as
	   a snap once the distance is a whole screen. Large positional moves
	   use a curve that spends its time visibly travelling. */
	--ease-move: cubic-bezier(.32, .72, 0, 1);
	/* A panel growing to fill the screen is not a whole screen moving:
	   expo-out puts 88% of the distance into the first third of the time,
	   which arrives as a snap and then an imperceptible settle. This one
	   spends its time travelling — 36% at a third, 73% at half. */
	--ease-deck: cubic-bezier(.45, 0, .25, 1);
	--gutter: 5vw;
	--frame: 120px;	/* top/bottom dead zone + slide padding */
	--face: 22px;	/* caption line box — also the cube's edge length */

	--bg: #f9f9f9;
	--bg-rgb: 249, 249, 249;
	--ink: #101010;
	--on-ink: #fff;			/* type on a solid --ink fill */
	--muted: #666;
	--select: #ddd;
	--placeholder: #b4b4b4;


	/* Translucent surfaces. Hairlines and fills are tinted rather than
	   solid so they hold up over photography in both themes. */
	--hair: rgba(16, 16, 16, .1);
	--surface: rgba(255, 255, 255, .42);
	--surface-hi: rgba(255, 255, 255, .6);
	--shadow: rgba(16, 16, 16, .1);
	--card: rgba(252, 252, 252, .52);
	--card-spec: rgba(255, 255, 255, .9);
	--card-rim: rgba(255, 255, 255, .55);
	--sweep: rgba(255, 255, 255, .55);
	--send-hover: #262626;

	/* --- the material ------------------------------------------------
	   Apple documents Liquid Glass for Apple platforms and ships no CSS
	   for it, so this is an approximation built from what they say it
	   does rather than from numbers they never published. From WWDC25,
	   "Meet Liquid Glass":

	   Not attempted: "this new set of materials dynamically bends,
	   shapes, and concentrates light in real time". Bending light means
	   displacing what is behind it; see the note on the edge below.

	   "Light sources inside of this environment shine on the material
	   producing highlights that respond to geometry ... light to travel
	   around the material, defining its silhouette" — so the highlight
	   is not one diagonal wash. The top edge takes the source, the
	   bottom edge takes the bounce, the sides take a little.

	   "When glass flexes and morphs to larger sizes ... it casts deeper,
	   richer shadows" — so the pieces are graded by size rather than all
	   sharing one recipe.

	   "When you interact with Liquid Glass, the material illuminates
	   from within ... starting right under your fingertips" — --glow,
	   lit from the pointer.

	   Not attempted: real-time adaptation of tint and shadow to whatever
	   is behind, and the light responding to device motion. Both need
	   the compositor to tell us what the backdrop looks like, and the
	   web does not. */
	--glass-tint: rgba(252, 252, 252, .55);
	--glass-tint-hi: rgba(252, 252, 252, .68);
	--glass-ink: var(--ink);
	--glass-muted: var(--muted);
	--glass-blur: 10px;
	--glass-sat: 180%;
	--glass-rim: rgba(255, 255, 255, .6);
	--glass-spec: rgba(255, 255, 255, .8);
	--glass-bounce: rgba(255, 255, 255, .38);	/* the light that comes back up */
	--glass-shade: rgba(16, 16, 16, .07);
	--glass-lift: 0 1px 2px var(--glass-shade), 0 10px 30px var(--glass-shade);
}

/* Registered so it can be animated: a plain custom property cannot be
   transitioned, and this one has to come up under the finger and fade. */
@property --glow {
	syntax: "<number>";
	inherits: false;
	initial-value: 0;
}

html { color-scheme: light; }	/* never auto-darken form controls */

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

html, body, div, span, h1, p, a, ul, li, button, section, figure, img, video, strong, nav {
	margin: 0; padding: 0; border: 0;
	font: inherit; font-size: 100%; vertical-align: baseline;
}

ul { list-style: none; }
img, video { display: block; }
section, nav, figure { display: block; }

html { width: 100%; height: 100%; }

body {
	width: 100%; height: 100%;
	overflow: hidden;
	background: var(--bg);
	color: var(--ink);
	font-family: "ABC Diatype", "Diatype", "Helvetica Neue", Helvetica, Arial, sans-serif;
	font-size: 16px;
	line-height: 1.4em;
	letter-spacing: -.01em;
	-webkit-font-smoothing: antialiased;
	-webkit-text-size-adjust: 100%;
	user-select: none;
	-webkit-user-select: none;
	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}

a { color: var(--ink); text-decoration: none; }
::selection { background: var(--select); color: var(--ink); text-shadow: none; }

button {
	outline: 0; border: 0; padding: 0;
	background: none; cursor: pointer;
	color: var(--ink);
	font: inherit; letter-spacing: inherit;
}

/* ------------------------------------------------------------------
   Splash — the wordmark lands, then the catalogue fades up under it
   ------------------------------------------------------------------ */

/* Nothing on the page is reachable behind the opening — except the
   opening itself, which is what you click to get past it. */
.show-splash-screen * { pointer-events: none !important; }
.show-splash-screen .name,
.show-splash-screen .name-mark,
.show-splash-screen .name-mark * { pointer-events: auto !important; }
.show-splash-screen .name { cursor: pointer; }

/* The whole screen. It is a flipbook of photographs now, not a word, so
   it takes the room a photograph wants — the entrance is a settling
   scale rather than a slide, which is what a centred word needed. */
/* The opening is the screen above the site, not a curtain over it. It
   sits at minus one screen when it is not wanted, so leaving it takes it
   up and coming back brings it down — the same move either way, and the
   direction says which of the two you are doing. */
.name {
	position: fixed;
	inset: 0;
	z-index: 100;
	line-height: 0;
	pointer-events: none;
	/* The grain inside runs a quarter past every edge so it can move
	   without showing one. Left unclipped that overhang widens the
	   document, and a phone answers a document wider than itself by
	   scaling the whole page down — which took the layout viewport to
	   1067px on a 390px screen and put the desktop build on a phone. */
	overflow: hidden;
	/* The film's own backing, here rather than only on the film, because
	   the film is built by script and the document is painted before that
	   runs. Without it the opening is transparent for those frames and
	   the catalogue's near-white shows through: that is the white flash,
	   and it was never the film's to fix. The picture over it is a
	   blurred thumbnail of the first frame, written into the document so
	   it needs nothing to have loaded — otherwise the flat colour is all
	   there is until the first photograph decodes, which cold on a 2Mbit
	   link is a second of it. */
	background-color: #0b0b0b;
	background-size: cover;
	background-position: center;
	transform: translateY(-100%);
	transition: transform 900ms var(--ease-move);
}
.show-splash-screen .name { transform: none; }

/* The name is still the heading, it just isn't what you look at any
   more: kept for anything reading the page rather than watching it.
   Direct child only — this used to be every span inside .name, which
   swallowed the mark's own lettering the moment it was added. */
.name > span {
	position: absolute;
	width: 1px; height: 1px;
	margin: -1px; padding: 0;
	overflow: hidden;
	clip-path: inset(50%);
	white-space: nowrap;
}

/* ------------------------------------------------------------------
   The wordmark is a flipbook. Twelve portraits shot so that the eyes
   land in the same place, cut one straight to the next: the eyes hold
   still while everything around them changes, and the run reads as one
   face rather than twelve.

   No timer in script. Every frame carries the same animation and a
   delay one step further along, so the browser keeps them in step with
   each other for as long as the page is open — a setInterval would
   drift, and a dropped frame would slide the whole run out of phase.
   ------------------------------------------------------------------ */

.name-inner {
	position: absolute;
	inset: 0;
	transform-origin: 50% 50%;
}

.name-film {
	position: absolute;
	inset: 0;
	overflow: hidden;
	/* Near-black, not the page's near-white. Recording the opening frame
	   by frame caught one frame at brightness 251 out of 255 at the 60ms
	   mark — the film's own backing, painted before the first photograph
	   had decoded, which is the white flash. It shows again whenever the
	   browser drops a decoded frame and has to fetch it back. Dark, it
	   passes for a dim frame; white, it is a glitch. */
	background: #0b0b0b;
	/* one step; the whole run is this times the number of frames */
	--step: 180ms;
	/* The black and white is in the files, not in a filter here. Stripping
	   the colour is what makes twenty-two different rooms read as one
	   face, and the lift keeps the darker frames from thumping against
	   the light ones as they cut — but a filter on a full-card element
	   forces a layer of its own, and a layer clipped by an ancestor's
	   rounded corners is where a phone gives you square ones. */
}

/* Film grain over the top, outside the filter so it stays its own thing
   rather than being brightened along with the photographs. It moves —
   grain that sits still is a texture, grain that jumps every few frames
   is film. Eight positions on a tile larger than the screen, so no edge
   can ever wander into view. */
.name-grain {
	position: absolute;
	inset: -25%;
	pointer-events: none;
	opacity: .28;
	mix-blend-mode: overlay;
	/* Desaturated on the way out: turbulence is coloured noise, and
	   coloured specks over a black and white photograph are the one
	   thing that gives grain away as an effect. */
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)'/%3E%3C/svg%3E");
	animation: name-grain 640ms steps(1) infinite;
	will-change: transform;
}

@keyframes name-grain {
	0%    { transform: translate(0, 0); }
	12.5% { transform: translate(-3%, 2%); }
	25%   { transform: translate(2%, -3%); }
	37.5% { transform: translate(-2%, -2%); }
	50%   { transform: translate(3%, 1%); }
	62.5% { transform: translate(-1%, 3%); }
	75%   { transform: translate(2%, 2%); }
	87.5% { transform: translate(-3%, -1%); }
}

/* ------------------------------------------------------------------
   The mark, and the way in. A pair of figures holding the middle of the
   screen; come near them and the name unrolls out of them.

   It grows from nothing to its own width — no measuring, no magic
   number: a grid track going 0fr to 1fr resolves to whatever the word
   needs, and the clip lives on the span inside. The button is centred
   on its own width, so as the name unrolls the figures slide right and
   the finished lockup lands centred. Helvetica because it is the one
   face that reads as a mark rather than as type.
   ------------------------------------------------------------------ */

/* Anchored on both edges rather than pinned at the middle: an absolute
   box placed at left:50% can only ever be as wide as the half screen to
   its right, so the lockup was clipped the moment the face got wider.
   Helvetica cleared it by thirteen pixels, which is not clearance. */
.name-mark {
	position: absolute;
	left: 0; right: 0; top: 50%;
	z-index: 3;
	width: max-content;
	max-width: 94vw;
	margin: 0 auto;
	display: flex;
	align-items: baseline;
	padding: 10px 14px;
	transform: translateY(-50%);
	font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
	font-weight: 700;
	font-size: clamp(30px, 5.4vw, 84px);
	line-height: 1;
	letter-spacing: -.035em;
	color: #fff;
	text-transform: uppercase;
	/* A drop-shadow on the button, not a text-shadow on the letters: each
	   letter is clipped to its own box for the reveal, and that clip cuts
	   the shadow too — a 40px blur came out as a hard-edged slab behind
	   the word, measured at 72 against 94 either side of it. A filter is
	   applied to what the button painted, so it haloes the letterforms
	   themselves and nothing squares it off. */
	filter: drop-shadow(0 2px 26px rgba(0, 0, 0, .5));
	cursor: pointer;
	opacity: 0;
	transition: opacity 700ms var(--ease) 900ms, filter 400ms var(--ease);
}

/* A typewriter, and it types for real: every letter has a box that is
   nothing wide until its turn and its own width from then on, with no
   duration on the change — so the word grows a letter at a time and the
   figures are pushed right in steps, the way a carriage moves. Nothing
   eases; a typewriter that eases is a typewriter that is pretending.

   No sliding clip either. The letters are the width, so the lockup is
   simply the sum of whatever has been typed, and the caret sits after
   the last one because the ones still to come take up no room. */
.mark-reveal { display: flex; }

.mark-word {
	display: flex;
	align-items: baseline;
	overflow: hidden;
	white-space: nowrap;
}

.mark-glyph {
	display: block;
	flex: 0 0 auto;
	max-width: 0;
	overflow: hidden;
	/* leaving unwinds it, last letter first and half again as quick */
	transition: max-width 0ms linear calc((var(--n) - 1 - var(--i)) * 32ms);
}
.mark-glyph i { display: block; font-style: normal; }

.name-mark:hover .mark-glyph,
.name-mark:focus-visible .mark-glyph {
	max-width: 2em;		/* past any glyph here, so each lands at its own width */
	transition-delay: calc(var(--i) * 62ms + 80ms);
}

/* The caret holds steady while the letters are coming, blinks twice when
   they stop, and goes. Left in, it reads as a rule between the name and
   the figures rather than as a caret; a caret that blinks mid-word reads
   as a stall. */
.mark-caret {
	flex: 0 0 auto;
	align-self: center;
	width: .07em;
	height: .76em;
	margin-left: .07em;
	background: currentColor;
	opacity: 0;
	transition: opacity 90ms linear;
}
.name-mark:hover .mark-caret,
.name-mark:focus-visible .mark-caret {
	animation: mark-caret 1080ms steps(1) forwards;
}

@keyframes mark-caret {
	0%, 44% { opacity: 1; }			/* the whole time the letters are landing */
	44.01%, 61% { opacity: 0; }
	61.01%, 78% { opacity: 1; }
	78.01%, 100% { opacity: 0; }	/* and then it is not there */
}

.name-mark:focus-visible { outline: 2px solid #fff; outline-offset: 6px; }
.name-mark:hover { filter: drop-shadow(0 2px 34px rgba(0, 0, 0, .62)); }
.name-mark:active { transform: translateY(-50%) scale(.985); }

/* It arrives once the flipbook has had a moment to itself */
.name-film.ready ~ .name-mark { opacity: 1; }

/* A finger has nothing to hover with, and the opened lockup does not
   fit a phone at any size the figures still read at — so there the mark
   is the figures, full stop. The name is in the heading either way. */
@media (max-width: 700px) {
	.mark-reveal { display: none; }
	.name-mark { font-size: 44px; }
	.mark-figs { color: #fff; -webkit-text-stroke-color: rgba(255, 255, 255, 0); }
}

@media (prefers-reduced-motion: reduce) {
	.mark-glyph i { transition-duration: 1ms; transition-delay: 0ms; }
}

@media (prefers-reduced-motion: reduce) {
	.name-grain { animation: none; }
}

.name-frame {
	position: absolute;
	inset: 0;
	width: 100%; height: 100%;
	object-fit: cover;
	opacity: 0;
}

/* Something to look at from the first paint. Over a network the twelve
   frames take a moment, and without this the middle of the opening
   screen is simply empty until they land. */
.name-film .name-frame:first-child { opacity: 1; }

/* Held until every frame has decoded — starting early means the first
   run through plays with gaps in it, which is the one time anybody is
   watching closely. */
.name-film.ready .name-frame {
	/* No delay: each frame's own keyframes say when its turn comes, and
	   they all share one clock so the run can never drift apart. */
	animation: name-hold-0 calc(var(--step) * var(--n)) linear infinite;
}

/* Held still when nobody can see it, and while the rail is moving. Once the opening lives in a card
   inside that rail, every frame it swaps is a texture the compositor has
   to push during a scroll — the one moment there is nothing to spare.
   Nobody is reading a flipbook mid-flick anyway. */
.is-scrolling .name-film.ready .name-frame,
.is-scrolling .name-grain,
.name-film.is-away.ready .name-frame { animation-play-state: paused; }

/* Each frame holds from its turn to the end of the run rather than
   blinking on for a beat, so the stack under the one on top is a run of
   photographs and not the backing. A frame that has not arrived, or that
   the browser has dropped to save memory, then shows the one before it
   held a beat longer — which reads as a slow cut, not as a flash. The
   keyframes are per frame and written by the script for that reason. */

/* A keyframe selector is a literal percentage and cannot be calc(), so
   the stop that depends on how many frames there are has to be worked
   out where the count is known. The cut is instant — a fade would
   cross-dissolve the faces and lose the snap the whole thing rests on. */

@media (prefers-reduced-motion: reduce) {
	/* one portrait, no flicker */
	.name-film.ready .name-frame { animation: none; }
	.name-film .name-frame:first-child { opacity: 1; }
}


@keyframes catalogue-fade-in {
	from { opacity: 0; }
	to   { opacity: 1; }
}

/* ------------------------------------------------------------------
   Nav — a frosted pill in the top corner that opens a glass panel.

   The work behind it is photography, so the chrome can't assume a
   background. Everything is translucent and blurred: the panel borrows
   whatever is underneath, and a light rim plus a specular sweep keep
   the edge readable over both a white studio shot and a black one.
   ------------------------------------------------------------------ */

.glass-nav {
	position: fixed;
	top: 24px; left: 24px;
	z-index: 100;
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: 10px;
	line-height: 1.4em;
	transition: opacity 500ms var(--ease);
}

.show-splash-screen .glass-nav { opacity: 0; pointer-events: none; }

/* --- the four layers ------------------------------------------------
   Every piece of glass on the site is built the same way, so they read
   as one material: the tinted, blurred body; a band of bent light at the
   edge with its own optics; a highlight that travels the silhouette; and
   a shadow that deepens with size.
   -------------------------------------------------------------------- */

/* 1. the body — tint, blur, saturation, and the glow that lights it from
   within while it is being pressed */
.glass-bar,
.glass-panel,
.deck-back,
.deck-step::before,
.contact-card,
.info-panel {
	background-color: var(--glass-tint);
	background-image: radial-gradient(circle at var(--gx, 50%) var(--gy, 50%),
		rgba(255, 255, 255, var(--glow)) 0%, rgba(255, 255, 255, 0) 68%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
	        backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
	transition: background-color 400ms var(--ease), box-shadow 400ms var(--ease),
		color 400ms var(--ease), --glow 460ms var(--ease);
}
.glass-bar:active, .deck-back:active, .deck-step:active::before { --glow: .5; transition-duration: 90ms; }

/* 2. the refraction, where the browser can do it. It sits on a layer of
   its own and carries nothing but the displacement, so a browser that
   drops SVG filters on backdrop-filter — Safari, Firefox — loses only
   this and keeps the blur and the edge, which live elsewhere. The layer
   is ::before rather than ::after so the backdrop it bends is the page
   behind the glass and not the glass's own type. */
.glass-optics { position: absolute; width: 0; height: 0; }

.glass-bar::before,
.glass-panel::before,
.contact-card::before,
.clients-strip::before,
.info-panel::before {
	content: "";
	position: absolute;
	inset: 0;
	border-radius: inherit;
	pointer-events: none;
	-webkit-backdrop-filter: url(#glass-refract);
	        backdrop-filter: url(#glass-refract);
}

/* 3. the edge. Apple's bends light — it displaces what is behind it —
   and CSS has no way to displace a backdrop. (backdrop-filter can take
   an SVG filter in Chrome, which would displace properly, but not in
   Safari, and there a url() makes the whole declaration fail and the
   glass loses its blur. This site is mostly read on phones.) */
.glass-bar::after,
.glass-panel::after,
.deck-back::before,
.deck-step::after,
.contact-card::after,
.clients-strip::after,
.info-panel::after {
	content: "";
	position: absolute;
	inset: 0;
	padding: 1px;
	border-radius: inherit;
	pointer-events: none;
	/* Painted, not filtered. A ring with a backdrop filter of its own can
	   only composite a differently-filtered copy of what is behind into a
	   band at the rim: a white outline over pale paper, a smear over a
	   photograph. Neither is a refraction, and both were visible.

	   This is what the eye actually reads along a curved glass edge
	   instead: a bright catch where the light lands, nothing across the
	   middle of the run, a dimmer return where it comes back. Nothing
	   behind it to smear, and gradated around the shape, which is what
	   keeps a line from reading as a border. One pixel; a thick one is a
	   stripe. */
	background: linear-gradient(158deg,
		var(--glass-spec) 0%,
		rgba(255, 255, 255, .3) 24%,
		rgba(255, 255, 255, 0) 46%,
		rgba(255, 255, 255, 0) 68%,
		var(--glass-bounce) 100%);
	-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
	        mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
	-webkit-mask-composite: xor;
	        mask-composite: exclude;
}

/* 4. the shadow */
.glass-bar,
.glass-panel {
	position: relative;
	color: var(--glass-ink);
	box-shadow:
		var(--glass-lift);
}


/* The three large panels are one piece of material, so they are written
   once. Apple grade glass by size and these are the big pieces: a wider
   lens than the pill, and a contrast()/brightness() pair doing the work
   a heavier tint used to. Letting more of the photograph through costs
   contrast, so the filter pulls a dark backdrop up towards the middle
   before the white is laid over it — which is what holds black type
   above 4.5:1 over the darkest frame in the catalogue. Measured, not
   guessed; there is an assertion on it. */
.contact-card,
.info-panel,
.clients-strip {
	--glass-spec: rgba(255, 255, 255, .5);
	--glass-bounce: rgba(255, 255, 255, .22);
	/* one tight shadow for contact, two wide ones for lift */
	--glass-lift:
		0 1px 1px rgba(16, 16, 16, .03),
		0 14px 28px -14px rgba(16, 16, 16, .12),
		0 44px 88px -34px rgba(16, 16, 16, .2);
	background-color: rgba(255, 255, 255, .43);
	-webkit-backdrop-filter: blur(15px) saturate(190%) contrast(.82) brightness(1.1);
	        backdrop-filter: blur(15px) saturate(190%) contrast(.82) brightness(1.1);
	/* light pooling inside the bottom edge, then the lift */
	box-shadow:
		inset 0 -26px 44px -34px rgba(16, 16, 16, .16),
		var(--glass-lift);
}

/* Without a blur behind it the tint alone is too thin to read on */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
	:root { --glass-tint: rgba(249, 249, 249, .93); --glass-tint-hi: rgba(255, 255, 255, .96); }
	.contact-card,
	.info-panel,
	.clients-strip { background-color: rgba(var(--bg-rgb), .95); }
}

/* --- the pill ----------------------------------------------------- */

.glass-bar {
	display: flex;
	flex-direction: row;
	align-items: center;
	gap: 12px;
	height: 40px;
	padding: 0 18px 0 15px;
	border-radius: 999px;
	cursor: pointer;
	font: inherit;
	letter-spacing: inherit;
	white-space: nowrap;
}
.glass-bar:hover { --glass-tint: var(--glass-tint-hi); }
.glass-bar:focus-visible { outline: 2px solid var(--glass-ink); outline-offset: 3px; }
.glass-bar:active { transform: scale(.985); }
/* Width included: the pill says what page you are on and which projects
   are showing, and both change. Nowrap and hidden with it, or the text
   wraps for the frame it takes to get there. */
.glass-bar {
	overflow: hidden;
	white-space: nowrap;
	transition: background 400ms var(--ease), box-shadow 400ms var(--ease),
		color 400ms var(--ease), transform 150ms var(--ease), width 460ms var(--ease);
}

.burger {
	position: relative;
	display: block;
	width: 15px; height: 15px;
	flex-shrink: 0;
}
.burger i {
	position: absolute;
	top: 50%; left: 0; right: 0;
	height: 1.5px;
	margin-top: -.75px;
	border-radius: 2px;
	background: currentColor;
	transition: transform 300ms var(--ease);
}
.burger i:nth-child(1) { transform: translateY(-3.5px); }
.burger i:nth-child(2) { transform: translateY(3.5px); }
/* The middle bar is a phone's alone: the pill has room for a word beside
   the mark, a circle has room for one bar more. */
.burger i:nth-child(3) { display: none; }

/* Two bars, not three — with nothing to fade out the X reads cleanly */
[aria-expanded="true"] .burger i:nth-child(1) { transform: rotate(45deg); }
[aria-expanded="true"] .burger i:nth-child(2) { transform: rotate(-45deg); }
[aria-expanded="true"] .burger i:nth-child(3) { opacity: 0; }

.glass-status { display: flex; flex-direction: row; align-items: center; }
.current-filters { color: var(--glass-muted); }
.current-filters::before { content: "\00a0—\00a0"; }
.current-filter { text-transform: lowercase; }

/* --- the panel ---------------------------------------------------- */

.glass-panel {
	/* a menu is bigger glass than the pill that opens it */
	/* A softer catch than the small chrome gets. The gradient's bright
	   stop is a share of the shape, so on a 460px sheet what reads as a
	   short glint on a 40px pill runs the whole top edge: measured 255
	   against an interior of 249, which is a white line. Apple grade this
	   the same way — bigger glass scatters light more softly. */
	--glass-spec: rgba(255, 255, 255, .5);
	--glass-bounce: rgba(255, 255, 255, .22);
	--glass-blur: 13px;
	--glass-tint: rgba(252, 252, 252, .62);
	--glass-lift: 0 2px 4px var(--glass-shade), 0 26px 60px var(--glass-shade);
	display: flex;
	flex-direction: column;
	gap: 20px;
	width: 268px;
	padding: 18px 20px 20px;
	border-radius: 22px;
	transform-origin: top left;

	/* Closed. Exit is deliberately shorter and travels less than the
	   enter — by then attention has already moved to the page. */
	opacity: 0;
	visibility: hidden;
	pointer-events: none;
	transform: translateY(-6px) scale(.98);
	filter: blur(3px);
	transition:
		opacity 220ms var(--ease),
		transform 220ms var(--ease),
		filter 220ms var(--ease),
		visibility 0s linear 220ms;
}

.menu-open .glass-panel {
	opacity: 1;
	visibility: visible;
	pointer-events: auto;
	transform: none;
	filter: blur(0);
	transition:
		opacity 320ms var(--ease),
		transform 320ms var(--ease),
		filter 320ms var(--ease),
		visibility 0s;
}

.panel-group { display: flex; flex-direction: column; gap: 7px; }

.panel-label {
	color: var(--glass-muted);
	font-size: 11px;
	line-height: 11px;
	letter-spacing: .08em;
	text-transform: uppercase;
}

/* Rows fade up in sequence on open; on close they leave together,
   because a staggered exit just delays getting out of the way. */
.panel-row {
	opacity: 0;
	transform: translateY(5px);
	transition: opacity 200ms var(--ease), transform 200ms var(--ease);
}
.menu-open .panel-row {
	opacity: 1;
	transform: none;
	transition: opacity 260ms var(--ease), transform 260ms var(--ease);
	transition-delay: calc(var(--i, 0) * 22ms);
}

.page-titles { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.page-titles a {
	color: var(--glass-muted);
	transition: color 200ms;
}
.page-titles a:hover,
.page-titles a:focus-visible { color: var(--glass-ink); }
.page-titles a.active-page { color: var(--glass-ink); }

.filter-titles ul {
	display: flex;
	flex-direction: row;
	flex-wrap: wrap;
	gap: 3px 10px;
}
.filter-titles button {
	color: var(--glass-muted);
	text-transform: lowercase;
	cursor: pointer;
	transition: color 200ms;
}
.filter-titles button:hover,
.filter-titles button:focus-visible { color: var(--glass-ink); }
.filter-titles .active-filter {
	color: var(--glass-ink);
	text-decoration: underline;
	text-decoration-thickness: 1px;
	text-underline-offset: 3px;
}

/* The panel is frosted glass with a blur behind it, so it is already
   legible over any photograph. Fading the work to 35% on top of that
   just let the light background through and washed the page out. */
.menu-open .mouse-follower { opacity: 0; }

/* The cursor readout only belongs on the catalogue, with nothing else
   claiming attention */
.show-title .mouse-follower,
.hide-interface .mouse-follower,
.contact-in .mouse-follower { opacity: 0; }
.inactive-section + .mouse-follower { opacity: 0 !important; }
.touch .mouse-follower { display: none; }

.hide-interface .minimap-container { opacity: 0; }
.menu-open .minimap-container { opacity: .25; pointer-events: none; }

/* ------------------------------------------------------------------
   Stepping through a category — the sides of the screen, given a handle

   The outer third of the screen has always moved the deck along. Nothing
   said so, and a photograph that fills the frame gives no clue that
   another one is beside it. These are not a new way through: they are
   that zone drawn, sitting where the click already worked.

   Two states, and the split between them is the point. The arrow is
   always there, quietly, so the sideways is legible before you have done
   anything — and the glass is not, until the cursor is in the band it
   belongs to. The material arrives at the moment you are in a position
   to use it, which is the only moment it has any business being over a
   photograph.
   ------------------------------------------------------------------ */

.deck-step {
	position: fixed;
	top: 50%;
	z-index: 70;
	/* Sized and inset so the arrow itself is always clear of the
	   photograph — the margin either side of a slide is 5vw, and at the
	   narrowest desktop that is barely fifty pixels. The disc may lap the
	   edge of a picture when it arrives; it is glass, that is what glass
	   is for. The arrow may not. */
	width: 54px; height: 54px;
	border-radius: 50%;
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--ink);
	cursor: pointer;
	opacity: .42;			/* the arrow alone, and quiet */
	transform: translateY(-50%);
	transition: opacity 420ms var(--ease);
}
.deck-step.is-prev { left: 18px; }
.deck-step.is-next { right: 18px; }

/* The glass, on its own layer so it can arrive without the arrow moving.
   Same recipe as the pill and the phone's back button — tint, blur,
   a hairline of specular inside the top edge, a rim, and two shadows. */
.deck-step::before {
	content: "";
	position: absolute;
	inset: 0;
	border-radius: inherit;
	box-shadow:
		var(--glass-lift);
	opacity: 0;
	transform: scale(.72);
	transition:
		opacity 420ms var(--ease),
		transform 420ms var(--ease),
		background-color 320ms var(--ease),
		--glow 460ms var(--ease);
}

/* The same 135° sheen the pill and the panel carry, so this reads as the
   same material rather than as another blurred circle. */
.deck-step::after {
	content: "";
	position: absolute;
	inset: 0;
	border-radius: inherit;
	pointer-events: none;
	opacity: 0;
	background: linear-gradient(135deg,
		var(--glass-spec) 0%,
		rgba(255, 255, 255, 0) 34%,
		rgba(255, 255, 255, 0) 68%,
		var(--glass-rim) 100%);
	transition: opacity 420ms var(--ease);
}

/* Two strokes off a corner of a square, turned — the same figure and the
   same weight as the arrow on the phone's back button. */
.deck-step-chev {
	position: relative;
	width: 10px; height: 10px;
	border-left: 1.6px solid currentColor;
	border-bottom: 1.6px solid currentColor;
	/* Nothing behind it until the glass arrives, and on a narrow window
	   the margin runs out and it can end up on a photograph. A hairline
	   of the page's own background around the strokes costs nothing and
	   means it is never lost against a dark one. */
	filter: drop-shadow(0 0 2px rgba(var(--bg-rgb), .95));
	transition: transform 420ms var(--ease), filter 420ms var(--ease);
}
.deck-step.is-prev .deck-step-chev { transform: translateX(2px) rotate(45deg); }
.deck-step.is-next .deck-step-chev { transform: translateX(-2px) rotate(-135deg); }

/* Awake: the cursor is in this side's band, which is where the click
   already worked. The glass comes up around the arrow that was already
   there — it grows into its own edge rather than fading in flat. */
.show-arrow-left .deck-step.is-prev,
.show-arrow-right .deck-step.is-next { opacity: 1; }
.show-arrow-left .deck-step.is-prev::before,
.show-arrow-right .deck-step.is-next::before { opacity: 1; transform: none; }
.show-arrow-left .deck-step.is-prev::after,
.show-arrow-right .deck-step.is-next::after { opacity: .5; }
/* the glass is behind it now, so the halo has nothing left to do */
.show-arrow-left .deck-step.is-prev .deck-step-chev,
.show-arrow-right .deck-step.is-next .deck-step-chev,
.deck-step:hover .deck-step-chev { filter: none; }

/* On the button itself the glass lifts, and the arrow leans the way it
   is about to take you. Transform and opacity only. */
.deck-step:hover { opacity: 1; }
.deck-step:hover::before { opacity: 1; transform: none; background: var(--glass-tint-hi); }
.deck-step:hover::after { opacity: .5; }
.deck-step.is-prev:hover .deck-step-chev { transform: translateX(-1px) rotate(45deg); }
.deck-step.is-next:hover .deck-step-chev { transform: translateX(1px) rotate(-135deg); }
.deck-step:active::before { transform: scale(.94); }
.deck-step:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.deck-step:focus-visible::before { opacity: 1; transform: none; }

/* Away when the catalogue is not what you are looking at, or when
   something else has the screen. */
.show-splash-screen .deck-step,
.menu-open .deck-step,
.credits-open .deck-step,
.contact-in .deck-step,
.hide-interface .deck-step,
body:has(.catalogue-container.inactive-section) .deck-step {
	opacity: 0;
	pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
	.deck-step, .deck-step::before, .deck-step::after, .deck-step-chev { transition-duration: 1ms; }
}

/* A phone swipes, and glass either side of a photograph on a 390px
   screen is furniture in front of the work. */
@media (max-width: 768px) {
	.deck-step { display: none; }
}

/* Which projects are showing is the catalogue's business: on a page with
   no projects on it the pill was reading "O mnie, pokazuję wszystkie",
   and the menu was offering a list of filters that would have sent you
   somewhere else to take effect. */
/* Keyed to the body class rather than to which section is active,
   because that class is set the moment you choose the page while the
   section itself swaps half a second later — and the pill measures its
   new width when you choose, so it was measuring with half its own
   content still in it and then jumping when the rest went. */
.information-open .current-filters,
.information-open .panel-group.is-filter { display: none; }

/* ------------------------------------------------------------------
   Information — one screen, held

   A photographer's page about themselves with no photograph on it is a
   contact card, so there is one, and it is the largest thing here. The
   words sit beside it and the practical details sit under one piece of
   glass that reaches back over the picture's edge — the only element on
   the page with something to refract, which is the only reason to reach
   for the material at all. It is used once.

   Nothing scrolls. Every other surface on this site is one screen at a
   time and this one has less to say than any of them.
   ------------------------------------------------------------------ */

/* The screen, not the content: the picture is the only thing here with a
   size of its own, and left to it the page was 888px tall on a 720px
   laptop. The rows carry the height and the photograph takes what is
   left, so a short window crops the frame instead of losing the panel
   off the bottom. */
.information-container {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100dvh;
	padding: 96px var(--gutter) 72px;
	overflow: hidden;
}

.info-grid {
	display: grid;
	grid-template-columns: repeat(12, 1fr);
	grid-template-rows: minmax(0, 1fr) auto;
	gap: 0 32px;
	width: 100%;
	max-width: 1280px;
	height: 100%;
	max-height: 760px;
}

/* Rows one and two both, so the panel below can lap its lower corner */
.info-photo {
	grid-column: 1 / 7;
	grid-row: 1 / 3;
	position: relative;
	min-height: 0;
	overflow: hidden;
	background: #eee;
}
.info-photo img {
	width: 100%; height: 100%;
	object-fit: cover;
	object-position: 50% 35%;	/* the face, not the middle of the frame */
}

.info-words {
	grid-column: 8 / 13;
	grid-row: 1;
	align-self: center;
	/* the client row is wider than the screen by design, and a grid item
	   is sized by its content unless it is told it may be narrower */
	min-width: 0;
	padding-bottom: 40px;
}

.info-name {
	font-size: 14px;
	color: var(--muted);
	letter-spacing: -.01em;
	margin-bottom: 22px;
}

.info-say {
	font-size: clamp(32px, 3.9vw, 56px);
	line-height: 1.05;
	letter-spacing: -.038em;
	font-weight: 400;
	max-width: 14ch;
}

/* The client wall. One piece of glass, the marks running through it and
   dissolving into the material at both ends rather than being cut off
   by it — a hard edge on a moving row reads as a clipping bug. */
.info-clients {
	position: relative;
	margin-top: 32px;
	/* A little wider than the column of type it sits under, so it reads as
	   its own object rather than as another paragraph. The caption keeps
	   the column's edge. */
	margin-inline: -28px;
	max-width: none;
}

/* The caption travels with the strip, which is why it is inside the
   thing you pick up rather than sitting above it in the column. */
.clients-label {
	margin: 0 0 10px 28px;
	font-size: 13px;
	color: var(--muted);
	transition: color 320ms var(--ease);
}
.info-clients:hover .clients-label { color: var(--ink); }

/* Material above; what is left here is this piece's own shape. */
.clients-strip {
	position: relative;
	border-radius: 999px;
	overflow: hidden;
	transition: background-color 320ms var(--ease), box-shadow 320ms var(--ease);
}
.info-clients:hover .clients-strip {
	background-color: rgba(255, 255, 255, .5);
	--glass-lift:
		0 1px 1px rgba(16, 16, 16, .04),
		0 18px 34px -16px rgba(16, 16, 16, .14),
		0 52px 96px -34px rgba(16, 16, 16, .22);
}

/* The fade is on the row, not on the strip: masking the strip took its
   own tint and its shadow with it and left the marks floating on the
   page. The glass keeps its box; the marks dissolve into it. */
.client-run {
	display: flex;
	width: max-content;
	-webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
	        mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
	/* one set wide, so the second copy is exactly where the first was */
	animation: client-run calc(var(--clients, 17) * 2.6s) linear infinite;
}
/* Held as well as hovered. Dragging takes the pointer capture, and a
   captured pointer is no longer hovering anything — so picking the strip
   up set it running again under your own hand. */
.info-clients:hover .client-run,
.info-clients.is-held .client-run { animation-play-state: paused; }
@keyframes client-run { to { transform: translateX(-50%); } }

.client-set {
	display: flex;
	align-items: center;
	flex-shrink: 0;
}
.client {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 54px;
	padding: 0 19px;
	flex-shrink: 0;
}
/* Every mark to one weight of ink. They arrive in whatever colour their
   owner uses and a client wall in seventeen brand colours is a swatch
   book, not a list of people you have worked for. */
/* Height set rather than capped: several of these SVGs carry a viewBox
   and no height attribute, and a replaced element with width auto and
   only a max-height to go on comes out nothing tall. */
.client img {
	height: calc(20px * var(--s, 1));
	width: auto;
	max-width: 108px;
	object-fit: contain;
	filter: brightness(0);
	opacity: .62;
}
.client-word {
	font-size: 15px;
	letter-spacing: -.01em;
	white-space: nowrap;
	opacity: .62;
}

@media (prefers-reduced-motion: reduce) {
	.client-run { animation: none; }
	.clients-strip { overflow-x: auto; }
}

.info-work {
	margin-top: 22px;
	font-size: 17px;
	line-height: 1.6;
	color: var(--muted);
	max-width: 34ch;
}

/* The glass. Starts inside the photograph's columns and runs out onto
   the page, so it is half over a picture and half over paper — which is
   the only way a material like this reads as a material. */
.info-panel {
	grid-column: 5 / 11;
	grid-row: 2;
	z-index: 1;
	display: grid;
	grid-template-columns: repeat(3, 1fr);
	gap: 28px;
	padding: 26px 30px 28px;
	/* Hung below the picture's bottom edge rather than flush with it.
	   Level, the two read as one block with a seam through it; dropped,
	   the glass is plainly a thing lying on top of a photograph. */
	margin-bottom: -38px;
	border-radius: 22px;
	/* Material above, with the contact card and the client wall: three
	   pieces of the same glass, so nothing here restates it. */
	position: relative;
}

.info-set { display: flex; flex-direction: column; gap: 5px; font-size: 15px; }
/* No grey inside the glass. Ink over this panel clears 13:1 where it laps
   the photograph and 4.6:1 over the darkest frame the site has, but the
   muted grey the rest of the page uses only manages 3.9:1 there and less
   over anything darker. The label is told apart by its size, which costs
   nothing and cannot fail against a photograph. */
.info-label {
	font-size: 12.5px;
	margin-bottom: 6px;
}
.info-set a { position: relative; width: fit-content; }
/* A line that grows from the side you are on, rather than a colour that
   swaps: the page has no accent to spend on a hover. */
.info-set a::after {
	content: "";
	position: absolute;
	left: 0; right: 0; bottom: -2px;
	height: 1px;
	background: currentColor;
	transform: scaleX(0);
	transform-origin: 0 50%;
	transition: transform 320ms var(--ease);
}
.info-set a:hover::after { transform: scaleX(1); }
.info-set a:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; border-radius: 2px; }

/* It arrives the way everything else here does: the picture uncovered by
   its own edge, the words a beat behind it, the glass last, because it
   is the thing that sits on top of the other two. */
.information-container .info-photo,
.information-container .info-words > *,
.information-container .info-panel {
	opacity: 0;
	transition: opacity 600ms var(--ease), transform 600ms var(--ease), clip-path 700ms var(--ease);
}
.information-container .info-photo { clip-path: inset(0 0 100% 0); }
/* The arrival and the drag share one transform, so a panel that has been
   moved does not snap home the next time this page is opened. --dx and
   --dy are the drag; the pixels beside them are the arrival. */
.information-container .info-words > * { transform: translate(var(--dx, 0px), calc(var(--dy, 0px) + 14px)); }
.information-container .info-panel { transform: translate(var(--dx, 0px), calc(var(--dy, 0px) + 20px)); }

.information-container.active-section .info-photo { opacity: 1; clip-path: inset(0 0 0 0); }
.information-container.active-section .info-words > * { opacity: 1; transform: translate(var(--dx, 0px), var(--dy, 0px)); }
.information-container.active-section .info-panel { opacity: 1; transform: translate(var(--dx, 0px), var(--dy, 0px)); }

.information-container.active-section .info-name { transition-delay: 120ms; }
.information-container.active-section .info-say { transition-delay: 190ms; }
.information-container.active-section .info-work { transition-delay: 260ms; }
.information-container.active-section .info-panel { transition-delay: 340ms; }

@media (prefers-reduced-motion: reduce) {
	.information-container .info-photo,
	.information-container .info-words > *,
	.information-container .info-panel {
		transition-duration: 1ms;
		transition-delay: 0ms !important;
		clip-path: none;
		transform: none;
	}
}

/* One material, so one exemption from it */
@media (prefers-reduced-transparency: reduce) {
	.info-panel,
	.clients-strip,
	.info-clients:hover .clients-strip,
	.contact-card {
		background-color: rgba(var(--bg-rgb), .96);
		-webkit-backdrop-filter: none;
		        backdrop-filter: none;
	}
}

/* Both panels can be picked up and put somewhere else. Desktop only:
   on a phone the screen is the layout and there is nowhere to put them,
   and a drag there would fight the scroll. */
.info-clients,
.info-panel {
	cursor: grab;
	touch-action: none;
}
.info-clients.is-held,
.info-panel.is-held {
	cursor: grabbing;
	z-index: 3;
	transition: none;
}
/* Lifted, and lifted glass casts a deeper shadow. Written on whichever
   element carries the material — the wrapper is what gets held, but the
   glass is the strip inside it, and a token set on the strip itself
   wins over one inherited from the wrapper. */
.info-panel.is-held,
.info-clients.is-held .clients-strip {
	--glass-lift:
		0 2px 3px rgba(16, 16, 16, .05),
		0 26px 44px -18px rgba(16, 16, 16, .16),
		0 70px 120px -36px rgba(16, 16, 16, .26);
}
.info-clients.is-held .clients-strip { transition: none; }

/* A phone has no room for two columns, let alone an overlap, and it has
   no room for all of this on one screen either. Held to one, the
   photograph got whatever was left after the type and the two panels had
   taken theirs — 210px of a portrait on a 390px screen, which crops a
   standing figure to a band across his chest. So this page scrolls here.
   The rail scrolls, the catalogue scrolls; a page with a portrait, a
   sentence, a client wall and three groups of details is not the one
   surface on this site that has to fit in 844px.

   Everything else follows from giving the picture its own shape back. */
@media (max-width: 768px) {
	.information-container {
		height: 100dvh;
		overflow-y: auto;
		-webkit-overflow-scrolling: touch;
		align-items: flex-start;
		padding: 84px var(--gutter) 44px;
	}
	.info-grid {
		grid-template-columns: 1fr;
		grid-template-rows: auto;
		gap: 26px;
		max-height: none;
		height: auto;
	}

	/* A portrait, at a portrait's proportions */
	.info-photo { grid-column: 1; grid-row: auto; aspect-ratio: 4 / 5; min-height: 0; }
	.info-photo img { object-position: 50% 22%; }

	.info-words { grid-column: 1; grid-row: auto; padding-bottom: 0; }
	.info-name { margin-bottom: 12px; }
	/* Two lines at a size that does not run into the edge of the screen */
	.info-say { font-size: clamp(26px, 7.4vw, 32px); line-height: 1.1; max-width: none; }
	.info-work { margin-top: 14px; font-size: 16px; }

	.info-clients { margin-top: 26px; margin-inline: 0; }
	.clients-label { margin-left: 2px; margin-bottom: 9px; }
	.client { height: 46px; padding: 0 16px; }
	.client img { height: calc(17px * var(--s, 1)); max-width: 88px; }
	.client-word { font-size: 14px; }

	.info-clients, .info-panel { cursor: auto; touch-action: auto; }
	.info-panel {
		grid-column: 1; grid-row: auto;
		grid-template-columns: 1fr 1fr;
		gap: 18px 16px;
		margin-top: 4px;
		margin-bottom: 0;		/* nothing to hang off: it is below the picture here */
		padding: 20px 22px 22px;
		border-radius: 20px;
	}
	.info-set { gap: 4px; font-size: 15px; }
	.info-set:last-child { grid-column: 1 / 3; }
}

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

section { transition: opacity 300ms var(--ease); }
/* Invisible is not the same as absent. The index page sat behind the
   catalogue at opacity 0 with a cover image 1067px wide on a 390px
   screen — an image given only a height takes whatever width its aspect
   asks for. That overflow widened the document; a phone sizes its fixed
   elements to the document; and the whole catalogue was then laid out
   in a 1067px box on a 390px screen. Two guards: the pages hold their
   own overflow, and the covers cannot outgrow their box. */
section.inactive-section { opacity: 0; pointer-events: none !important; z-index: -1; overflow: hidden; }
section.inactive-section * { pointer-events: none !important; }
section.active-section { opacity: 1; pointer-events: auto !important; }

/* ------------------------------------------------------------------
   Catalogue — vertical snap of projects, each a horizontal snap of slides
   ------------------------------------------------------------------ */

.catalogue-container {
	position: fixed;
	inset: 0;
	display: flex;
	flex-direction: column;
	flex-wrap: nowrap;
	overflow-y: scroll;
	scroll-snap-type: y mandatory;
	scrollbar-width: none;

	/* The contact panel is authored in the markup and the projects get
	   inserted above it, which scroll anchoring compensates for by pushing
	   scrollTop down by the height of everything added — landing the page
	   on the last screen. This container does its own scroll management. */
	overflow-anchor: none;
}
.catalogue-container::-webkit-scrollbar { display: none; }

.show-splash-screen .catalogue-container.active-section { opacity: 0; }
.show-splash-screen .catalogue-container {
	animation: catalogue-fade-in 1000ms forwards;
	animation-delay: 1000ms;
}

.catalogue-project {
	position: relative;
	display: flex;
	flex-direction: row;
	flex-wrap: nowrap;
	flex-shrink: 0;
	flex-grow: 0;
	width: 100vw;
	height: 100dvh;
	overflow-x: scroll;
	scroll-snap-type: x mandatory;
	scroll-snap-align: center;
	scrollbar-width: none;
}
.catalogue-project::-webkit-scrollbar { display: none; }
.catalogue-project.hidden-project { display: none; }

.catalogue-slide {
	position: relative;
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
	flex-shrink: 0;
	flex-grow: 0;
	width: 100vw;
	height: 100dvh;
	gap: var(--gutter);
	padding: var(--frame) var(--gutter);
	scroll-snap-align: center;
	transition: transform 500ms var(--ease), opacity 600ms var(--ease);
}

.catalogue-slide figure {
	width: 100%;
	min-width: 0;
	max-width: 100%;
	height: calc(100dvh - (var(--frame) * 2));
	transition: transform 500ms var(--ease), opacity 600ms var(--ease);
}

.catalogue-slide figure img,
.catalogue-slide figure video {
	width: 100%;
	height: 100%;
	object-fit: contain;
	object-position: center;
}

/* Wordmark on screen — the work recedes behind it */
.show-title .catalogue-slide figure { opacity: .2; transform: scale(.9); }

.catalogue-project-credits {
	position: fixed;
	inset: 0;
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	padding: 155px var(--gutter);
	text-align: center;
	background: var(--bg);
	color: var(--muted);
	opacity: 0;
	pointer-events: none;
	transition: opacity 500ms var(--ease);
}
.catalogue-project-credits strong { color: var(--ink); }
.credits-open .active-project .catalogue-project-credits { opacity: 1; pointer-events: all; }

/* ------------------------------------------------------------------
   Minimap — every photograph in the open category, in one row.

   It used to be a vertical stack of one row per project, scrubbed by
   the page scroll, so you saw ragged fragments of two rows at once and
   it collided with the credits button. There is one row now: the open
   category's photographs, with a frame around the pair on screen.
   ------------------------------------------------------------------ */

.minimap-container {
	position: fixed;
	bottom: 38px; left: 50%;
	transform: translateX(-50%);
	z-index: 100;
	max-width: 90vw;
	overflow: hidden;
	transition: opacity 500ms var(--ease);
	-webkit-mask-image: linear-gradient(90deg, transparent, #fff 4%, #fff 96%, transparent);
	        mask-image: linear-gradient(90deg, transparent, #fff 4%, #fff 96%, transparent);
}

/* One or the other, never both. Hovering the caption brings the row up
   from under it; leaving sends it back. The caption folds away on the
   axis it already turns on, so the swap speaks the same language as the
   scroll does. */
.minimap-container {
	opacity: 0;
	pointer-events: none;
	transform: translateX(-50%) translateY(9px);
	filter: blur(5px);
	transition: opacity 300ms var(--ease), transform 380ms var(--ease), filter 300ms var(--ease);
}
.show-minimap-thumbnails .minimap-container {
	opacity: 1;
	pointer-events: auto;
	transform: translateX(-50%);
	filter: blur(0);
}

/* Pointer events go off with it. The caption and the row share one spot,
   so a caption left clickable would sit invisibly on top of the row and
   swallow every hover the thumbnails were meant to get. The row keeps
   itself up from here — it has its own mouseenter. */
.show-minimap-thumbnails .title-drum {
	opacity: 0;
	pointer-events: none;
	filter: blur(4px);
}
.show-minimap-thumbnails .title-cube { transform: rotateX(-58deg); }
.title-cube { transition: transform 380ms var(--ease); }

.show-title .minimap-container { opacity: .2; pointer-events: none; }
.show-splash-screen .minimap-container,
.credits-open .minimap-container { opacity: 0; pointer-events: none; }

.minimap-inner {
	position: relative;	/* the travelling frame is measured against this */
	display: flex;
	flex-direction: row;
	align-items: center;
	justify-content: center;
	padding: 0 4px;
}

/* Only the open category is on show — the rest would not fit and would
   not tell you anything about where you are */
.minimap-project {
	display: none;
	flex-direction: row;
	align-items: center;
	gap: 5px;
}
.minimap-project.hidden-project { display: none; }
.minimap-project.active-minimap-project { display: flex; }

/* A slide is one screen of the deck, so the frame goes round the whole
   slide — it marks the pair you are looking at, not one of the two. */
.minimap-slide {
	position: relative;
	display: flex;
	flex-direction: row;
	align-items: center;
	gap: 3px;
	padding: 3px;
	border-radius: 6px;
	cursor: pointer;
	box-shadow: inset 0 0 0 1px transparent;
	transition: box-shadow 350ms var(--ease), background 350ms var(--ease);
}
/* One frame for the whole row, not a border switched on per slide: it
   slides from the slide you were on to the slide you moved to, so the
   eye follows one object across instead of losing one and finding
   another. Width animates with it because slides are not all one size —
   a landscape sits alone, portraits go in pairs. */
.minimap-frame {
	position: absolute;
	top: 0; left: 0;
	border-radius: 6px;
	/* A frame, not a wash. It carried a white fill so it would read
	   against the row — but it is painted over the thumbnail it marks,
	   so the one photograph you were being pointed at was the one behind
	   55% white. It went bright under the cursor and then fogged over as
	   the frame caught up with it. The ring does the marking; the
	   photograph underneath is already the only one at full strength. */
	box-shadow:
		inset 0 0 0 1px rgba(16, 16, 16, .34),
		0 1px 3px rgba(16, 16, 16, .12);
	pointer-events: none;
	opacity: 0;
	transition:
		transform 420ms var(--ease),
		width 420ms var(--ease),
		opacity 250ms var(--ease);
}

@media (prefers-reduced-motion: reduce) {
	.minimap-frame { transition: opacity 250ms var(--ease); }
}

/* Without this the thumbnails render at full size and wrap into rows
   across the page — they are 26px tall and nothing else */
.minimap-image {
	flex-shrink: 0;
	flex-grow: 0;
	height: 26px;
	border-radius: 3px;
	overflow: hidden;
}
.minimap-image img {
	display: block;
	width: auto;
	height: 26px;
	opacity: .45;
	transition: opacity 350ms var(--ease);
}
.minimap-slide:hover .minimap-image img { opacity: .8; }
.active-minimap-project .minimap-slide.active-minimap-slide .minimap-image img { opacity: 1; }

/* ------------------------------------------------------------------
   Contact — the last screen of the catalogue.

   The backdrop was three photographs blurred into one another, which
   came out as a smear rather than a field of light. It is a gradient
   now: soft, very large tints in the site's own neutrals, with grain so
   a wash this gentle never bands. Glass still has something to refract,
   and nothing competes with the card.
   ------------------------------------------------------------------ */

.contact-panel {
	position: relative;
	flex-shrink: 0;
	flex-grow: 0;
	width: 100vw;
	height: 100dvh;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: var(--frame) var(--gutter);
	scroll-snap-align: center;
	overflow: hidden;
	background: var(--bg);
}

.contact-backdrop {
	position: absolute;
	inset: -10%;
	pointer-events: none;
	/* The panel clips this at its own top edge, so the tint used to
	   arrive at full strength on the first row and drew a hard line
	   across the screen against the plain page above. Fading it in over
	   the first fifth means the colour gathers as you scroll in, and
	   there is no edge to see. Bottom too, for the same reason. */
	-webkit-mask-image: linear-gradient(180deg, transparent 8%, #fff 27%, #fff 84%, transparent 97%);
	        mask-image: linear-gradient(180deg, transparent 8%, #fff 27%, #fff 84%, transparent 97%);
	opacity: 0;
	transform: scale(1.04);
	transition: opacity 900ms var(--ease), transform 1400ms var(--ease);
	background:
		radial-gradient(58% 44% at 18% 12%, rgba(203, 216, 232, .62), transparent 62%),
		radial-gradient(52% 40% at 84% 22%, rgba(235, 219, 208, .58), transparent 64%),
		radial-gradient(64% 46% at 62% 96%, rgba(206, 213, 222, .55), transparent 66%),
		radial-gradient(40% 30% at 8% 82%, rgba(224, 216, 228, .45), transparent 68%);
}

.contact-backdrop::after {
	content: "";
	position: absolute;
	inset: 0;
	opacity: .26;
	mix-blend-mode: overlay;
	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='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
}

/* ------------------------------------------------------------------
   The trail — photographs spawned under the cursor, then drawn into the
   middle of the panel until they are gone.

   All of it runs off one keyframe set per tile, so the compositor owns
   the motion and this file never asks a frame for anything. The place a
   tile is born goes in `left`/`top`; everything the animation touches is
   transform and opacity. The pull is a vector to the centre measured
   once at birth and handed over as --cx/--cy.
   ------------------------------------------------------------------ */

.contact-trail {
	position: absolute;
	inset: 0;
	z-index: 1;
	overflow: hidden;		/* nothing escapes the panel */
	pointer-events: none;	/* the form underneath stays reachable */
}

.trail-tile {
	position: absolute;
	top: var(--y); left: var(--x);
	width: var(--tile); height: var(--tile);
	/* born on the cursor, not beside it */
	margin: calc(var(--tile) / -2) 0 0 calc(var(--tile) / -2);
	border-radius: 12px;
	overflow: hidden;
	will-change: transform, opacity;
	box-shadow: 0 18px 40px -22px rgba(16, 16, 16, .45);
	animation: trail-tile var(--life) linear forwards;
}

.trail-tile img {
	display: block;
	width: 100%; height: 100%;
	object-fit: cover;
}

/* translate is written into every step: a transform list that changes
   shape between keyframes stops interpolating piece by piece and gets
   run through the matrix instead, which bends the path. */
@keyframes trail-tile {
	0% {
		transform: translate(0, 0) rotate(var(--r)) scale(0);
		opacity: 1;
		animation-timing-function: cubic-bezier(.22, 1.2, .36, 1);
	}
	9% { transform: translate(0, 0) rotate(var(--r)) scale(1.13); }
	15% { transform: translate(0, 0) rotate(var(--r)) scale(.97); }
	21% { transform: translate(0, 0) rotate(var(--r)) scale(1); }
	46% {
		transform: translate(0, 0) rotate(var(--r)) scale(1);
		opacity: 1;
		animation-timing-function: cubic-bezier(.5, 0, .78, .38);
	}
	100% {
		transform: translate(var(--cx), var(--cy)) rotate(calc(var(--r) * .35)) scale(0);
		opacity: 0;
	}
}

/* No cursor to follow, and the panel is short enough that a pile of
   photographs would only crowd the form */
@media (max-width: 768px) {
	.contact-trail { display: none; }
}

.contact-card {
	position: relative;
	z-index: 2;		/* over the trail — that is what the glass is blurring */
	width: 100%;
	max-width: 460px;
	padding: 38px 34px 32px;
	border-radius: 34px;
	overflow: hidden;

	/* Glass, not frosting: at 44px a photograph passing underneath came
	   through as a smear of colour and you could not tell there had been
	   a photograph at all. The material itself is up in the glass
	   section — this card is where it was worked out, and the other two
	   panels now take the same. */
	opacity: 0;
	transform: translateY(22px) scale(.975);
	filter: blur(10px);
	transition: opacity 700ms var(--ease), transform 700ms var(--ease), filter 700ms var(--ease);
}

.contact-in .contact-backdrop { opacity: 1; transform: scale(1); }
.contact-in .contact-card { opacity: 1; transform: none; filter: blur(0); }

.contact-card > *,
.contact-form > * {
	opacity: 0;
	transform: translateY(10px);
	transition: opacity 500ms var(--ease), transform 500ms var(--ease);
}
.contact-in .contact-card > *,
.contact-in .contact-form > * {
	opacity: 1;
	transform: none;
	transition-delay: calc(180ms + var(--i, 0) * 60ms);
}

/* Ink, not grey. These sit on glass with photographs moving under it,
   and grey over a dark frame measured 1.4:1 — the hierarchy here comes
   from size, case and tracking, which survive whatever passes behind. */
.contact-eyebrow {
	color: var(--ink);
	font-size: 11px;
	line-height: 11px;
	letter-spacing: .12em;
	text-transform: uppercase;
}

.contact-heading {
	margin: 12px 0 26px;
	font-size: 38px;
	line-height: 1.05;
	letter-spacing: -.035em;
}

/* One grouped list, hairlines between rows — no boxes inside boxes */
.field-group {
	border-radius: 22px;
	overflow: hidden;
	background: rgba(255, 255, 255, .34);
	box-shadow:
		inset 0 0 0 .5px rgba(255, 255, 255, .5),
		inset 0 1px 0 rgba(255, 255, 255, .7),
		0 1px 2px rgba(16, 16, 16, .04);
}

.field {
	position: relative;
	display: flex;
	flex-direction: row;
	align-items: baseline;
	gap: 14px;
	padding: 15px 18px;
	cursor: text;
	transition: background 260ms var(--ease);
}
.field + .field::before {
	content: "";
	position: absolute;
	top: 0; left: 18px; right: 18px;
	height: .5px;
	background: rgba(16, 16, 16, .09);
}
.field:focus-within { background: rgba(255, 255, 255, .5); }

.field > span {
	flex-shrink: 0;
	width: 88px;
	color: var(--ink);
	font-size: 14px;
	transition: color 200ms;
}
.field:focus-within > span { color: var(--ink); }

.field input,
.field textarea {
	flex: 1;
	min-width: 0;
	border: 0;
	outline: 0;
	padding: 0;
	background: none;
	color: var(--ink);
	font: inherit;
	letter-spacing: inherit;
	resize: none;
}
.field input::placeholder,
.field textarea::placeholder { color: var(--placeholder); }

.field-message { align-items: flex-start; }
.field-message textarea { line-height: 1.5; }

/* Invalid only after a submit attempt, never while still typing */
.contact-form.validated .field:has(:invalid) { background: rgba(255, 232, 232, .5); }
.contact-form.validated .field:has(:invalid) > span { color: #b4342c; }

.contact-send {
	position: relative;
	width: 100%;
	margin-top: 20px;
	padding: 15px;
	border-radius: 999px;
	overflow: hidden;
	background: linear-gradient(180deg, #2a2a2a, var(--ink));
	color: var(--on-ink);
	font: inherit;
	letter-spacing: -.01em;
	cursor: pointer;
	box-shadow:
		inset 0 1px 0 rgba(255, 255, 255, .18),
		0 1px 2px rgba(16, 16, 16, .18),
		0 10px 22px -10px rgba(16, 16, 16, .5);
	transition: transform 180ms var(--ease), box-shadow 260ms var(--ease), opacity 260ms var(--ease);
}
.contact-send:hover {
	box-shadow:
		inset 0 1px 0 rgba(255, 255, 255, .24),
		0 2px 4px rgba(16, 16, 16, .2),
		0 14px 28px -10px rgba(16, 16, 16, .55);
}
.contact-send:active { transform: scale(.985); }
.contact-send:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.contact-send[disabled] { opacity: .5; cursor: default; }

.contact-status {
	min-height: 20px;
	margin-top: 14px;
	text-align: center;
	font-size: 14px;
	color: var(--ink);
}
.contact-status.is-error { color: #b4342c; }

/* Sent — the form steps aside for the acknowledgement */
.contact-form.sent .field-group,
.contact-form.sent .contact-send { display: none; }
.contact-form.sent .contact-status { color: var(--ink); font-size: 17px; }

@media (max-width: 768px) {
	/* Pulled up under the last cover. The rail is a sticky frame inside a
	   taller pin, so once the frame reaches the pin's bottom there is
	   still a whole screen of scrolling left before the pin is out of the
	   way — a screen in which nothing happens and nothing is on it. The
	   panel starts in that screen instead, its top edge exactly where the
	   last card's bottom edge is, so the last cover and the beginning of
	   the contact sheet are on screen together.

	   That alone only puts an empty plane under the cover: the card is
	   centred in the panel, which is 190px down, and only its corner
	   would clear the fold. So the card is lifted within the panel and
	   the panel hangs the same distance below the bottom of the scroll —
	   it reaches the fold that much sooner, and still comes to rest
	   centred on the screen. Lifted by margin rather than by placement,
	   so none of it depends on how tall the card happens to be. */
	.contact-panel {
		margin-top: calc((100dvh - var(--open, 50dvh)) / -2);
		/* Short of a screen, and the card lifted by the same amount inside
		   it. Both together, or neither: the panel ends short so it comes
		   to rest that far down the screen, and the card sits that far up
		   inside it, which cancels at the bottom of the scroll and leaves
		   it centred exactly as before — while at the last cover it has
		   reached the fold twice as early. A margin, not a placement, so
		   nothing here depends on how tall the card happens to be. */
		height: calc(100dvh - var(--tease));
		padding-block: 40px;		/* 120 would bind before the card was lifted */
	}
	.contact-card { margin-bottom: var(--tease); }
	.contact-card { padding: 30px 24px 26px; border-radius: 28px; }
	.contact-heading { font-size: 30px; margin-bottom: 22px; }
	.field { flex-direction: column; align-items: stretch; gap: 6px; padding: 13px 16px; }
	.field > span { width: auto; }
	.field + .field::before { left: 16px; right: 16px; }
}

/* ------------------------------------------------------------------
   Masked image rail — phones only.

   A narrow column down the middle. Five slots on screen: the centre one
   open, the rest squeezed to bands. What changes is the mask, not the
   picture — every photograph is laid in at the open height and stays
   there, so squeezing a slot closes a window over a still image rather
   than re-cropping it. Two empty slots pad each end so the first and
   last photograph reach the centre like any other.
   ------------------------------------------------------------------ */

.stack { display: none; }

@media (max-width: 768px) {
	/* The rail replaces the decks, so the machinery built around one
	   project per viewport steps aside with them. */
	/* Proximity, not mandatory: mandatory drags the container to the
	   nearest point the moment it is laid out, which knocks the rail off
	   its centre line before anyone has scrolled. What actually stopped
	   the rail fighting the thumb was shortening the travel per cover. */
	.catalogue-container:has(.stack) { scroll-snap-type: y proximity; }
	.catalogue-container:has(.stack) .catalogue-project,
	.catalogue-container:has(.stack) .minimap-container,
	.catalogue-container:has(.stack) .title-drum { display: none; }

	/* The way out of a category, in the corner the burger was in — while
	   a deck is open that corner has one obvious job, and it is not the
	   menu. Same circle, same glass, an arrow instead of the bars. */
	.deck-back {
		position: fixed;
		top: 16px; left: 16px;
		z-index: 101;
		display: none;
		align-items: center;
		justify-content: center;
		width: 46px; height: 46px;
		border-radius: 50%;
		background: var(--glass-tint);
		-webkit-backdrop-filter: blur(16px) saturate(180%);
		        backdrop-filter: blur(16px) saturate(180%);
		box-shadow:
			inset 0 1px 0 var(--glass-spec),
			inset 0 -1px 0 var(--glass-bounce),
			var(--glass-lift);
		color: var(--glass-ink);
		cursor: pointer;
	}
	.deck-open .deck-back { display: flex; }
	/* The opening docks into the rail as its first card and goes on
	   tracking it. With a category full screen it has nothing to be on
	   top of — and it sits at z-index 100 against the deck's 60, so a
	   card box left over from a rail that has since been rebuilt paints
	   a portrait straight over the photograph you opened. */
	.deck-open .name { opacity: 0; pointer-events: none; }
	.name { transition: transform 900ms var(--ease-move), opacity 300ms var(--ease); }
	.deck-open .glass-nav { opacity: 0; pointer-events: none; }
	.deck-back:active { transform: scale(.94); }
	.deck-back:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

	/* The readout, opposite corner to the way out: bottom centre, where a
	   thumb is already resting and where it is furthest from the face of
	   whatever is in the frame. Same small glass as the back button —
	   this is chrome, not one of the three big panels. */
	.deck-progress {
		position: fixed;
		left: 50%;
		bottom: calc(18px + env(safe-area-inset-bottom, 0px));
		z-index: 101;
		display: none;
		align-items: center;
		gap: 10px;
		padding: 0 14px;
		height: 30px;
		transform: translateX(-50%);
		border-radius: 999px;
		background: var(--glass-tint);
		-webkit-backdrop-filter: blur(16px) saturate(180%);
		        backdrop-filter: blur(16px) saturate(180%);
		box-shadow:
			inset 0 1px 0 var(--glass-spec),
			inset 0 -1px 0 var(--glass-bounce),
			var(--glass-lift);
		color: var(--glass-ink);
		font-size: 12px;
		letter-spacing: .01em;
		pointer-events: none;		/* a readout, not a control */
	}
	.deck-open .deck-progress { display: flex; }

	.deck-progress-track {
		position: relative;
		width: 62px; height: 3px;
		border-radius: 999px;
		background: rgba(16, 16, 16, .14);
		overflow: hidden;
	}
	/* scaleX off a full-width bar: a swipe moves this on every scroll
	   event and a width would ask for a layout each time */
	.deck-progress-track i {
		display: block;
		width: 100%; height: 100%;
		border-radius: inherit;
		background: var(--ink);
		transform-origin: left center;
		transform: scaleX(var(--at, 0));
		transition: transform 220ms var(--ease);
	}
	/* The count sits still while it changes: tabular figures, or the "z"
	   and the total shuffle sideways every time the index gains a digit. */
	.deck-progress-count { font-variant-numeric: tabular-nums; color: var(--glass-muted); }
	.deck-progress-count b { font-weight: 500; color: var(--glass-ink); }

	/* The arrow: two strokes off one corner of a square, turned. Drawn
	   rather than set, so it lines up with the burger's weight. */
	.deck-back-arrow {
		width: 10px; height: 10px;
		margin-left: 3px;
		border-left: 1.6px solid currentColor;
		border-bottom: 1.6px solid currentColor;
		transform: rotate(45deg);
	}

	/* A circle rather than the pill: the same glass, the same panel, the
	   same button — there is no room beside a mark on a phone for the
	   line of text the pill carries, so the text goes and the shape
	   follows it. Three bars because a circle has the room for it. */
	.glass-bar {
		width: 46px; height: 46px;
		padding: 0;
		justify-content: center;
		border-radius: 50%;
	}
	.glass-status { display: none; }
	.burger { width: 17px; height: 17px; }
	.burger i { height: 1.6px; margin-top: -.8px; }
	.burger i:nth-child(1) { transform: translateY(-5px); }
	.burger i:nth-child(2) { transform: translateY(5px); }
	.burger i:nth-child(3) { display: block; transition: opacity 200ms var(--ease); }
	[aria-expanded="true"] .burger i:nth-child(1) { transform: rotate(45deg); }
	[aria-expanded="true"] .burger i:nth-child(2) { transform: rotate(-45deg); }

	.glass-nav { top: 16px; left: 16px; }
	.glass-panel { width: calc(100vw - 32px); max-width: 320px; }

	/* The catalogue is a column flex container, so without this the pin
	   gets shrunk to fit and takes the sticky frame down with it */
	.stack { display: block; flex-shrink: 0; }

	/* The card's measurements, up here because two things aim at them:
	   the rail that draws the cards and the opening that becomes one.
	   Measured off the reference recording: the column is 22% of a
	   1280px frame — 282px — with 6-8px gaps. A phone is 390 wide, so
	   66vw lands at ~257px, which keeps that absolute size and the
	   margin either side the effect depends on. Full width turned the
	   bands into slabs of photograph fighting the middle one. */
	:root {
		--rail: 66vw;
		--open: 50dvh;		/* reference centre slot sits at 39-53% of frame */
		--shut: 6dvh;
		--card-round: 14px;
		--tease: 60px;		/* lifts the contact card this far above the fold */
	}

	/* The opening does not slide away on a phone, it becomes one of the
	   cards: the screen closes down to the same box the rail draws, with
	   the same corners, and only then goes. Coming back it opens out of
	   that box. Clipped rather than scaled, so the photograph inside
	   never stretches on the way. */
	/* The opening never moves and nothing is ever handed over: it stays
	   where it is, full screen, and script clips it to wherever its card
	   in the rail happens to be. Docking is that clip closing in, and it
	   keeps tracking afterwards, so the picture rides the rail without
	   ever being a different element in a different place.

	   Handing the film to the card instead meant two motions with a
	   pause between them, and a reparented element that landed in the
	   wrong slot whenever the rail was rebuilt underneath it. */
	.name { transform: none; }

	/* Moved and scaled as one while docking; the clip stays on .name */
	.name-inner { will-change: transform; }

	/* The card is the hole the opening shows through, so it stays dark
	   and empty. No label either: it is not a category. */
	.stack-item.is-opening .stack-media { background: #0b0b0b; }

	.stack-frame {

		position: sticky;
		top: 0;
		height: 100dvh;
		overflow: hidden;
	}

	/* Absolute and fixed-height: nothing here changes layout. The mask is
	   a clip-path and the position a transform, so a frame is pure
	   compositing — animating height meant relaying out the whole list
	   sixty times a second, which is what felt notchy.

	   Vertical stays the browser's; sideways belongs to the open gesture,
	   or the scroller claims the drag and it never reaches the handler. */
	.stack-list {
		touch-action: pan-y;
		position: absolute;
		inset: 0;
	}

	.stack-item {
		position: absolute;
		top: 0; left: 50%;
		width: var(--rail);
		height: var(--open);
		margin-left: calc(var(--rail) / -2);
		overflow: hidden;
		background: var(--surface);
		/* Only while it is in play. Eight permanent layers is eight
		   textures a phone has to keep and composite on every frame of a
		   scroll, and the ones parked off screen never change. */
		will-change: auto;
	}

	.stack-item.is-live { will-change: transform, clip-path; }

	.stack-item.is-spacer { background: none; }

	/* No transition on transform: the position is written every scroll
	   frame, and a transition on top of that leaves the slot permanently
	   catching up. It is added only to spring back from a drag. */
	.stack-item.is-releasing { transition: transform 320ms var(--ease); }

	/* Held at the open height whatever the mask is doing, so a slot
	   opening reveals more of the same frame instead of recomposing it */
	.stack-media {
		width: 100%;
		height: 100%;
		object-fit: cover;
		object-position: center;
		-webkit-user-drag: none;
		user-select: none;
	}

	/* The caption rides on the cover — at this width there is no margin
	   left to put it in */
	.stack-label {
		position: absolute;
		left: 18px; bottom: 16px;
		color: #fff;
		font-size: 17px;
		line-height: 1.25;
		text-shadow: 0 1px 14px rgba(0, 0, 0, .6);
		opacity: 0;
		transform: translateY(6px);
		transition: opacity 320ms var(--ease), transform 320ms var(--ease);
	}
	.stack-label i { display: block; font-style: normal; font-size: 12px; opacity: .75; }
	.stack-item.is-open .stack-label { opacity: 1; transform: none; }

	/* One per photograph, at the scroll offset that centres it */
	.stack-snap {
		position: absolute;
		left: 0;
		width: 1px; height: 1px;
		pointer-events: none;
		scroll-snap-align: start;
	}

	/* --- the open category ---------------------------------------- */

	.catalogue-container:has(.stack) .catalogue-project.deck-active {
		touch-action: pan-x;
		display: flex;
		position: fixed;
		inset: 0;
		z-index: 60;
		height: 100dvh;
		background: var(--bg);
		overflow-x: scroll;
		scroll-snap-type: x mandatory;
		overscroll-behavior-x: contain;
	}

	/* Square edges, one photograph per screen. The desktop pairs portraits
	   two-up inside a slide, so the snap target here is the figure, not
	   the slide — otherwise a pair would share the screen. */
	/* Keyed to the element, not to the state that animates it. These
	   change the layout inside the deck, and deck-open goes the instant a
	   close begins — so the photograph resized in one frame while the
	   window around it took 480ms to follow. deck-active outlives the
	   animation in both directions, and while it is on without deck-open
	   the deck is clipped down to the card and cannot be seen anyway. */
	.catalogue-project.deck-active .catalogue-slide {
		width: auto;
		height: 100dvh;
		flex-shrink: 0;
		padding: 0;
		gap: 0;
		scroll-snap-align: none;
	}
	.catalogue-project.deck-active .catalogue-slide figure {
		width: 100vw;
		height: 100dvh;
		padding: 0 7vw;		/* let it breathe rather than run to the edges */
		border-radius: 0;
		scroll-snap-align: center;
		opacity: 1;
		transform: none;
	}
	.catalogue-project.deck-active .catalogue-slide figure img { object-fit: contain; }

	/* The rail is built out of masks, so opening one continues that
	   language: the cover's window grows to the whole screen. The first
	   photograph in the deck is the same file as the cover, so it reads as
	   that picture opening rather than a new page arriving. */
	.catalogue-container:has(.stack) .catalogue-project.deck-active {
		/* Both the window and the picture, from the card's box to the whole
		   screen. The clip is given in the deck's own coordinates — worked
		   back through the scale in setFromCard, since a transform moves
		   those coordinates under it — and the corner radius with it, so
		   14px on the card stays 14px on the card. */
		clip-path: inset(var(--from-top, 0px) var(--from-right, 0px) var(--from-bottom, 0px) var(--from-left, 0px)
			round calc(14px / var(--from-scale, 1)));
		transform: translate(var(--from-x, 0px), var(--from-y, 0px)) scale(var(--from-scale, 1));
		transform-origin: 50% 50%;
		transition: clip-path 480ms var(--ease-deck), transform 480ms var(--ease-deck);
	}
	.deck-open .catalogue-container:has(.stack) .catalogue-project.deck-active {
		clip-path: inset(0px 0px 0px 0px round 0px);
		transform: none;
	}

	/* Faded, and only faded. It was scaled to .96 as well — pushed aside
	   rather than dismissed — but scaling a rail moves every card in it:
	   each one sat 9px high and 17px short for as long as a deck was
	   over them, and coming back they slid down into place behind the
	   closing window. The deck lands exactly on its card; the cards have
	   to be there already, not still arriving.

	   The fade stays slow. A rail that evaporates in 60ms was half of
	   why this read as a cut rather than a move. */
	.stack { transition: opacity 480ms var(--ease-deck); }
	.deck-open .stack {
		opacity: 0;
		pointer-events: none;
	}
	.deck-open .contact-panel { visibility: hidden; }

	/* One per photograph, at the scroll offset that centres it */
	.stack-snap {
		position: absolute;
		left: 0;
		width: 1px; height: 1px;
		pointer-events: none;
		scroll-snap-align: start;
	}

	/* Held at the open height whatever the mask is doing, so a slot
	   opening reveals more of the same frame instead of recomposing it */
	.stack-media {
		flex-shrink: 0;
		width: 100%;
		height: var(--open);
		object-fit: cover;
		object-position: center;
	}

}

/* --- caption fold --------------------------------------------------
   The same turn the title reel uses, at caption scale: each caption is a
   face on a cylinder, folding away as the next comes up square.
   ------------------------------------------------------------------ */

.title-drum {
	position: fixed;
	/* The same line as the thumbnail row: they trade places, so they have
	   to occupy one spot. 29px against the row's 38px because the row
	   carries its own padding — this puts the two centres together. */
	bottom: 29px; left: 50%;
	transform: translateX(-50%);
	z-index: 100;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 100%;
	height: 32px;
	pointer-events: auto;	/* the hover target that reveals the row */
	perspective: 420px;		/* deep enough that a 40px radius doesn't distort */
	transition: opacity 500ms var(--ease);
}

.title-cube {
	/* Measured off the reference: both captions on screen share one tilt,
	   so it belongs to the assembly rather than to each line. It is level
	   at rest, reaches about 6.5deg mid-turn, and flips direction on each
	   boundary — JS drives --tilt, which carries the sign. */
	transform: rotateZ(calc(var(--tilt, 0) * 1deg));

	--step: 52deg;
	/* In the reference the two captions in transit clear each other by
	   about 1.9 line heights. At 52deg that needs a radius near twice
	   the line box — at half of it they overlapped and read as one
	   smudged block. */
	--radius: 40px;
	position: relative;
	width: 100%;
	height: var(--face);
	transform-style: preserve-3d;
}

.title-face {
	/* distance from the centre line, in projects, and its absolute value —
	   max(d, -d) rather than abs() so older Safari isn't left out */
	--d: calc(var(--i) - var(--p, 0));
	--ad: max(var(--d), calc(var(--d) * -1));

	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	transform-style: preserve-3d;
	will-change: transform, opacity;

	transform: rotateX(calc(var(--d) * var(--step) * -1)) translateZ(var(--radius));
	/* Read off the reference: at rest exactly one caption is on screen,
	   and mid-turn two — both at full strength, separated by the fold
	   rather than by fading. A linear ramp left every neighbour sitting
	   there at 15%, which is what read as a smear behind the text. */
	opacity: clamp(0, calc((1 - var(--ad)) * 4), 1);
}

.title-face span {
	display: block;
	white-space: nowrap;
	color: var(--ink);
	/* the second axis — each caption twists against the one before it */
	transform:
		translateX(calc(var(--d) * var(--dir) * 10px))
		rotateY(calc(var(--d) * var(--dir) * 11deg));
}
.title-face i { font-style: normal; color: var(--muted); }

@media (prefers-reduced-motion: reduce) {
	/* A fold is a rotation; flatten it and cut between captions instead */
	.title-face { transform: none; opacity: calc(1 - var(--ad) * 4); }
	.title-face span { transform: none; }
}

.show-minimap-thumbnails .title-drum,
.show-arrow-minimap-thumbnails .title-drum,
.show-splash-screen .title-drum,
.credits-open .title-drum,
.menu-open .title-drum,
.contact-in .title-drum,
.contact-in .minimap-container {
	opacity: 0;
}
/*.contact-in 



/* Credits is offered only while the filmstrip is up — same moment the
   thumbnails appear, so the bottom edge only ever animates once */


/* Idle — after 3s of stillness the chrome gets out of the way */
.hide-interface .minimap-container { opacity: 0; }

/* ------------------------------------------------------------------
   Touch / small screens
   ------------------------------------------------------------------ */

/* The pill behaves the same on touch as on a pointer, so there is no
   separate touch menu any more — only the cursor readout goes away. */

@media (max-width: 768px) {
	body { font-size: 16px; line-height: 1.4em; }

	.catalogue-slide {
		width: auto;
		gap: 0;
		padding: var(--frame) 0;
		scroll-snap-align: none;
	}
	.catalogue-slide figure {
		width: 100vw;
		height: calc(100dvh - 310px);
		padding: 0 var(--gutter);
		scroll-snap-align: center;
		transition: transform 800ms, opacity 800ms;
	}
	.show-title .catalogue-slide figure { opacity: .1; transform: scale(1); }

	.minimap-container { bottom: 0; pointer-events: none; }
}

@media (prefers-reduced-motion: reduce) {
	*, *::before, *::after {
		animation-duration: 1ms !important;
		animation-delay: 0ms !important;
		transition-duration: 1ms !important;
		scroll-behavior: auto !important;
	}

	/* The caption cube is driven by an inline transform rather than a
	   transition, so the blanket rule above never reaches it. Flatten it
	   to a single face that just swaps at each project boundary. */
	.title-cube { transform: none; }
	.title-face:nth-child(1) { opacity: 1; }
	.title-face:nth-child(2) { display: none; }
}
