/* ==========================================================================
   Tolli — creative development studio
   Palette derives from Combustion's flame spectrum (#ff6b35 → #ff43c5 →
   #4f34ea). Tolli takes the cool violet end; orange stays with Combustion.
   ========================================================================== */

:root {
	--ink: #0b0714;
	--panel: #150c24;
	--rule: #2e2044;
	--lilac: #e9ddff;
	--paper: #ffffff;

	--arc-a: #6b4bff;
	--arc-b: #b44bff;
	/* the crest of the arc — same hue family, lifted toward --lilac so moving
	   gradients have luminance to travel on, not just hue */
	--arc-hi: #e0a8ff;

	/* ── the shader ramp ──────────────────────────────────────────────────
	   One violet run, shared by the Blue Steel button, the headline accent
	   and the nav. --quote-hi IS the button's data-shader-tint.

	   The defining feature of the button's palette is that green sits near
	   zero all the way down: its darks sample as #000037 and #080066, which
	   are blue, not grey-violet. Keep G low or these stop matching it.

	   The dark end is violet, not blue. Sampling the button's raw pixels put it
	   at #080066 — almost pure blue, and it read as arriving from outside the
	   palette. Red is lifted here to swing the hue back toward the violet the
	   rest of the site runs on, and the whole run is a little brighter, which
	   also takes the harshness out of the contrast against --quote-hi.

	   It still leans dark, which only works because .hero h1 em carries a white
	   drop-shadow — the letterforms keep a lit edge however dark the fill goes.
	   Remove that shadow and this becomes unreadable. */
	--quote-lo: #2d0f70;
	--quote-mid: #5a2aa8;
	--quote-hi: #a86aff;

	/* Nav: five equidistant steps along the same run, shifted up into a band
	   that stays readable at 14px. The bottom of the quote ramp is luminance
	   57 — fine for a 5rem headline, illegible for a nav label. */
	--nav-1: #8f6bff;
	--nav-2: #a283ff;
	--nav-3: #b69cff;
	--nav-4: #c9b4ff;
	--nav-5: #dcccff;
	--arc: linear-gradient(100deg, var(--arc-a), var(--arc-b));

	--muted: #a294c4;

	--display: 'Sora', system-ui, sans-serif;
	--body: 'Archivo', system-ui, sans-serif;
	--mono: 'JetBrains Mono', ui-monospace, monospace;

	/* The sticky bar's height. Three places need it in agreement: the bar
	   itself, the mobile drawer that hangs off its bottom edge, and the
	   closing screen, which fills exactly the rest of the viewport. */
	--nav-h: 72px;

	/* The footer's own height. Measured and rewritten by site.js — see the
	   closing screen rules at the bottom of this file. */
	--footer-h: 12rem;

	--wrap: 1280px;
	--gutter: clamp(1.25rem, 4vw, 3rem);
	--band: clamp(4.5rem, 9vw, 8rem);

	--radius: 14px;
}

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

/* The ground colour lives on html, not body. #field is a fixed canvas behind
   the page, and a background on body would propagate to the root canvas and
   paint over it. */
html {
	-webkit-text-size-adjust: 100%;
	/* The no-JS answer only. site.js switches this off inline as soon as it
	   runs and animates jump links itself, because the browser's own duration
	   grows with distance and the run to #contact reads as a stall. Leave it:
	   without it, and without JS, every nav click is a hard cut. */
	scroll-behavior: smooth;
	background: var(--ink);
}

body {
	margin: 0;
	background: transparent;
	color: var(--lilac);
	font-family: var(--body);
	font-size: 1.0625rem;
	line-height: 1.65;
	font-weight: 400;
	-webkit-font-smoothing: antialiased;
	overflow-x: hidden;
}

h1,
h2,
h3 {
	font-family: var(--display);
	color: var(--paper);
	margin: 0;
	line-height: 1.05;
	letter-spacing: -0.025em;
	text-wrap: balance;
}

p {
	margin: 0 0 1.1em;
	text-wrap: pretty;
}

p:last-child {
	margin-bottom: 0;
}

img,
svg {
	max-width: 100%;
	display: block;
}

a {
	color: var(--paper);
	text-decoration-color: color-mix(in srgb, var(--arc-b) 60%, transparent);
	text-underline-offset: 0.22em;
	text-decoration-thickness: 1px;
}

a:hover {
	text-decoration-color: var(--arc-b);
}

:focus-visible {
	outline: 2px solid var(--arc-b);
	outline-offset: 3px;
	border-radius: 3px;
}

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

/* ---------------------------------------------------------------------------
   The field and the surfaces that share it

   #field is the page's only WebGL context: one canvas, viewport-fixed, sitting
   behind all content. The hero shows it directly. Everything else is opaque
   and hides it — which is why .band carries an explicit background now that
   body no longer has one. Remove that background and the smoke reappears under
   the entire page.

   A .surface is a plain 2D canvas that copies the region of #field directly
   behind its host, so several of them on screen at once read as windows onto
   one continuous field rather than as repeated copies. See site.js.
   --------------------------------------------------------------------------- */

.band {
	padding-block: var(--band);
	border-top: 1px solid var(--rule);
	position: relative;
	z-index: 1;
	background: var(--ink);
	/* Offsets the sticky nav so an anchored section doesn't land with its
	   heading underneath it. Now that Blog and Contact are sections rather
	   than pages, this is on the main navigation path.

	   Tuned by eye: the band's own top padding does most of the clearing, so
	   this sits below the 72px nav height rather than above it. */
	scroll-margin-top: -32px;
}

#field {
	position: fixed;
	inset: 0;
	z-index: 0;
	display: block;
	width: 100%;
	height: 100%;
	pointer-events: none;
	opacity: 0.72;
}

.nav,
.footer,
.skip {
	position: relative;
	z-index: 2;
}

.surface {
	position: absolute;
	inset: 0;
	z-index: 0;
	display: block;
	width: 100%;
	height: 100%;
	max-width: none;
	opacity: 0;
	pointer-events: none;
}

