/* ==========================================================================
   CORE
   Reset, document, layout scaffolding, the MATERIAL SYSTEM, and the motion
   primitives. Everything every page needs; nothing that belongs to one
   component.

   Replaces the former base.css + layout.css + materials.css + animations.css.
   ========================================================================== */

/* --------------------------------------------------------------------------
   CASCADE LAYERS

   Declared once, in priority order — later layers win. This makes load order
   a statement of intent rather than a fragile accident of the <link> list.

   Component stylesheets that have not yet been migrated are UNLAYERED, and
   unlayered rules beat every layered rule regardless of specificity. That is
   deliberate during the migration: an old component keeps its own appearance
   until the commit that moves it into @layer component.
   -------------------------------------------------------------------------- */
@layer reset, base, layout, material, motion, component, state, utility;

/* ==========================================================================
   FONTS

   Self-hosted, latin subset, variable. Two files, 79 KB total.

   Google Fonts cost a preconnect, a stylesheet round trip and only then the
   font fetches — three sequential hops on the critical path before the first
   glyph can paint. Serving them from the same origin removes all three, and
   removes a third party from the privacy surface of the page.

   Both faces are VARIABLE, so one file covers each family's whole weight
   range: Inter 400-800 and JetBrains Mono 400-500 are single downloads
   rather than five and two static instances.

   `font-display: swap` shows the fallback immediately and swaps when the
   real face arrives. The size-adjust / metric overrides below tune the
   fallback so that swap does not move the text — the fallback is set to the
   same effective x-height and line box as Inter, which is what stops the
   layout shift that `swap` otherwise causes.
   ========================================================================== */