/* Hosts stack their own content above their surface */
.pillar,
.pillar-ask,
.tile-body,
.case-body {
	position: relative;
}

.pillar > *:not(.surface),
.pillar-ask > *:not(.surface),
.tile-body > *:not(.surface),
.case-body > *:not(.surface) {
	position: relative;
	z-index: 1;
}

.skip {
	position: absolute;
	left: -9999px;
	top: 0;
	background: var(--paper);
	color: var(--ink);
	padding: 0.7rem 1.1rem;
	z-index: 100;
	font-family: var(--mono);
	font-size: 0.925rem;
}

.skip:focus {
	left: 0.5rem;
	top: 0.5rem;
}

/* --- shared type devices ------------------------------------------------
   The mono label tier (nav, eyebrows, tags, captions, stats, footer) was
   raised by 2px across the board — it read as too small at every size. Root
   is the 16px default, so that is +0.125rem on each value; the odd-looking
   numbers below are the result. Nothing here should go under 0.785rem.
   ------------------------------------------------------------------------ */

.eyebrow {
	font-family: var(--mono);
	font-size: 0.845rem;
	font-weight: 500;
	letter-spacing: 0.14em;
	text-transform: uppercase;
	color: var(--muted);
	margin: 0 0 1.4rem;
}

.section-title {
	font-size: clamp(1.9rem, 3.4vw, 2.9rem);
	font-weight: 400;
	max-width: 20ch;
}

.section-lead {
	margin-top: 1.2rem;
	max-width: 58ch;
	font-size: clamp(1.02rem, 1.3vw, 1.15rem);
	color: var(--lilac);
}

.mono {
	font-family: var(--mono);
	font-size: 0.875rem;
	letter-spacing: 0.06em;
	color: var(--muted);
}

/* Live indicator — used wherever the page claims something is really running */
.live {
	display: inline-flex;
	align-items: center;
	gap: 0.45rem;
	font-family: var(--mono);
	font-size: 0.825rem;
	letter-spacing: 0.1em;
	text-transform: uppercase;
	color: var(--muted);
}

.live::before {
	content: '';
	width: 6px;
	height: 6px;
	border-radius: 50%;
	background: var(--arc-b);
	box-shadow: 0 0 0 0 color-mix(in srgb, var(--arc-b) 70%, transparent);
	animation: pulse 2.4s ease-out infinite;
}

/* Nothing is moving — so the indicator shouldn't move either */
.live.is-off::before {
	background: var(--muted);
	animation: none;
	box-shadow: none;
}

@keyframes pulse {
	0% {
		box-shadow: 0 0 0 0 color-mix(in srgb, var(--arc-b) 70%, transparent);
	}
	70% {
		box-shadow: 0 0 0 7px transparent;
	}
	100% {
		box-shadow: 0 0 0 0 transparent;
	}
}

/* --- buttons ------------------------------------------------------------ */

.btn {
	display: inline-flex;
	align-items: center;
	gap: 0.6rem;
	font-family: var(--display);
	font-size: 0.95rem;
	font-weight: 600;
	letter-spacing: -0.01em;
	padding: 0.85rem 1.5rem;
	border-radius: 100px;
	text-decoration: none;
	border: 1px solid transparent;
	transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}

.btn-primary {
	background: var(--arc);
	color: var(--paper);
}

/* --- shader buttons ------------------------------------------------------
   Any element with data-shader gets a live shader behind its label. The
   canvas is inserted by site.js as the first child.

   The gradient stays underneath as the fallback: no WebGL, no JS, or a
   shader that fails to compile all leave a perfectly good button rather than
   a blank pill. The canvas simply never appears.

   The shader draws its own rounded silhouette from an SDF, so the button
   needs no overflow clipping and the canvas needs no border-radius.
   ------------------------------------------------------------------------ */
[data-shader] {
	position: relative;
	isolation: isolate;
}

.btn-shader {
	position: absolute;
	inset: 0;
	z-index: -1;
	display: block;
	width: 100%;
	height: 100%;
	max-width: none;
	pointer-events: none;
}

[data-shader] > *:not(.btn-shader) {
	position: relative;
	z-index: 1;
}

/* Chrome sweeps a near-white highlight across the button. Measured, contrast
   behind the label swings from ~20:1 down to ~1.1:1 as that band passes — so
   the label needs to carry its own separation rather than rely on what's
   behind it. */
[data-shader] {
	text-shadow:
		0 0 4px rgba(0, 0, 0, 0.5),
		0 0 10px rgba(0, 0, 0, 0.5),
		0 0 18px rgba(0, 0, 0, 0.5);
}

/* The shader answers the pointer itself — it dims and stirs under the cursor.
   The lift and glow the other buttons use would fight that, and moving the
   target out from under the cursor is a poor trade for decoration.
   `.btn[data-shader]` rather than `[data-shader]` so it outranks
   `.btn-primary:hover` wherever these rules end up in the file. */
.btn[data-shader]:hover {
	transform: none;
	box-shadow: none;
}

.btn-primary:hover {
	transform: translateY(-2px);
	box-shadow: 0 10px 30px -10px var(--arc-a);
}

.btn-ghost {
	border-color: var(--quote-lo);
	color: var(--lilac);
}

.btn-ghost:hover {
	border-color: var(--quote-mid);
	color: var(--paper);
}

/* Slow breathing border — travels between the resting colour and the hover
   colour, so the invitation and the rollover speak the same language.

   The :hover reset is required, not tidiness. A running animation outranks
   normal declarations in the cascade, so without it the border keeps pulsing
   straight through the hover state instead of settling on it. */
.btn-pulse {
	animation: btn-throb 4.5s ease-in-out infinite;
}

.btn-pulse:hover {
	animation: none;
}

@keyframes btn-throb {
	0%,
	100% {
		border-color: var(--quote-lo);
	}
	50% {
		border-color: var(--quote-mid);
	}
}

/* --- nav ---------------------------------------------------------------- */

.nav {
	position: sticky;
	top: 0;
	z-index: 50;
	background: color-mix(in srgb, var(--ink) 82%, transparent);
	backdrop-filter: blur(14px);
	border-bottom: 1px solid var(--rule);
}

.nav-inner {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 1.5rem;
	height: var(--nav-h);
}

/* --- wordmark ------------------------------------------------------------
   Liquid Chrome, but in the mode it was actually written for: masked to a
   silhouette rather than draped over a pill. The silhouette is the word
   itself, rendered to a texture — see buildMask() in site.js.

   Three things this rule has to guarantee for that to line up:

   1. inline-block + line-height 1 gives the canvas a containing block with a
      known height, so the mask's baseline maths has something stable to
      resolve against. As an inline element the box would depend on the line
      box around it and drift.
   2. .wm-ink stays in the DOM at full size — the shader never measures the
      word itself, it measures this span. Delete the span and the mask has no
      idea where the letters go.
   3. The ink only goes transparent under .is-shaded, which site.js adds after
      a frame has actually been copied out. No WebGL, no JS, a shader that
      fails to compile — all leave a plain white wordmark rather than a hole.
   -------------------------------------------------------------------------- */
.wordmark {
	font-family: var(--display);
	font-weight: 700;
	font-size: 2.8rem;
	letter-spacing: -0.04em;
	color: var(--paper);
	text-decoration: none;
	display: inline-block;
	line-height: 1;
}

.wordmark .wm-dot {
	background: var(--arc);
	-webkit-background-clip: text;
	background-clip: text;
	color: transparent;
}

/* line-height 1 crops the em box, and the canvas is inset:0 by default — the
   ascenders on t/l/i would be sliced off. Bleed it past the element on all
   four sides and let the mask maths use the larger box. */
.wordmark .btn-shader {
	/* Explicit sizes, not `inset` + `auto`. A canvas is a replaced element, so
	   `width:auto` resolves to its intrinsic size — the width attribute — and
	   drops the `right` offset. drawButtons() sets that attribute from the
	   measured rect, so auto sizing feeds back on itself and the canvas grows
	   every frame until it hits the field's clamp. Ask for a real size and the
	   attribute only ever means "backing store". */
	top: -0.34em;
	left: -0.16em;
	right: auto;
	bottom: auto;
	width: calc(100% + 0.32em);
	height: calc(100% + 0.68em);
	/* Same separation language as the shader button's label: the nav is only
	   82% opaque, so whatever scrolls under it can wash out the darker bands. */
	filter: drop-shadow(0 1px 5px rgba(0, 0, 0, 0.55));
}

.wordmark.is-shaded .wm-ink {
	color: transparent;
	/* The inherited [data-shader] glow would paint straight through unfilled
	   glyphs and flood them solid — the same trap .hero h1 em documents. */
	text-shadow: none;
}

.nav-links {
	display: flex;
	align-items: center;
	gap: 2rem;
	list-style: none;
	margin: 0;
	padding: 0;
}

.nav-links a {
	font-family: var(--mono);
	font-size: 0.905rem;
	letter-spacing: 0.08em;
	text-transform: uppercase;
	color: var(--muted);
	text-decoration: none;
	transition: color 0.2s ease;
}

/* Every item runs the same five-colour cycle; the negative delays are what
   stagger them. At -3.2s apart on a 16s loop, the five sit exactly one fifth
   of the cycle apart from each other and hold that spacing forever — so the
   nav always shows the whole ramp at once, drifting.

   The nth-child colours are the static fallback and also the starting frame,
   so the nav is never uncoloured if the animation doesn't run. */
.nav-links li:nth-child(1) a {
	color: var(--nav-1);
	animation-delay: 0s;
}

.nav-links li:nth-child(2) a {
	color: var(--nav-2);
	animation-delay: -3.2s;
}

.nav-links li:nth-child(3) a {
	color: var(--nav-3);
	animation-delay: -6.4s;
}

.nav-links li:nth-child(4) a {
	color: var(--nav-4);
	animation-delay: -9.6s;
}

.nav-links li:nth-child(5) a {
	color: var(--nav-5);
	animation-delay: -12.8s;
}

.nav-links a {
	animation: nav-cycle 16s linear infinite;
}

@keyframes nav-cycle {
	0% {
		color: var(--nav-1);
	}
	20% {
		color: var(--nav-2);
	}
	40% {
		color: var(--nav-3);
	}
	60% {
		color: var(--nav-4);
	}
	80% {
		color: var(--nav-5);
	}
	100% {
		color: var(--nav-1);
	}
}

/* Two separate traps here, and both bit.

   `animation: none` is required, not tidy: a running animation outranks a
   normal declaration, so without it the item keeps cycling while hovered.

   The `li` in the selector is also required. The nth-child rules above score
   (0,2,2); a plain `.nav-links a:hover` scores (0,2,1) and loses to them, so
   hover silently kept the item's own ramp colour instead of going white. */
.nav-links li a:hover {
	animation: none;
	color: var(--paper);
}

/* The section you're actually in. Same specificity as the :nth-child ramp
   rules — (0,2,2) — so it only wins by sitting after them. Moving it above
   them silently does nothing, which is the same trap :hover fell into above.
   `animation: none` is not optional: a running animation outranks a normal
   declaration, so without it the colour here is ignored outright. */
.nav-links li a.is-current {
	animation: none;
	color: var(--paper);
}

.nav-toggle {
	display: none;
	background: none;
	border: 1px solid var(--rule);
	border-radius: 8px;
	color: var(--lilac);
	padding: 0.5rem 0.7rem;
	font-family: var(--mono);
	font-size: 0.845rem;
	letter-spacing: 0.1em;
	cursor: pointer;
}

/* --- hero --------------------------------------------------------------- */