@font-face {
	font-family: 'Inter';
	font-style: normal;
	font-weight: 400 800;
	font-display: swap;
	src: url('../assets/font/inter-latin-var.woff2') format('woff2');
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
		U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
		U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
	font-family: 'JetBrains Mono';
	font-style: normal;
	font-weight: 400 500;
	font-display: swap;
	src: url('../assets/font/jetbrains-mono-latin-var.woff2') format('woff2');
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
		U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
		U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Metric-matched fallback. While Inter is still loading, text is laid out in
   the local system sans at adjusted metrics, so the swap changes the shapes
   without moving a single line. */
@font-face {
	font-family: 'Inter Fallback';
	src: local('Segoe UI'), local('Helvetica Neue'), local('Arial');
	size-adjust: 107%;
	ascent-override: 90%;
	descent-override: 22%;
	line-gap-override: 0%;
}

/* ==========================================================================
   RESET
   ========================================================================== */
@layer reset {
	*,
	*::before,
	*::after {
		box-sizing: border-box;
		margin: 0;
		padding: 0;
	}

	/* Give every element the same 3D context so translateZ is meaningful
	   wherever it is used, without each component re-declaring it. */
	* {
		transform-style: flat;
	}

	html {
		-webkit-text-size-adjust: 100%;
		text-size-adjust: 100%;
		-moz-tab-size: 2;
		tab-size: 2;
		scroll-behavior: smooth;
		/* Nav is fixed — anchor targets must clear it. */
		scroll-padding-top: calc(var(--nav-height, 4.5rem) + var(--space-lg));
		/* Kill horizontal drift from wide decorative layers. */
		overflow-x: clip;
		scrollbar-width: thin;
		scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
	}

	@media (prefers-reduced-motion: reduce) {
		html {
			scroll-behavior: auto;
		}
	}

	body {
		min-height: 100svh;
		margin: 0;
		overflow-x: clip;

		font-family: var(--font-sans);
		font-size: var(--text-base);
		font-weight: var(--weight-regular);
		line-height: var(--leading-normal);
		letter-spacing: var(--tracking-normal);
		color: var(--text-2);
		background-color: var(--canvas);

		-webkit-font-smoothing: antialiased;
		-moz-osx-font-smoothing: grayscale;
		text-rendering: optimizeLegibility;
		font-synthesis-weight: none;

		/* Opt into the modern typographic features Inter ships. */
		font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11', 'ss03';
		font-variant-ligatures: contextual common-ligatures;

		/*
		 * NOTE: do NOT set `perspective` here.
		 * A non-`none` perspective makes <body> the containing block for every
		 * `position: fixed` descendant, which breaks the fixed nav and lets
		 * full-bleed decorative layers inflate the document height.
		 * Perspective is declared per-component instead (see .stage, .hero).
		 */

		transition:
			background-color var(--dur-slow) var(--ease-out),
			color var(--dur-slow) var(--ease-out);
	}

	/* Media defaults — always fluid, never overflow, never shift layout. */
	img,
	svg,
	video,
	canvas,
	audio,
	iframe,
	embed,
	object {
		display: block;
		max-width: 100%;
	}

	img,
	video {
		height: auto;
		background-color: var(--surface-3);
	}

	/* Inherit type into form controls — browsers refuse to by default. */
	input,
	button,
	textarea,
	select,
	optgroup {
		font: inherit;
		letter-spacing: inherit;
		color: inherit;
	}

	button {
		background: none;
		border: 0;
		cursor: pointer;
		-webkit-tap-highlight-color: transparent;
	}

	textarea {
		resize: vertical;
		field-sizing: content; /* progressive: grows with input */
		min-height: 6rem;
	}

	/* Prevent long unbroken strings (URLs, IDs) from blowing out layouts. */
	p,
	li,
	h1,
	h2,
	h3,
	h4,
	h5,
	h6,
	dd,
	dt,
	figcaption {
		overflow-wrap: break-word;
		text-wrap: pretty;
	}

	ul[role='list'],
	ol[role='list'],
	ul.unstyled,
	ol.unstyled {
		list-style: none;
	}

	/* Remove the default focus ring only where we supply our own. */
	:focus:not(:focus-visible) {
		outline: none;
	}
}

/* ==========================================================================
   BASE — typography, focus, selection, chrome
   ========================================================================== */
@layer base {
	h1,
	h2,
	h3,
	h4,
	h5,
	h6 {
		color: var(--text-1);
		font-family: var(--font-display);
		font-weight: var(--weight-bold);
		line-height: var(--leading-tight);
		letter-spacing: var(--tracking-tight);
		text-wrap: balance;
	}

	h1 {
		font-size: var(--text-4xl);
		font-weight: var(--weight-black);
		letter-spacing: var(--tracking-tighter);
	}

	h2 {
		font-size: var(--text-2xl);
		letter-spacing: var(--tracking-tighter);
	}

	h3 {
		font-size: var(--text-lg);
		line-height: var(--leading-snug);
	}

	h4 {
		font-size: var(--text-md);
		font-weight: var(--weight-semibold);
		line-height: var(--leading-snug);
	}

	h5,
	h6 {
		font-size: var(--text-sm);
		font-weight: var(--weight-semibold);
	}

	p {
		line-height: var(--leading-relaxed);
	}

	strong,
	b {
		color: var(--text-1);
		font-weight: var(--weight-semibold);
	}

	em,
	i {
		font-style: italic;
	}

	small {
		font-size: var(--text-xs);
	}

	code,
	kbd,
	samp,
	pre {
		font-family: var(--font-mono);
		font-size: 0.92em;
		font-feature-settings: 'zero', 'ss01';
	}

	code {
		padding: 0.15em 0.4em;
		border: 1px solid var(--line);
		border-radius: var(--radius-xs);
		background: var(--surface-3);
		color: var(--text-1);
	}

	/* Physical keycap — the smallest relief object in the system. */
	kbd {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-width: 1.6em;
		padding: 0.2em 0.45em;

		border-radius: var(--radius-xs);
		background: var(--relief-face);
		box-shadow: var(--relief-raised);

		color: var(--text-1);
		font-family: var(--font-sans);
		font-size: 0.78em;
		font-weight: var(--weight-semibold);
		line-height: 1;
	}

	a {
		color: var(--accent);
		text-decoration: none;
		text-underline-offset: 0.22em;
		text-decoration-thickness: from-font;
		transition: color var(--t-fast);
	}

	a:hover {
		color: var(--accent-strong);
	}

	/* Prose links get an underline that grows from the left on hover.
	   background-size is a paint-only change on a small inline box, which
	   the motion law permits: discrete, user-initiated, well under 600ms. */
	.prose a,
	a.link {
		position: relative;
		color: var(--text-1);
		background-image: linear-gradient(var(--accent), var(--accent));
		background-repeat: no-repeat;
		background-position: 0 100%;
		background-size: 0% 1.5px;
		transition:
			background-size var(--t-base),
			color var(--t-fast);
	}

	.prose a:hover,
	a.link:hover {
		color: var(--accent);
		background-size: 100% 1.5px;
	}

	hr {
		height: 1px;
		border: 0;
		background: linear-gradient(
			90deg,
			transparent,
			var(--line-strong) 18%,
			var(--line-strong) 82%,
			transparent
		);
	}

	blockquote {
		padding-inline-start: var(--space-md);
		border-inline-start: 2px solid var(--accent);
		color: var(--text-2);
		font-style: italic;
	}

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

	/* ----------------------------------------------------------------------
	   ICONS
	   One inline <svg> sprite, referenced with <use>. Sized in `em` so an
	   icon always matches the type it sits beside, and filled with
	   currentColor so it inherits colour, theme and palette for free — none
	   of which an icon font gives you without fighting it.

	   THIS BELONGS IN @layer base, NOT @layer utility. Utility is declared
	   last and therefore beats every component rule whatever the specificity.
	   With `.icon` in utility, `.contact__row .icon` could not resize its own
	   icons: the 1em default won on layer order alone and squashed them to a
	   couple of pixels inside their padding. Element defaults go in base so
	   components can still speak.
	   ---------------------------------------------------------------------- */
	.sprite {
		position: absolute;
		width: 0;
		height: 0;
		overflow: hidden;
	}

	.icon {
		display: inline-block;
		width: 1em;
		height: 1em;
		fill: currentColor;
		/* Nudge onto the type's optical baseline. */
		vertical-align: -0.125em;
		flex-shrink: 0;
	}

	.icon--xs {
		width: 0.75em;
		height: 0.75em;
	}

	/* ----------------------------------------------------------------------
	   FOCUS
	   One ring, everywhere, always visible. Uses the accent so it reads as
	   part of the design rather than as a browser artefact.

	   The previous system animated `outline-offset` on every focus. That is
	   a repaint on an element the user is actively driving with the keyboard,
	   for no perceptible benefit, so it is gone.
	   ---------------------------------------------------------------------- */
	:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: 3px;
		border-radius: var(--radius-xs);
	}

	/* On glass or relief, a solid ring can disappear into the surface.
	   Add a contrasting halo. */
	.glass:focus-visible,
	.pane:focus-visible,
	.well:focus-visible,
	[data-focus-halo]:focus-visible {
		outline-color: var(--glow);
		box-shadow:
			0 0 0 4px rgb(var(--accent-rgb) / 0.28),
			var(--elev-3);
	}

	/* ----------------------------------------------------------------------
	   SKIP LINK — first tab stop.
	   ---------------------------------------------------------------------- */
	.skip-link {
		position: fixed;
		top: var(--space-sm);
		left: 50%;
		z-index: var(--z-toast);

		padding: var(--space-2xs) var(--space-md);
		border-radius: var(--radius-pill);
		background: var(--surface-2);
		box-shadow: var(--elev-3), 0 0 0 1px var(--line-strong);

		color: var(--text-1);
		font-size: var(--text-sm);
		font-weight: var(--weight-semibold);
		white-space: nowrap;

		transform: translate(-50%, -220%);
		transition: transform var(--dur-base) var(--ease-spring);
	}

	.skip-link:focus-visible {
		transform: translate(-50%, 0);
	}

	/* ----------------------------------------------------------------------
	   NATIVE SCROLLBAR
	   Painted with the theme's own colours so it belongs to the page rather
	   than showing the browser's grey. The custom rail replaces it on
	   pointer-fine devices; this is what everyone else sees.
	   ---------------------------------------------------------------------- */
	::-webkit-scrollbar {
		width: 12px;
		height: 12px;
		background: var(--scrollbar-track);
	}

	::-webkit-scrollbar-track {
		background: var(--scrollbar-track);
	}

	::-webkit-scrollbar-thumb {
		border: 3px solid transparent;
		border-radius: var(--radius-pill);
		background-clip: content-box;
		background-color: var(--scrollbar-thumb);
		transition: background-color var(--t-fast);
	}

	::-webkit-scrollbar-thumb:hover {
		background-color: var(--scrollbar-thumb-hover);
	}

	::-webkit-scrollbar-corner {
		background: var(--scrollbar-track);
	}
}

/* ==========================================================================
   LAYOUT
   ========================================================================== */