.hero {
	position: relative;
	min-height: min(88vh, 820px);
	display: flex;
	align-items: center;
	overflow: hidden;
}

/* Fades the shader into the page ground on all four edges */
.hero-veil {
	position: absolute;
	inset: 0;
	pointer-events: none;
	/* The smoke drifts, so any given spot behind the text is bright sooner or
	   later. This scrim is what guarantees the contrast ratio holds through
	   the whole loop — light enough that the field still reads underneath. */
	background: linear-gradient(to right, 
color-mix(in srgb, var(--ink) 62%, transparent) 0%, 
color-mix(in srgb, var(--ink) 0%, transparent) 0%, transparent 0%), radial-gradient(120% 90% at 22% 40%, transparent 0%, transparent 64%, var(--ink) 82%), linear-gradient(to bottom, var(--ink) 0%, transparent 22%, transparent 82%, var(--ink) 100%);
}

.hero-inner {
	position: relative;
	z-index: 2;
	width: 100%;
	padding-block: clamp(4rem, 12vw, 8rem);
}

.hero h1 {
	font-size: clamp(2.5rem, 6.4vw, 5.1rem);
	font-weight: 400;
	letter-spacing: -0.038em;
	line-height: 0.98;
	max-width: 16ch;
	text-shadow: 1px 1px 0 var(--paper);
}

/* The same 100deg violet→magenta arc as everywhere else, but flowing.
   The gradient carries two full cycles across a tile twice the width of the
   text, so the tiled pattern repeats every 100%. Sliding by exactly 100%
   therefore lands on an identical frame and the loop has no visible seam —
   with a plain two-stop gradient it would snap back at the end of every pass.

   --arc-hi is why this is visible at all. Sliding between --arc-a and --arc-b
   alone animates hue with almost no change in lightness, and motion perception
   runs on luminance, not hue — it repaints every frame and still reads as a
   static fill. The bright crest gives the eye an edge to track. */
.hero h1 em {
	font-style: normal;
	background-image: linear-gradient(
		100deg,
		var(--quote-lo) 0%,
		var(--quote-mid) 18%,
		var(--quote-hi) 25%,
		var(--quote-mid) 32%,
		var(--quote-lo) 50%,
		var(--quote-mid) 68%,
		var(--quote-hi) 75%,
		var(--quote-mid) 82%,
		var(--quote-lo) 100%
	);
	background-size: 200% 100%;
	background-position: 0% 50%;
	-webkit-background-clip: text;
	background-clip: text;
	color: transparent;
	animation: arc-flow 4.5s linear infinite;
	/* text-shadow paints BEHIND the glyph, and these glyphs have no fill —
	   the shadow shows straight through and floods the letters solid with the
	   shadow colour. So the inherited one is cancelled and the same offset is
	   redrawn with drop-shadow(), which works off the rendered silhouette and
	   sits correctly outside the letterforms. */
	text-shadow: none;
	filter: drop-shadow(1px 1px 0 var(--paper));
	    font-weight: 600;
}

@keyframes arc-flow {
	to {
		/* one full cycle, right to left — same direction as the marquee */
		background-position: 100% 50%;
	}
}

.hero-lead {
	margin-top: 1.8rem;
	max-width: 50ch;
	font-size: clamp(1.05rem, 1.5vw, 1.3rem);
	color: var(--lilac);
}

.hero-actions {
	margin-top: 2.4rem;
	display: flex;
	flex-wrap: wrap;
	gap: 0.9rem;
}

/* The thesis line. States what the hero actually is. */
.hero-caption {
	margin-top: 3.2rem;
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 0.55rem;
	font-family: var(--mono);
	font-size: 0.885rem;
	line-height: 1.5;
	color: var(--muted);
}

.hero-caption a {
	color: var(--lilac);
	text-decoration-color: var(--rule);
}

.hero-caption a:hover {
	color: var(--paper);
	text-decoration-color: var(--arc-b);
}

/* --- pillars ------------------------------------------------------------ */

/* Three across so the five pillars plus the enquiry cell fill two clean rows —
   five in a four-column grid orphans the last one. */
.pillar-grid {
	margin-top: 3.5rem;
	display: grid;
	grid-template-columns: repeat(3, 1fr);
	gap: 1px;
	background: var(--rule);
	border: 1px solid var(--rule);
	border-radius: var(--radius);
	overflow: hidden;
}

.pillar {
	background: var(--ink);
	padding: 2rem 1.8rem 2.2rem;
	transition: background 0.25s ease;
}

.pillar:hover {
	background: var(--panel);
}

/* Sixth cell — completes the grid and gives the section somewhere to go */
.pillar-ask {
	background: var(--panel);
	display: flex;
	flex-direction: column;
	justify-content: center;
	gap: 0.8rem;
	padding: 2rem 1.8rem;
	text-decoration: none;
	transition: background 0.25s ease;
}

.pillar-ask:hover {
	background: color-mix(in srgb, var(--arc-a) 16%, var(--panel));
}

.pillar-ask h3 {
	font-size: 1.18rem;
	font-weight: 600;
	margin: 0;
}

.pillar-ask p {
	font-size: 0.94rem;
	color: var(--muted);
	line-height: 1.6;
	margin: 0;
}

.pillar-ask .tile-go {
	margin-top: 0.3rem;
}

@media (max-width: 1000px) {
	.pillar-grid {
		grid-template-columns: repeat(2, 1fr);
	}
}

@media (max-width: 620px) {
	.pillar-grid {
		grid-template-columns: 1fr;
	}
}

.pillar-no {
	font-family: var(--mono);
	font-size: 0.825rem;
	letter-spacing: 0.12em;
	color: var(--arc-b);
}

.pillar h3 {
	margin: 0.9rem 0 0.7rem;
	font-size: 1.18rem;
	font-weight: 400;
}

.pillar p {
	font-size: 0.94rem;
	color: var(--muted);
	line-height: 1.6;
}

/* --- lab ---------------------------------------------------------------- */

.lab-head {
	display: flex;
	flex-wrap: wrap;
	align-items: flex-end;
	justify-content: space-between;
	gap: 1.5rem;
}

.lab-grid {
	margin-top: 3.5rem;
	display: grid;
	grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
	gap: 1.4rem;
}

.tile {
	position: relative;
	display: flex;
	flex-direction: column;
	background: var(--panel);
	border: 1px solid var(--rule);
	border-radius: var(--radius);
	overflow: hidden;
	text-decoration: none;
	color: inherit;
	transition: transform 0.25s ease, border-color 0.25s ease;
}

.tile:hover {
	transform: translateY(-4px);
	border-color: color-mix(in srgb, var(--arc-b) 55%, var(--rule));
}

.tile-poster {
	aspect-ratio: 16 / 10;
	position: relative;
	overflow: hidden;
	background: var(--ink);
	display: grid;
	place-items: center;
}

/* Placeholder poster art — a generated field, not a stock screenshot */
.tile-poster::after {
	content: '';
	position: absolute;
	inset: -30%;
	background: conic-gradient(from var(--a, 0deg) at 50% 50%, var(--arc-a), var(--arc-b), var(--ink), var(--arc-a));
	filter: blur(38px);
	opacity: 0.42;
	animation: drift 18s linear infinite;
}

.tile:nth-child(2n) .tile-poster::after {
	animation-duration: 24s;
	animation-direction: reverse;
}

.tile:nth-child(3n) .tile-poster::after {
	animation-duration: 30s;
}

@property --a {
	syntax: '<angle>';
	inherits: false;
	initial-value: 0deg;
}

@keyframes drift {
	to {
		--a: 360deg;
	}
}

/* Real screenshots, where we have them. The conic wash underneath stays as the
   backdrop so a slow image doesn't flash the card background on load. */
.tile-poster img,
.tile-poster video {
	position: absolute;
	inset: 0;
	z-index: 1;
	width: 100%;
	height: 100%;
	max-width: none;
	object-fit: cover;
	object-position: top center;
	transition: transform 0.55s ease, opacity 0.35s ease;
	opacity: 0.92;
}

.tile:hover .tile-poster img,
.tile:hover .tile-poster video {
	transform: scale(1.035);
	opacity: 1;
}

.tile-glyph {
	position: relative;
	z-index: 1;
	font-family: var(--display);
	font-weight: 700;
	font-size: clamp(2rem, 4vw, 2.8rem);
	letter-spacing: -0.04em;
	color: var(--paper);
	mix-blend-mode: overlay;
}

.tile-body {
	padding: 1.5rem 1.5rem 1.7rem;
	display: flex;
	flex-direction: column;
	gap: 0.7rem;
	flex: 1;
}

.tile-body h3 {
	font-size: 1.15rem;
	font-weight: 600;
	display: flex;
	align-items: center;
	gap: 0.5rem;
}

.tile-body p {
	font-size: 0.93rem;
	color: var(--muted);
	line-height: 1.6;
	margin: 0;
	flex: 1;
}

.tile-tags {
	display: flex;
	flex-wrap: wrap;
	gap: 0.4rem;
	margin-top: 0.3rem;
}

.tag {
	font-family: var(--mono);
	font-size: 0.785rem;
	letter-spacing: 0.06em;
	text-transform: uppercase;
	color: var(--muted);
	border: 1px solid var(--rule);
	border-radius: 100px;
	padding: 0.22rem 0.6rem;
}

.tile-go {
	font-family: var(--mono);
	font-size: 0.845rem;
	letter-spacing: 0.08em;
	color: var(--lilac);
}

.tile-soon {
	border-style: dashed;
	background: transparent;
}

.tile-soon .tile-poster::after {
	opacity: 0.14;
}

/* --- work --------------------------------------------------------------- */

.work-grid {
	margin-top: 3.5rem;
	display: grid;
	grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
	gap: 1.4rem;
}

.case {
	display: flex;
	flex-direction: column;
	border: 1px solid var(--rule);
	border-radius: var(--radius);
	overflow: hidden;
	background: var(--panel);
	text-decoration: none;
	color: inherit;
	transition: transform 0.25s ease, border-color 0.25s ease;
}

.case:hover {
	transform: translateY(-4px);
	border-color: color-mix(in srgb, var(--arc-b) 55%, var(--rule));
}

.case-shot {
	aspect-ratio: 16 / 9;
	overflow: hidden;
	background: var(--ink);
	border-bottom: 1px solid var(--rule);
}

.case-shot img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	object-position: top center;
	filter: saturate(0.9);
	transition: transform 0.5s ease, filter 0.3s ease;
}

.case:hover .case-shot img {
	transform: scale(1.04);
	filter: saturate(1.1);
}

.case-body {
	padding: 1.4rem 1.5rem 1.6rem;
	display: flex;
	flex-direction: column;
	gap: 0.6rem;
	flex: 1;
}

.case-sector {
	font-family: var(--mono);
	font-size: 0.785rem;
	letter-spacing: 0.12em;
	text-transform: uppercase;
	color: var(--arc-b);
}

.case-body h3 {
	font-size: 1.05rem;
	font-weight: 600;
	word-break: break-word;
}

.case-body p {
	font-size: 0.9rem;
	color: var(--muted);
	line-height: 1.55;
	margin: 0;
	flex: 1;
}

.case-stack {
	font-family: var(--mono);
	font-size: 0.805rem;
	color: var(--muted);
	padding-top: 0.7rem;
	border-top: 1px solid var(--rule);
}

/* --- partner ------------------------------------------------------------ */

.partner-inner {
	display: grid;
	grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
	gap: clamp(2rem, 6vw, 5rem);
	align-items: center;
}

.partner-quote {
	font-family: var(--display);
	font-size: clamp(1.35rem, 2.4vw, 1.95rem);
	font-weight: 400;
	line-height: 1.28;
	letter-spacing: -0.02em;
	color: var(--paper);
	margin: 0;
}