@layer layout {
	/* ----------------------------------------------------------------------
	   BACKDROP
	   The lit studio the content floats in. Fixed, behind everything, never
	   scrolls — so glass panes refract a stable field of colour as they move.

	   Gradients are kept wide and low-contrast. Tight, high-contrast radials
	   read as hard rectangular patches at the viewport corners.
	   ---------------------------------------------------------------------- */
	.backdrop {
		position: fixed;
		inset: 0;
		z-index: var(--z-backdrop);
		pointer-events: none;
		overflow: hidden;

		background:
			radial-gradient(
				ellipse 130% 100% at 12% -18%,
				rgb(var(--accent-rgb) / 0.13),
				transparent 68%
			),
			radial-gradient(
				ellipse 110% 90% at 95% 4%,
				rgb(var(--glow-rgb) / 0.075),
				transparent 66%
			),
			radial-gradient(
				ellipse 140% 95% at 50% 118%,
				rgb(var(--accent-rgb) / 0.075),
				transparent 68%
			),
			var(--canvas);

		transition: background var(--dur-slow) var(--ease-out);
	}

	/* ---- Containers ---------------------------------------------------- */
	.container {
		width: 100%;
		max-width: var(--width-content);
		margin-inline: auto;
		padding-inline: var(--gutter);
	}

	.container--wide {
		max-width: var(--width-wide);
	}

	.container--prose {
		max-width: var(--width-prose);
	}

	/* ---- Sections ------------------------------------------------------- */
	.section {
		position: relative;
		padding-block: var(--section-gap);
		/* Establish a containing block so decorative children can be absolute. */
		isolation: isolate;
	}

	.section--tight {
		padding-block: var(--section-gap-tight);
	}

	.section--first {
		padding-block-start: 0;
	}

	.section-head {
		max-width: var(--width-prose);
		margin-block-end: var(--space-xl);
	}

	.section-head--center {
		margin-inline: auto;
		text-align: center;
	}

	.section-head__title {
		margin-block: var(--space-xs) 0;
		font-size: var(--text-2xl);
	}

	.section-head__lede {
		margin-block-start: var(--space-sm);
		color: var(--text-3);
		font-size: var(--text-md);
		line-height: var(--leading-relaxed);
		text-wrap: pretty;
	}

	/* Numeric index beside section titles — a quiet nod to engineering docs. */
	.section-head__index {
		color: var(--text-4);
		font-family: var(--font-mono);
		font-size: var(--text-xs);
		font-weight: var(--weight-medium);
		letter-spacing: var(--tracking-wide);
	}

	/* The final section sits directly above the footer, so it does not need
	   a full section gap beneath it as well. */
	main > .section:last-of-type {
		padding-block-end: var(--section-gap-tight);
	}

	/* ---- Grids ---------------------------------------------------------- */
	.grid {
		display: grid;
		gap: var(--space-md);
	}

	.grid--2 {
		grid-template-columns: repeat(auto-fit, minmax(min(19rem, 100%), 1fr));
	}

	.grid--3 {
		grid-template-columns: repeat(auto-fit, minmax(min(15.5rem, 100%), 1fr));
	}

	.grid--4 {
		grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
	}

	/* Asymmetric split used by About: narrative + side panel. */
	.grid--split {
		grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
		gap: var(--space-xl);
		align-items: start;
	}

	@media (max-width: 60rem) {
		.grid--split {
			grid-template-columns: minmax(0, 1fr);
			gap: var(--space-lg);
		}
	}

	/* ---- Flex helpers --------------------------------------------------- */
	.cluster {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: var(--space-2xs);
	}

	.cluster--center {
		justify-content: center;
	}

	.cluster--md {
		gap: var(--space-sm);
	}

	.stack {
		display: flex;
		flex-direction: column;
		gap: var(--space-sm);
	}

	.stack--lg {
		gap: var(--space-md);
	}

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

	main {
		position: relative;
		z-index: var(--z-base);
		display: block;
	}
}

/* ==========================================================================
   MATERIAL — the physical primitives

   ┌─ THE SIX RULES ─────────────────────────────────────────────────────────┐
   │                                                                         │
   │ 1. ONE LIGHT SOURCE, ONE ANGLE. --lx/--ly (top-left) drives the relief  │
   │    pair, the glass rim, the lens caustic and the ambient orbs. Two      │
   │    light directions is how a mixed-material page starts reading as two  │
   │    unrelated design systems.                                            │
   │                                                                         │
   │ 2. DIFFERENT JOBS, DIFFERENT Z. Glass is what you read THROUGH —        │
   │    containers. Relief is what you PRESS — controls. A control sits ON   │
   │    glass; a glass panel is never inside a .pane.                        │
   │                                                                         │
   │ 3. SHARED EDGE. Every shadow list, glass or relief, ends with the same  │
   │    `0 0 0 1px var(--line*)` hairline, and both use the same radius      │
   │    scale. The edges are what make them one family.                      │
   │                                                                         │
   │ 4. SHARED LIGHT ANGLE IN THE FILL. --relief-face and --glass-sheen are  │
   │    both 157deg gradients, so a palette switch moves both materials      │
   │    together.                                                            │
   │                                                                         │
   │ 5. RELIEF ON GLASS IS HALVED. --relief-on-glass drops the bright half.  │
   │    A white highlight over a blurred backdrop reads as a halo floating   │
   │    in front of the pane, not as an edge belonging to the control.       │
   │                                                                         │
   │ 6. MAX TWO RELIEF STEPS PER COMPONENT. A raised knob in a debossed      │
   │    track is two. There is never a third.                                │
   │                                                                         │
   └─────────────────────────────────────────────────────────────────────────┘

   PSEUDO-ELEMENT BUDGET — this is a hard rule, not a guideline:
       ::before = the edge lens (refraction ring)
       ::after  = the light (pointer specular)
   Both are spent. Anything else a surface needs is a real child element.
   ========================================================================== */