.partner-quote strong {
	background: var(--arc);
	-webkit-background-clip: text;
	background-clip: text;
	color: transparent;
	font-weight: 600;
}

.partner-note p {
	font-size: 1rem;
	color: var(--muted);
}

.partner-stats {
	display: flex;
	gap: 2.5rem;
	margin-top: 2rem;
	padding-top: 1.6rem;
	border-top: 1px solid var(--rule);
}

.stat-n {
	font-family: var(--display);
	font-size: 2rem;
	font-weight: 700;
	letter-spacing: -0.04em;
	color: var(--paper);
	display: block;
	line-height: 1;
}

.stat-l {
	font-family: var(--mono);
	font-size: 0.805rem;
	letter-spacing: 0.1em;
	text-transform: uppercase;
	color: var(--muted);
	display: block;
	margin-top: 0.5rem;
}

/* --- stack marquee (salvaged from the old style.css) -------------------- */

/* No margin-top: the section has no heading to clear, so the band's own
   padding centres the row on its own. */
.marquee {
	position: relative;
	overflow: hidden;
	-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
	mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}

.marquee-inner {
	display: inline-grid;
	grid-auto-flow: column;
	align-items: center;
	/* Paired with the keyframe offset below — change one and you must change
	   the other, or the loop develops a visible jump. */
	gap: 3rem;
	/* A slow right-to-left drift — about 30px a second. Long enough that it
	   reads as ambient rather than as something demanding attention.
	   Scaled with the logo count to hold that speed: one half-set is ~2190px,
	   so 68s keeps it at ~32px/s. Add logos and this needs raising with them. */
	animation: loop1 68s linear infinite;
	/* Promote to its own layer: without this some compositors only repaint the
	   masked strip on scroll, which makes the loop look stalled. */
	will-change: transform;
}

/* These SVGs carry a viewBox but no width/height, so `width:auto` collapses
   them to nothing inside a grid track. Fixed slot + object-fit instead.

   The set is now uniform 24×24 marks, so the slot is square and tight rather
   than the wide box the old mixed-aspect artwork needed. That is what pays for
   the logo count: 22 marks at 40px occupy roughly what 12 at 104px did, so the
   loop length and scroll speed are unchanged. */
.marquee-inner img {
	height: 40px;
	width: 40px;
	/* beats the `img { max-width: 100% }` reset, which otherwise resolves
	   against an auto grid track and collapses these to a few pixels */
	max-width: none;
	object-fit: contain;
	opacity: 0.45;
	/* The source logos are dark artwork for light backgrounds — brightness()
	   can't lift pure black, so flatten to black then invert to white. */
	filter: brightness(0) invert(1);
	transition: opacity 0.25s ease;
}

/* FrankenPHP has no monochrome mark — simple-icons doesn't carry it and no
   icon CDN has one, so this is the official wordmark at 7.6:1. It needs a
   wider slot or `contain` would shrink it to a sliver. The seam maths below
   is independent of individual item widths, so the exception is safe. */
.marquee-inner img.is-wordmark {
	width: 120px;
	height: 26px;
}

.marquee-inner img:hover {
	opacity: 1;
}

/* The list is duplicated in the markup, so one half-set is 50% of the track
   plus half a gap — land exactly there and the seam is invisible.
   This constant is half of .marquee-inner's gap. Keep them in step. */
@keyframes loop1 {
	0% {
		transform: translateX(0%);
	}
	100% {
		transform: translateX(calc(-50% - 1.5rem));
	}
}

/* --- blog notes --------------------------------------------------------- */

.soon-list {
	margin: 3rem 0 2.6rem;
	padding: 0;
	list-style: none;
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
	gap: 1px;
	background: var(--rule);
	border: 1px solid var(--rule);
	border-radius: var(--radius);
	overflow: hidden;
}

.soon {
	background: var(--ink);
	padding: 1.8rem 1.6rem 2rem;
}

.soon-tag {
	font-family: var(--mono);
	font-size: 0.785rem;
	letter-spacing: 0.12em;
	text-transform: uppercase;
	color: var(--quote-hi);
}

.soon h3 {
	margin: 0.9rem 0 0.7rem;
	font-size: 1.12rem;
	font-weight: 600;
}

.soon p {
	font-size: 0.93rem;
	color: var(--lilac);
	margin: 0;
}

/* --- cta + footer: the closing field ------------------------------------

   These two are the only sections besides the hero that show #field directly,
   so the page closes on the same shader it opened with. They are transparent
   on purpose — but note the trap that made it accidental first time round:
   `.cta`'s own `background:` shorthand sits later in this file at equal
   specificity to `.band`, so it silently reset background-color to
   transparent. Anything that wants to stay opaque must not be given a
   `background` shorthand after .band.

   ::before is the scrim, and the two are deliberately NOT the same thing:

   The CTA works the way .hero-veil does — ink at the edges, clear through the
   middle. It was a flat 58% wash, which held the contrast but greyed the smoke
   out everywhere, and the closing screen ended up muddier than the hero it is
   meant to rhyme with. Its side fade varies only horizontally, so it cannot
   show a join against the footer however the two boxes are sized; a radial
   vignette like the hero's would need a different size and offset on each and
   would seam the moment the footer wrapped to two rows.

   The footer stays a flat 68% wash, and that difference is the point. It is
   the colophon rather than the closing statement, and the step between a clear
   field above and a settled one below is what separates them — matching the
   two makes the whole screen read as one undifferentiated slab. It also puts
   the small mono type on an even ground instead of on drifting smoke.
   ------------------------------------------------------------------------ */

.cta,
.footer {
	background: transparent;
}

.cta::before,
.footer::before {
	content: '';
	position: absolute;
	inset: 0;
	z-index: 0;
	pointer-events: none;
}

/* Sides, then the top edge — so the field doesn't start on a hard line against
   the opaque band above it. 22% matches the hero veil's own top fade. */
.cta::before {
	background:
		linear-gradient(to right, var(--ink) 0%, transparent 16%, transparent 84%, var(--ink) 100%),
		linear-gradient(to bottom, var(--ink) 0%, transparent 22%);
}

/* Flat, and 10 points heavier than the CTA used to be — see above. */
.footer::before {
	background: color-mix(in srgb, var(--ink) 68%, transparent);
}

.cta > * {
	position: relative;
	z-index: 1;
}

.cta {
	text-align: center;
	/* #contact scrolls 30px further down than the other bands. The section
	   above it ends on a button, and at the shared -32px the bottom half of
	   that button was still on screen. Equal specificity to .band, but this
	   rule is later in the file, so it wins. */
	scroll-margin-top: -62px;
}

.cta h2 {
	font-size: clamp(2rem, 4.4vw, 3.4rem);
	font-weight: 400;
	max-width: 18ch;
	margin-inline: auto;
}

.cta p {
	margin: 1.4rem auto 0;
	max-width: 46ch;
	color: #fff;
}

.cta-actions {
	margin-top: 2.4rem;
	display: flex;
	flex-wrap: wrap;
	gap: 0.9rem;
	justify-content: center;
}

/* --- footer ------------------------------------------------------------- */

/* Transparent — see "the closing field" above. Deliberately no background
   here: it would override the rule up there, since this one is later. */
.footer {
	border-top: 1px solid var(--rule);
	padding-block: 3.5rem 3rem;
	position: relative;
}

/* --- the closing screen --------------------------------------------------
   The CTA and the footer together are one screen: `100dvh - --nav-h`, because
   the bar is sticky, so when the page is scrolled to the end the bar is the
   only other thing on it.

   The height goes on the CTA rather than the footer. The footer is a colophon
   and should stay the size of its own contents; the CTA is the part with room
   to breathe, so it takes the slack and centres in it. The two of them then
   land exactly on the bottom of the viewport.

   `--footer-h` is the one number this can't get from CSS — the footer's height
   is its content's, and the CTA needs it before it can know its own. The 12rem
   here is a measured estimate that stands in until site.js replaces it with
   the real figure; it is close enough (188px against 192px on a wide screen)
   that a page with no JS is not visibly different.

   Conditional on the page having a field, because that is the only thing that
   justifies the height. The subpages have no #field and a screen of empty --ink
   under three lines of colophon reads as broken, not spacious. A browser with
   no :has() gets the short version everywhere, which is the old layout. */
body:has(#field) .cta {
	min-height: calc(100vh - var(--nav-h) - var(--footer-h));
	/* second pass for browsers that have it: on a phone, 100vh is the address
	   bar's *retracted* height, so the closing screen overshoots by ~60px */
	min-height: calc(100dvh - var(--nav-h) - var(--footer-h));
	display: flex;
	flex-direction: column;
	justify-content: center;
	/* .band's own padding is what centres a normal section's content. Here the
	   min-height and the centring already do that, so the full band padding is
	   dead space — and on a phone, where the footer wraps to two rows and takes
	   292px, it was the 15px that pushed the closing screen past one screen.
	   What's left is the guarantee that the copy never touches the edges on a
	   viewport too short to fit it. */
	padding-block: 3rem;
}

.footer > *:not(.surface) {
	position: relative;
	z-index: 1;
}

.footer-inner {
	display: flex;
	flex-wrap: wrap;
	gap: 2rem;
	justify-content: space-between;
	align-items: flex-start;
}

.footer-links {
	display: flex;
	flex-wrap: wrap;
	gap: 1.6rem;
}

/* White, not --muted — this is the only body text sitting on live smoke */
.footer a {
	font-family: var(--mono);
	font-size: 0.885rem;
	color: var(--paper);
	text-decoration: none;
}

.footer a:hover {
	color: var(--paper);
}

.footer-legal {
	font-family: var(--mono);
	font-size: 0.825rem;
	color: var(--paper);
	line-height: 1.7;
}

/* --- stub pages --------------------------------------------------------- */

/* --- the blog ------------------------------------------------------------
   Two views out of one file: the list, and one post. Both live inside a
   normal opaque `.band`, so the field stays hidden here — long-form reading
   is the one place on this site where a moving background would be actively
   hostile.
   ------------------------------------------------------------------------ */

.post-list {
	margin: 3rem 0 0;
	padding: 0;
	list-style: none;
	border-top: 1px solid var(--rule);
}

/* Same list on the homepage, where it has a button under it. */
.post-list-home {
	margin-bottom: 2.6rem;
}

.post-item h3 {
	margin: 0.7rem 0 0.6rem;
	font-size: clamp(1.2rem, 2vw, 1.45rem);
	font-weight: 600;
	letter-spacing: -0.02em;
}

.post-item > a:hover h3 {
	color: var(--paper);
}

.post-item {
	border-bottom: 1px solid var(--rule);
}

/* The whole row is the link, not the heading — a two-word target inside a
   three-line block is a bad hit area on any device and a terrible one on a
   phone. */
.post-item > a {
	display: block;
	padding: 1.7rem 0;
	text-decoration: none;
	color: inherit;
	transition: padding-left 0.25s ease;
}

.post-item > a:hover {
	padding-left: 0.7rem;
}

.post-item-tag {
	font-family: var(--mono);
	font-size: 0.785rem;
	letter-spacing: 0.12em;
	text-transform: uppercase;
	color: var(--quote-hi);
}

.post-item h2 {
	margin: 0.7rem 0 0.6rem;
	font-size: clamp(1.35rem, 2.4vw, 1.75rem);
	font-weight: 600;
	letter-spacing: -0.02em;
}

.post-item > a:hover h2 {
	color: var(--paper);
}

.post-item p {
	margin: 0;
	max-width: 68ch;
	color: var(--muted);
	font-size: 0.98rem;
}