@layer material {
	/* ----------------------------------------------------------------------
	   GLASS — tier 0, the cheap default.

	   No backdrop-filter. An opaque tint carrying the identical sheen, rim
	   and elevation as a real pane.

	   This inversion is the single largest performance lever on the page.
	   `backdrop-filter` costs a backdrop snapshot plus a separable blur,
	   proportional to area x DPR², for every element that has it. The
	   previous design put it on 38 elements. At card scale, over a
	   low-frequency gradient backdrop, the difference between this and a
	   real blur is genuinely invisible — so real glass is opt-in, via
	   .glass--live, for the handful of surfaces that sit over moving or
	   high-contrast content and actually read as translucent.
	   ---------------------------------------------------------------------- */
	.glass {
		position: relative;
		isolation: isolate;

		border-radius: var(--radius-lg);
		background-color: var(--glass-tint-opaque);
		background-image: var(--glass-sheen);
		/* No `border`. The rim is drawn by box-shadow spread so ::before is
		   free to own the edge geometry for the lens ring. */
		box-shadow: var(--elev-2), var(--glass-rim);

		transition:
			box-shadow var(--dur-base) var(--ease-out),
			background-color var(--dur-base) var(--ease-out);
	}

	/* Content sits above both pseudo-elements. */
	.glass > *,
	.pane > * {
		position: relative;
		z-index: 2;
	}

	.glass--flush {
		box-shadow: var(--elev-1), var(--glass-rim);
	}

	.glass--float {
		box-shadow: var(--elev-float), var(--glass-rim);
	}

	/* ----------------------------------------------------------------------
	   GLASS — tier 1, real translucency.

	   Opt-in. Budget: at most 4 of these intersecting the viewport at once,
	   of which at most 2 may exceed 25% of viewport area. Never nested.
	   ---------------------------------------------------------------------- */
	@supports (
		(backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))
	) {
		.glass--live {
			background-color: var(--glass-tint);
			-webkit-backdrop-filter: blur(var(--glass-blur))
				saturate(var(--glass-saturate));
			backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
		}

		.glass--strong {
			background-color: var(--glass-tint-strong);
			-webkit-backdrop-filter: blur(var(--glass-blur-heavy))
				saturate(var(--glass-saturate));
			backdrop-filter: blur(var(--glass-blur-heavy))
				saturate(var(--glass-saturate));
		}
	}

	/* A zero blur radius still pays for the backdrop snapshot, so the lite
	   tier removes the property outright rather than dialling it to 0. */
	[data-fx='lite'] .glass--live,
	[data-fx='lite'] .glass--strong {
		-webkit-backdrop-filter: none;
		backdrop-filter: none;
		background-color: var(--glass-tint-opaque);
	}

	/* ----------------------------------------------------------------------
	   EDGE LENS — CSS-only refraction

	   A real lens bends light most at its edge, where the glass is thickest.
	   That is what this reproduces: a ring around the pane carrying a much
	   heavier blur plus a lift in brightness and saturation, so the backdrop
	   visibly distorts as it passes under the rim.

	   HOW THE RING IS CUT
	   The thickness lives in `padding`. With border-box sizing the content
	   box is the inner rect, so two mask layers — one clipped to content-box,
	   one to the default border-box — XOR into a ring exactly --lens-w thick
	   that follows `border-radius` for free, at any corner radius, with no
	   second element and no clip-path maths.

	   CROSS-BROWSER, and every line of this matters:

	   1. `-webkit-mask-composite` and `mask-composite` are DIFFERENT
	      PROPERTIES with different value grammars, not a prefix pair.
	      WebKit's legacy keyword is `xor`; the standard one is `exclude`.
	      Both must ship.
	   2. `mask` and `-webkit-mask` are SHORTHANDS, and a shorthand RESETS
	      mask-composite. So each composite declaration must come after its
	      own shorthand, and the WebKit pair must come first so a standards
	      engine's later declaration wins.
	   3. The whole ring — including its backdrop-filter — is inside the
	      @supports gate. If the composite declaration were ever dropped, the
	      two mask layers would union instead of subtract and the ring would
	      render as a FILLED RECT carrying a 48px blur over the entire panel:
	      both wrong and expensive. Gating the backdrop-filter with the mask
	      means an unsupporting engine gets no ring at all, which is correct.
	   4. The angular caustic goes in `background-image`, NOT in the mask.
	      `exclude` computes |a - b|, so a non-solid third mask layer leaks
	      partial alpha into the panel interior — and that interior would
	      then carry the heavy backdrop-filter. Painting it is free and safe.
	   ---------------------------------------------------------------------- */
	@supports (
		((mask-composite: exclude) or (-webkit-mask-composite: xor)) and
			((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)))
	) {
		.glass--lens::before {
			content: '';
			position: absolute;
			inset: 0;
			/*
			 * ABOVE the content, not below it — this is the one place the
			 * pseudo-element z-order is inverted, and it is the whole point
			 * of the effect. A real lens rim refracts whatever lies UNDER
			 * it; a ring painted beneath the pane's own image would be
			 * covered by that image and refract nothing.
			 *
			 * CONSTRAINT: a .glass--lens surface must have padding of at
			 * least --lens-w, so the ring only ever covers the margin of the
			 * pane and never reaches text.
			 */
			z-index: 3;
			pointer-events: none;
			border-radius: inherit;

			padding: var(--lens-w);

			-webkit-backdrop-filter: blur(var(--lens-blur)) saturate(var(--lens-sat))
				brightness(var(--lens-bright));
			backdrop-filter: blur(var(--lens-blur)) saturate(var(--lens-sat))
				brightness(var(--lens-bright));

			/* WebKit legacy grammar — must precede the standard pair. */
			-webkit-mask:
				linear-gradient(#000 0 0) content-box,
				linear-gradient(#000 0 0);
			-webkit-mask-composite: xor;

			/* Standard grammar — must follow `mask`, which resets composite. */
			mask:
				linear-gradient(#000 0 0) content-box,
				linear-gradient(#000 0 0);
			mask-composite: exclude;

			/* Chromatic aberration without an SVG filter: two sub-pixel
			   hairlines in complementary hues on opposite edges — the same
			   split a real lens produces, at the cost of one box-shadow. */
			box-shadow:
				inset 0.5px 0.5px 0 0 rgb(140 224 255 / 0.5),
				inset -0.5px -0.5px 0 0 rgb(255 132 214 / 0.42);

			/* The caustic: brightest where the light is. --rim-spin is driven
			   by pointer angle on the hero plate, so the bright arc travels
			   around the rim toward the cursor. */
			background-image: conic-gradient(
				from calc(var(--rim-spin) * 1turn) at 50% 50%,
				transparent 0deg,
				rgb(190 236 255 / 0.3) 52deg,
				rgb(255 255 255 / 0.55) 104deg,
				rgb(255 176 236 / 0.26) 158deg,
				transparent 252deg
			);
		}

		.glass--lens-strong::before {
			padding: var(--lens-w-strong);
		}
	}

	/* ----------------------------------------------------------------------
	   SPECULAR — the pointer light

	   ::after is the light layer for every surface. --mx/--my are written
	   already-smoothed by js/fx/surface.js, once per frame, and are never
	   CSS-transitioned: transitioning a registered property that feeds a
	   gradient forces a repaint of the element on every frame of the
	   transition, which is precisely the cost this design avoids.

	   Only the `opacity` is transitioned, which is compositor-only.
	   ---------------------------------------------------------------------- */
	.glass::after,
	.pane--lit::after {
		content: '';
		position: absolute;
		inset: 0;
		z-index: 1;
		pointer-events: none;
		border-radius: inherit;

		opacity: var(--spec-o, 0);
		background:
			radial-gradient(
				26rem 20rem at var(--mx) var(--my),
				var(--pointer-light),
				transparent 62%
			),
			radial-gradient(
				8rem 8rem at var(--mx) var(--my),
				var(--pointer-light),
				transparent 70%
			);

		transition: opacity var(--dur-slow) var(--ease-out);
	}

	@media (hover: hover) {
		.glass:hover,
		.glass:focus-within,
		.pane--lit:hover {
			--spec-o: 1;
		}
	}

	/* ----------------------------------------------------------------------
	   SHEEN — the specular sweep, panel variant

	   A real child element, not a pseudo (the budget is spent) and not a
	   background-position animation. It animates `translate` and `opacity`
	   only, so it is pure compositor work: zero repaint, zero layout.

	   The parent needs `overflow: clip` rather than `hidden` — `clip` does
	   not create a scroll container, and unlike `contain: paint` it does NOT
	   create a backdrop root, so glass beneath it keeps working.
	   ---------------------------------------------------------------------- */
	.sheen {
		position: absolute;
		inset: -20% -60%;
		z-index: 1;
		pointer-events: none;

		background: linear-gradient(
			var(--sweep-angle),
			transparent 42%,
			var(--relief-hi-2) 50%,
			transparent 58%
		);

		translate: -70% 0;
		opacity: 0;
	}

	@media (hover: hover) {
		.glass:hover > .sheen,
		.pane:hover > .sheen {
			animation: sheen-pass calc(var(--dur-slow) * 1.6) var(--ease-out) 1;
		}
	}

	/* Small-control variant: one registered property inside a gradient. One
	   element, one paint pass, discrete and user-initiated — permitted. */
	@media (hover: hover) {
		.sweep:hover::after {
			background-image: linear-gradient(
				var(--sweep-angle),
				transparent calc(var(--sweep-x) - var(--sweep-width)),
				var(--relief-hi-2) var(--sweep-x),
				transparent calc(var(--sweep-x) + var(--sweep-width))
			);
			animation: sweep-across calc(var(--dur-slow) * 1.6) var(--ease-out) 1;
		}
	}

	/* ----------------------------------------------------------------------
	   RELIEF — .pane (raised) and .well (debossed)

	   CONTRAST RULE, and it is structural rather than a matter of discipline:
	   .pane and .well carry CONTROL LABELS ONLY, at --text-1, weight >= 500.
	   Body copy is never placed on a soft face.

	   Neumorphism's classic failure is a low-contrast label on a face that
	   is the same colour as its background. Because the palette contract
	   guarantees --text-1 clears 4.5:1 against the surface ramp, keeping
	   prose off these surfaces makes that failure unreachable rather than
	   merely unlikely.
	   ---------------------------------------------------------------------- */
	.pane {
		position: relative;
		isolation: isolate;
		border-radius: var(--radius-md);
		background: var(--relief-face);
		box-shadow: var(--relief-raised);
		color: var(--text-1);
		font-weight: var(--weight-medium);

		transition:
			box-shadow var(--dur-base) var(--ease-out),
			background var(--dur-base) var(--ease-out),
			translate var(--dur-fast) var(--ease-out);
	}

	@media (hover: hover) {
		.pane:is(button, a, label, summary, [tabindex]):hover {
			box-shadow: var(--relief-raised-hi);
			translate: 0 -1px;
		}
	}

	.pane:is(button, a, label, summary, [tabindex]):active,
	.pane[aria-pressed='true'],
	.pane.is-on {
		box-shadow: var(--relief-pressed);
		background: var(--relief-face-press);
		translate: 0 1px;
	}

	/* Rule 5: on a translucent parent, drop the bright half. */
	.glass--live .pane,
	.glass--strong .pane,
	.pane--on-glass {
		box-shadow: var(--relief-on-glass);
	}

	.well {
		position: relative;
		border-radius: var(--radius-md);
		background: var(--well-face);
		box-shadow: var(--relief-well);
	}

	/* ---- Migration shims -------------------------------------------------
	   The former skeuomorphic class names, re-pointed at the relief system so
	   any component stylesheet not yet migrated keeps its tactile treatment.
	   These go with the last unlayered component file. */
	.raised {
		border-radius: var(--radius-md);
		background: var(--relief-face);
		box-shadow: var(--relief-raised);
		transition:
			box-shadow var(--t-fast),
			background var(--t-fast),
			translate var(--t-fast);
	}

	.inset {
		border-radius: var(--radius-md);
		background: var(--well-face);
		box-shadow: var(--relief-well);
	}

	/* Real tactile press for anything that is not a full .pane. */
	.pressable {
		transition:
			translate var(--dur-instant) var(--ease-out),
			box-shadow var(--dur-instant) var(--ease-out);
	}

	.pressable:active {
		translate: 0 1px;
		box-shadow: var(--elev-1), var(--relief-pressed);
	}

	/* ----------------------------------------------------------------------
	   DEPTH — place elements on z-planes
	   Used together with a perspective ancestor (.stage, or a component that
	   declares its own).
	   ---------------------------------------------------------------------- */
	.stage {
		perspective: var(--perspective);
		perspective-origin: var(--perspective-origin);
		transform-style: preserve-3d;
	}

	.stage--near {
		perspective: var(--perspective-near);
	}

	.depth-flat {
		transform: translateZ(var(--depth-flat));
	}
	.depth-fore {
		transform: translateZ(var(--depth-fore));
	}
	.depth-near {
		transform: translateZ(var(--depth-near));
	}

	/* The receding planes carry a focal blur. `filter` makes an element a
	   BACKDROP ROOT, flattening any nested backdrop-filter, so these are for
	   decorative layers only — never for a subtree containing .glass--live. */
	.depth-far {
		transform: translateZ(var(--depth-far));
		filter: blur(var(--focus-far));
	}
	.depth-back {
		transform: translateZ(var(--depth-back));
		filter: blur(var(--focus-back));
	}
	.depth-mid {
		transform: translateZ(var(--depth-mid));
		filter: blur(var(--focus-mid));
	}

	/* ----------------------------------------------------------------------
	   LIFT — hover elevation through z, not scale.
	   Scaling resamples text and makes it soft. Moving the element toward
	   the camera does not.
	   ---------------------------------------------------------------------- */
	.lift {
		transition:
			transform var(--dur-base) var(--ease-out),
			box-shadow var(--dur-base) var(--ease-out);
	}

	@media (hover: hover) {
		.lift:hover,
		.lift:focus-visible {
			transform: translate3d(0, -3px, var(--depth-fore));
			box-shadow: var(--elev-4), var(--glass-rim);
		}
	}

	/* ----------------------------------------------------------------------
	   TILT — pointer-driven lean.
	   JS writes --tilt-x / --tilt-y in degrees; the element leans toward the
	   cursor as though hinged at its centre.
	   ---------------------------------------------------------------------- */
	.tilt {
		transform: perspective(var(--perspective-near)) rotateX(var(--tilt-x, 0deg))
			rotateY(var(--tilt-y, 0deg)) translate3d(0, 0, var(--tilt-z, 0px));
		transition: transform var(--dur-slow) var(--ease-out);
	}

	.tilt[data-tilting] {
		/* While actively tracking, follow the cursor with almost no lag. */
		transition: transform var(--dur-instant) linear;
	}

	/* ----------------------------------------------------------------------
	   ORBS — ambient light sources.

	   Painted as radial-gradients with soft stops, NOT as solid circles
	   behind `filter: blur(72px)`. The two produce the same image; the
	   filtered version additionally allocates a full-size offscreen buffer
	   and runs a separable blur pass on every frame it moves. Seven such
	   layers were the largest continuous cost in the previous design.
	   ---------------------------------------------------------------------- */
	.orb-field {
		position: absolute;
		inset: 0;
		z-index: var(--z-behind);
		overflow: hidden;
		pointer-events: none;
		/* Contain so an orb can never trigger a page-wide repaint. */
		contain: strict;
	}

	.orb {
		position: absolute;
		border-radius: var(--radius-circle);
		opacity: 0.9;
	}

	.orb--1 {
		width: 34rem;
		height: 34rem;
		background: radial-gradient(
			closest-side,
			var(--orb-1),
			rgb(var(--accent-rgb) / 0.16) 52%,
			transparent 76%
		);
		animation: orb-drift-a 26s var(--ease-in-out) infinite alternate;
	}

	.orb--2 {
		width: 26rem;
		height: 26rem;
		background: radial-gradient(
			closest-side,
			var(--orb-2),
			rgb(var(--glow-rgb) / 0.12) 52%,
			transparent 76%
		);
		animation: orb-drift-b 31s var(--ease-in-out) infinite alternate;
	}

	.orb--3 {
		width: 30rem;
		height: 30rem;
		background: radial-gradient(
			closest-side,
			var(--orb-3),
			rgb(var(--accent-rgb) / 0.1) 52%,
			transparent 76%
		);
		animation: orb-drift-c 38s var(--ease-in-out) infinite alternate;
	}

	/* ----------------------------------------------------------------------
	   HAIRLINE — a rule that fades at both ends, so it reads as light
	   catching an edge rather than as a drawn border.
	   ---------------------------------------------------------------------- */
	.hairline {
		height: 1px;
		border: 0;
		background: linear-gradient(
			90deg,
			transparent,
			var(--line-strong) 20%,
			var(--line-strong) 80%,
			transparent
		);
	}

	.hairline--accent {
		background: linear-gradient(
			90deg,
			transparent,
			rgb(var(--accent-rgb) / 0.5) 30%,
			rgb(var(--glow-rgb) / 0.5) 70%,
			transparent
		);
	}

	/* ----------------------------------------------------------------------
	   FILM GRAIN

	   One fixed tile, not a 400%-oversized plane. The previous version sat at
	   `inset: -150%`, i.e. a layer sixteen times the viewport area, animated
	   on a step loop. A 240px tile repeated across a normal fixed layer is
	   visually identical and roughly a sixteenth of the compositing cost.
	   ---------------------------------------------------------------------- */
	.grain {
		position: fixed;
		inset: 0;
		z-index: var(--z-behind);
		pointer-events: none;

		opacity: var(--grain-opacity);
		mix-blend-mode: var(--grain-blend);

		/* Self-contained SVG turbulence — no image request. */
		background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
		background-repeat: repeat;

		/* Drift the tile by moving the layer a whole tile width, which the
		   repeat makes seamless — transform only, no background-position. */
		animation: grain-drift 7s steps(6, end) infinite;
	}

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

/* ==========================================================================
   MOTION

   ┌─ THE LAW ───────────────────────────────────────────────────────────────┐
   │                                                                         │
   │ An animation or transition may target ONLY:                             │
   │     opacity · transform · translate · rotate · scale                    │
   │     · a registered custom property that feeds one of those              │
   │                                                                         │
   │ Layout-triggering properties (width, height, top, margin, gap) and      │
   │ full-repaint properties (filter, backdrop-filter, box-shadow,           │
   │ background-position, clip-path) may be animated ONLY when all three     │
   │ hold:                                                                   │
   │     a) the element is under 25% of viewport area                        │
   │     b) discrete, user-initiated, non-looping, under 600ms               │
   │     c) not concurrent with scrolling                                    │
   │                                                                         │
   │ THREE NAMED EXCEPTIONS, each documented at its call site:               │
   │     · the project archive animates `height` (once, on click)            │
   │     · the theme change animates `clip-path` on ::view-transition-new    │
   │     · the hero <h1> wipes with `clip-path` once on load                 │
   │                                                                         │
   └─────────────────────────────────────────────────────────────────────────┘

   NO `filter: blur()` IN REVEAL KEYFRAMES. The previous design blurred every
   revealing element from 14px and every section from 2.5px. That is a
   full-box blur pass per frame for 900ms across up to six concurrent
   elements; it makes each of those elements a backdrop root, so glass inside
   a revealing section rendered flat for the whole entrance; and it
   disqualifies the animation from running off the main thread, which defeats
   the entire purpose of animation-timeline: view().

   The sense of "coming into focus" comes instead from a larger scale delta
   and from the glass's own edge lens.
   ========================================================================== */
@layer motion {
	/* ----------------------------------------------------------------------
	   REVEALS — three tiers, in increasing order of capability.

	   TIER 0 (no @supports, no JS): content is simply visible. Nothing is
	   ever hidden by default. This is why the page cannot break: a stylesheet
	   that fails to load, or a browser that supports neither path, still
	   shows every word.

	   TIER 1: animation-timeline: view() — the compositor drives it, no JS
	   is involved at all.

	   TIER 2: js/fx/reveal.js sets data-reveal-mode="observer" on <html>
	   ONLY after confirming tier 1 is unavailable. The hidden starting state
	   below is scoped to that attribute, so content is never hidden without
	   a guaranteed way to reveal it.
	   ---------------------------------------------------------------------- */
	@keyframes arrive {
		from {
			opacity: 0;
			translate: 0 1rem;
			scale: 0.985;
		}
		to {
			opacity: 1;
			translate: 0 0;
			scale: 1;
		}
	}

	@keyframes arrive-left {
		from {
			opacity: 0;
			translate: -1.25rem 0;
			rotate: y -4deg;
		}
		to {
			opacity: 1;
			translate: 0 0;
			rotate: y 0deg;
		}
	}

	@keyframes arrive-right {
		from {
			opacity: 0;
			translate: 1.25rem 0;
			rotate: y 4deg;
		}
		to {
			opacity: 1;
			translate: 0 0;
			rotate: y 0deg;
		}
	}

	@keyframes grow-line {
		from {
			scale: 1 0;
		}
		to {
			scale: 1 1;
		}
	}

	@keyframes draw-line {
		from {
			stroke-dashoffset: var(--dash-length, 1000);
		}
		to {
			stroke-dashoffset: 0;
		}
	}

	/*
	 * Reveals animate the INDEPENDENT translate/scale/rotate properties, not
	 * the `transform` shorthand.
	 *
	 * An animation with `both` fill owns its property for the element's
	 * entire life. If these used `transform`, every hover lift and every
	 * .tilt on a revealed element would be permanently dead. Keeping them on
	 * the independent properties leaves `transform` free for interaction.
	 */
	@supports (animation-timeline: view()) {
		[data-reveal] {
			animation: arrive var(--dur-reveal) var(--ease-glide) both;
			animation-timeline: view();
			animation-range: entry 0% cover 32%;
		}

		[data-reveal='left'] {
			animation-name: arrive-left;
		}

		[data-reveal='right'] {
			animation-name: arrive-right;
		}

		[data-reveal='line'] {
			animation-name: grow-line;
			transform-origin: top center;
		}

		/*
		 * Grid rows share a Y coordinate, so a view timeline gives every item
		 * in a row the same progress. The stagger has to come from the RANGE,
		 * never from animation-delay.
		 *
		 * On a progress timeline, a time delay does not postpone the start —
		 * it consumes part of the range. The tail of a long group then never
		 * reaches its end state, and items are left permanently half-revealed.
		 * Offsetting the range start is the correct mechanism, and --i is
		 * registered as <number> precisely so this calc() is legal.
		 */
		[data-reveal-group] > * {
			animation: arrive var(--dur-reveal) var(--ease-glide) both;
			animation-timeline: view();
			animation-range: entry calc(var(--i) * 3%) cover calc(30% + var(--i) * 3%);
		}
	}

	/* ---- Tier 2: observer fallback -------------------------------------- */
	html[data-reveal-mode='observer'] :is([data-reveal], [data-reveal-group] > *) {
		opacity: 0;
		translate: 0 1rem;
		scale: 0.985;
		transition:
			opacity var(--dur-reveal) var(--ease-glide),
			translate var(--dur-reveal) var(--ease-glide),
			scale var(--dur-reveal) var(--ease-glide);
		transition-delay: calc(var(--i) * 45ms);
	}

	html[data-reveal-mode='observer'] [data-reveal='left'] {
		translate: -1.25rem 0;
	}

	html[data-reveal-mode='observer'] [data-reveal='right'] {
		translate: 1.25rem 0;
	}

	html[data-reveal-mode='observer']
		:is([data-reveal], [data-reveal-group] > *).is-revealed {
		opacity: 1;
		translate: 0 0;
		scale: 1;
	}

	/* ----------------------------------------------------------------------
	   VELOCITY RESPONSE

	   js/core/scroll.js publishes --scroll-v (0..1, eased) and --scroll-dir
	   (+/-1) once per frame. One measurement, many consumers, zero extra
	   listeners.

	   Magnitudes come from tokens, NOT hard-coded numbers. The previous
	   system declared --skew-max and then hard-coded 3.4deg in the component
	   layer, so zeroing the token under reduced motion did precisely nothing.
	   ---------------------------------------------------------------------- */
	[data-skew] {
		transform: skewY(
			calc(var(--scroll-v) * var(--scroll-dir) * var(--skew-max))
		);
	}

	[data-lean] {
		transform: perspective(1200px)
			rotateX(calc(var(--scroll-v) * var(--scroll-dir) * var(--lean-max) * -1))
			translateY(calc(var(--scroll-v) * var(--scroll-dir) * var(--lean-rise) * -1));
	}

	/* ----------------------------------------------------------------------
	   SHARED KEYFRAMES
	   ---------------------------------------------------------------------- */
	@keyframes sheen-pass {
		0% {
			translate: -70% 0;
			opacity: 0;
		}
		18% {
			opacity: 1;
		}
		100% {
			translate: 70% 0;
			opacity: 0;
		}
	}

	@keyframes sweep-across {
		from {
			--sweep-x: -40%;
		}
		to {
			--sweep-x: 140%;
		}
	}

	@keyframes float-idle {
		from {
			transform: translate3d(0, 0, 0);
		}
		to {
			transform: translate3d(0, -0.6rem, 0);
		}
	}

	@keyframes pulse-ring {
		0% {
			opacity: 0.55;
			transform: scale(0.85);
		}
		70% {
			opacity: 0;
			transform: scale(1.9);
		}
		100% {
			opacity: 0;
			transform: scale(1.9);
		}
	}

	@keyframes spin {
		to {
			transform: rotate(1turn);
		}
	}

	@keyframes shake {
		0%,
		100% {
			transform: translate3d(0, 0, 0);
		}
		20% {
			transform: translate3d(-7px, 0, 0);
		}
		40% {
			transform: translate3d(6px, 0, 0);
		}
		60% {
			transform: translate3d(-4px, 0, 0);
		}
		80% {
			transform: translate3d(2px, 0, 0);
		}
	}

	@keyframes caret-blink {
		0%,
		45% {
			opacity: 1;
		}
		50%,
		95% {
			opacity: 0;
		}
		100% {
			opacity: 1;
		}
	}

	@keyframes orb-drift-a {
		from {
			transform: translate3d(0, 0, 0) scale(1);
		}
		to {
			transform: translate3d(9%, -7%, 0) scale(1.14);
		}
	}

	@keyframes orb-drift-b {
		from {
			transform: translate3d(0, 0, 0) scale(1.08);
		}
		to {
			transform: translate3d(-11%, 9%, 0) scale(0.92);
		}
	}

	@keyframes orb-drift-c {
		from {
			transform: translate3d(0, 0, 0) scale(0.96);
		}
		to {
			transform: translate3d(7%, 11%, 0) scale(1.1);
		}
	}

	@keyframes grain-drift {
		0% {
			transform: translate3d(0, 0, 0);
		}
		16% {
			transform: translate3d(-2%, 1.5%, 0);
		}
		33% {
			transform: translate3d(1.5%, -2%, 0);
		}
		50% {
			transform: translate3d(-1%, 2.5%, 0);
		}
		66% {
			transform: translate3d(2.5%, 1%, 0);
		}
		83% {
			transform: translate3d(-2.5%, -1%, 0);
		}
		100% {
			transform: translate3d(0, 0, 0);
		}
	}

	/* ----------------------------------------------------------------------
	   BOOT CHOREOGRAPHY

	   `data-boot` is set on <html> by the inline head script, NOT by a
	   module, so a JavaScript failure still produces the full entrance
	   rather than a page frozen at frame zero.

	   Real-time animation-delay is safe here because none of this is
	   scroll-linked — the postponement problem described above applies only
	   to progress timelines.
	   ---------------------------------------------------------------------- */
	@keyframes boot-fade {
		from {
			opacity: 0;
		}
		to {
			opacity: 1;
		}
	}

	@keyframes boot-drop {
		from {
			opacity: 0;
			translate: 0 -140%;
		}
		to {
			opacity: 1;
			translate: 0 0;
		}
	}

	@keyframes boot-plate {
		from {
			opacity: 0;
			scale: 0.94;
			rotate: y 8deg;
		}
		to {
			opacity: 1;
			scale: 1;
			rotate: y 0deg;
		}
	}

	@keyframes boot-wipe {
		from {
			clip-path: inset(0 100% 0 0);
		}
		to {
			clip-path: inset(0 0 0 0);
		}
	}

	@keyframes boot-rise {
		from {
			opacity: 0;
			translate: 0 1.4rem;
		}
		to {
			opacity: 1;
			translate: 0 0;
		}
	}

	@media (prefers-reduced-motion: no-preference) {
		/*
		 * Every step here animates opacity, translate, scale or rotate — the
		 * compositor-only set — with one documented exception, the <h1> wipe.
		 *
		 * Real-time `animation-delay` is safe in this block because none of
		 * it is scroll-linked. The rule against delays applies to progress
		 * timelines, where a time delay eats the range instead of postponing
		 * the start.
		 *
		 * Nothing here targets an element that carries [data-reveal]. Those
		 * are already owned by a `both`-filled reveal animation, and a second
		 * animation on the same property would fight it.
		 */
		html[data-boot] .backdrop,
		html[data-boot] .ambient {
			animation: boot-fade 700ms var(--ease-out) both;
		}

		html[data-boot] .nav__inner {
			animation: boot-drop 520ms var(--ease-spring) 120ms both;
		}

		/* THE ONE CLIP-PATH EXCEPTION.
		   Once, on load, on a single element, 700ms, never during a scroll.
		   A name arriving letter-band by letter-band is the page introducing
		   itself; every other way of animating it moves the whole block. */
		html[data-boot] .hero__name {
			animation: boot-wipe 700ms var(--ease-glide) 200ms both;
			/*
			 * REQUIRED. The <h1> carries data-reveal, so the reveal rule has
			 * already set `animation-timeline: view()` on it. Replacing
			 * `animation` alone would leave that timeline in place and the
			 * wipe would be driven by scroll position rather than by time —
			 * the name would sit half-drawn until the reader scrolled.
			 */
			animation-timeline: auto;
		}

		html[data-boot] .hero__plate {
			animation: boot-plate 800ms var(--ease-glide) 380ms both;
		}

		/* The instrument dock arrives last, after the page has settled. */
		html[data-boot] .instrument {
			animation: boot-rise 460ms var(--ease-spring) 1000ms both;
		}
	}

	/* ----------------------------------------------------------------------
	   REDUCED MOTION

	   The token layer already collapses every duration to its 1ms floor and
	   zeroes every displacement dial. This block handles the two things
	   tokens cannot reach: looping decorative animations, and reveal states
	   that must resolve to their FINISHED value rather than their starting
	   one — a reader who cannot see the animation must still see the content.
	   ---------------------------------------------------------------------- */
	@media (prefers-reduced-motion: reduce) {
		*,
		*::before,
		*::after {
			animation-duration: 1ms !important;
			animation-iteration-count: 1 !important;
			animation-delay: 0ms !important;
		}

		/*
		 * The reveal animations must be CANCELLED, not shortened.
		 *
		 * `animation-duration` does nothing to a scroll-progress timeline —
		 * progress comes from scroll position, not from elapsed time — so a
		 * 1ms duration leaves `animation-timeline: view()` driving the
		 * element exactly as before. And because an animation with `both`
		 * fill outranks any normal declaration in the cascade, the
		 * `opacity: 1` below could never win on its own: every off-screen
		 * element stayed pinned at its `from` state, opacity 0.
		 *
		 * Killing animation-name is what actually hands control back to the
		 * static declarations, which is the whole point — a reader who has
		 * asked for less motion must still see every word.
		 */
		[data-reveal],
		[data-reveal-group] > * {
			animation-name: none !important;
			animation-timeline: auto !important;
		}

		[data-reveal],
		[data-reveal-group] > *,
		html[data-reveal-mode='observer']
			:is([data-reveal], [data-reveal-group] > *) {
			opacity: 1 !important;
			translate: none !important;
			scale: none !important;
			rotate: none !important;
			transition: none;
		}

		[data-skew],
		[data-lean] {
			transform: none;
		}

		.sheen {
			display: none;
		}
	}
}

/* ==========================================================================
   UTILITY
   ========================================================================== */
@layer utility {
	/* Visually hidden but announced by screen readers. */
	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	.no-scroll {
		overflow: hidden;
	}

	[hidden] {
		display: none !important;
	}

	/* Eyebrow label above section headings. */
	.eyebrow {
		display: inline-flex;
		align-items: center;
		gap: var(--space-2xs);

		color: var(--accent);
		font-size: var(--text-xs);
		font-weight: var(--weight-semibold);
		letter-spacing: var(--tracking-widest);
		text-transform: uppercase;
	}

	/* Gradient text — violet into cyan, following the light source. The
	   angle tracks --px so the hue shifts as the pointer moves. */
	.gradient-text {
		background: linear-gradient(
			calc(112deg + var(--px, 0) * 14deg),
			var(--text-1) 8%,
			var(--accent-strong) 48%,
			var(--glow) 92%
		);
		-webkit-background-clip: text;
		background-clip: text;
		color: transparent;
		/* The background box is what clips the glyphs, so it must extend
		   below the baseline far enough to contain descenders. */
		padding-block-end: 0.18em;
		margin-block-end: -0.18em;
	}

	/* Constrain measure for comfortable reading. */
	.prose {
		max-width: var(--width-prose);
		color: var(--text-2);
	}

	.prose > * + * {
		margin-block-start: var(--space-sm);
	}

	/* ----------------------------------------------------------------------
	   CONTENT-VISIBILITY

	   Skips rendering for off-screen sections entirely. The two-value
	   `auto <length>` form lets the browser remember the last measured size,
	   so the scrollbar stops jumping once a section has been seen.

	   CAVEAT, and it constrains the whole design: `content-visibility: auto`
	   implies `contain: paint`, which makes the element a BACKDROP ROOT. Any
	   .glass--live inside a skipped section would render flat the moment it
	   first appears. It is therefore applied only via an explicit opt-in
	   class, never blanket to .section, and never to a section holding a
	   live glass pane or a scroll-snap container.
	   ---------------------------------------------------------------------- */
	.cv-auto {
		content-visibility: auto;
		contain-intrinsic-size: auto 900px;
	}

	@media print {
		.grain,
		.orb,
		.orb-field,
		.ambient,
		.backdrop,
		.nav,
		.instrument,
		.scrollbar,
		.skip-link,
		[data-print-hide] {
			display: none !important;
		}

		*,
		*::before,
		*::after {
			background: #fff !important;
			box-shadow: none !important;
			-webkit-backdrop-filter: none !important;
			backdrop-filter: none !important;
			color: #000 !important;
			filter: none !important;
			transform: none !important;
			animation: none !important;
			content-visibility: visible !important;
		}

		a::after {
			content: ' (' attr(href) ')';
			font-size: 0.85em;
			word-break: break-all;
		}
	}
}