.post-item time,
.post-meta time {
	display: inline-block;
	margin-top: 0.8rem;
	font-family: var(--mono);
	font-size: 0.78rem;
	color: var(--muted);
}

.post-empty {
	margin-top: 2.5rem;
	font-family: var(--mono);
	color: var(--muted);
}

/* --- one post ------------------------------------------------------------ */

.post-back {
	color: var(--nav-3);
	text-decoration: none;
}

.post h1 {
	font-size: clamp(2rem, 4.4vw, 3.2rem);
	font-weight: 400;
	letter-spacing: -0.038em;
	line-height: 1.08;
	/* Wider than the body measure below on purpose: a headline set to 62ch
	   wraps into a paragraph shape and stops reading as a headline. */
	max-width: 20ch;
	margin: 0.6rem 0 0;
}

.post-standfirst {
	margin: 1.4rem 0 0;
	max-width: 60ch;
	font-size: clamp(1.05rem, 1.6vw, 1.2rem);
	line-height: 1.55;
	color: var(--lilac);
}

.post-meta {
	display: flex;
	align-items: center;
	gap: 0.9rem;
	margin: 1.6rem 0 0;
	padding-bottom: 2.2rem;
	border-bottom: 1px solid var(--rule);
}

.post-meta time {
	margin-top: 0;
}

/* 68ch is about 14 words a line at this size — past roughly 75 the eye starts
   losing its place on the return sweep. */
.post-body {
	margin-top: 2.6rem;
	max-width: 68ch;
}

.post-body p {
	margin: 0 0 1.3rem;
	color: var(--lilac);
	line-height: 1.75;
}

.post-body h2 {
	margin: 2.8rem 0 1.1rem;
	font-size: clamp(1.25rem, 2.2vw, 1.55rem);
	font-weight: 600;
	letter-spacing: -0.02em;
}

.post-body strong {
	color: var(--paper);
	font-weight: 600;
}

.post-body em {
	color: var(--nav-4);
	font-style: italic;
}

.post-body a {
	color: var(--quote-hi);
	text-underline-offset: 3px;
}

.post-body ol,
.post-body ul {
	margin: 0 0 1.3rem;
	padding-left: 1.3rem;
	color: var(--lilac);
	line-height: 1.75;
}

.post-body li {
	margin-bottom: 0.6rem;
}

.post-body li::marker {
	color: var(--muted);
}

/* Inline code sits inside running prose, so it takes the smallest treatment
   that still separates it — no border, no background block. */
/* A quoted report, usually somebody else's words. Indented rather than
   boxed: it is part of the argument, not an aside from it. */
.post-body blockquote {
	margin: 0 0 1.5rem;
	padding-left: 1.2rem;
	border-left: 2px solid var(--quote-mid);
}

.post-body blockquote p {
	margin: 0;
	color: var(--muted);
	font-family: var(--mono);
	font-size: 0.88rem;
	line-height: 1.7;
}

.post-body :not(pre) > code {
	font-family: var(--mono);
	font-size: 0.88em;
	color: var(--nav-4);
	background: color-mix(in srgb, var(--panel) 80%, transparent);
	border-radius: 5px;
	padding: 0.1em 0.35em;
}

.post-body pre {
	margin: 0 0 1.5rem;
	padding: 1.1rem 1.2rem;
	overflow-x: auto;
	background: var(--panel);
	border: 1px solid var(--rule);
	border-radius: var(--radius);
}

.post-body pre code {
	font-family: var(--mono);
	font-size: 0.83rem;
	line-height: 1.65;
	color: var(--lilac);
	white-space: pre;
	tab-size: 4;
}

.post-foot {
	margin: 3.2rem 0 0;
	padding-top: 1.6rem;
	max-width: 68ch;
	border-top: 1px solid var(--rule);
	font-family: var(--mono);
	font-size: 0.82rem;
	color: var(--muted);
}

.stub {
	min-height: 68vh;
	display: flex;
	align-items: center;
}

/* .wrap is a flex item here, so it shrink-wraps and drifts to the middle
   unless told to take the full row */
.stub > .wrap {
	width: 100%;
}

.stub h1 {
	font-size: clamp(2.2rem, 5vw, 3.6rem);
	font-weight: 700;
}

.stub p {
	margin-top: 1.4rem;
	max-width: 52ch;
	color: var(--muted);
}

.stub .btn {
	margin-top: 2.2rem;
}

/* --- responsive --------------------------------------------------------- */

@media (max-width: 860px) {
	.nav-toggle {
		display: block;
		/* the collapsed <nav> is still a zero-width flex item, so without this
		   space-between strands the button in the middle of the bar */
		order: 3;
	}

	.nav-links {
		display: none;
		position: absolute;
		top: var(--nav-h);
		left: 0;
		right: 0;
		flex-direction: column;
		align-items: flex-start;
		gap: 0;
		background: var(--panel);
		border-bottom: 1px solid var(--rule);
		padding: 0.5rem var(--gutter) 1.2rem;
	}

	.nav-links.open {
		display: flex;
	}

	.nav-links li {
		width: 100%;
	}

	.nav-links a {
		display: block;
		padding: 0.85rem 0;
		font-size: 0.975rem;
		border-bottom: 1px solid var(--rule);
	}

	.nav-links li:last-child a {
		border-bottom: none;
	}

	.partner-inner {
		grid-template-columns: 1fr;
	}

	.partner-stats {
		gap: 1.8rem;
	}
}

@media (max-width: 520px) {
	.hero {
		min-height: auto;
	}

	.partner-stats {
		flex-wrap: wrap;
		gap: 1.4rem 2.2rem;
	}
}

/* --- motion -------------------------------------------------------------
   No `prefers-reduced-motion` block, by explicit decision. The motion on this
   page is the product being sold, so it runs for everyone. Don't reintroduce
   one — a well-meaning "accessibility fix" here silently kills the hero and
   the marquee, and it is not obvious from the page why.
   ------------------------------------------------------------------------ */
