/**
 * Base site styles — Victoria Soncini.
 *
 * Placeholder foundation on semantic tokens only: zero hex/px values,
 * zero hard-coded fonts. Every value comes from var(--*) tokens or
 * theme.json presets. Per-domain splits arrive with the final design.
 *
 * TODO(IDENTIDAD): full visual design pending.
 */

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

html {
	/* iOS Safari inflates small text on its own (`text-size-adjust`), so
	 * glyphs render wider than the metrics the split-title JS measured
	 * at load: words outgrow their masked lines and read as cut
	 * (Títulos 01, solo reproducible en iPhone real, no en emulación).
	 * 100% keeps author sizes everywhere — zero change where no
	 * inflation would happen. Keywords, not design values: no token. */
	-webkit-text-size-adjust: 100%;
	text-size-adjust: 100%;
}

body {
	margin: 0;
	background-color: var(--color-background);
	color: var(--color-text);
	font-family: var(--font-sans);
	font-size: var(--font-size-body);
	line-height: var(--font-line-height-body);
}

img {
	max-width: 100%;
	height: auto;
}

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

/* One owner for the divider, so every `<hr>` in the site reads the same
 * colour by construction instead of each one carrying its own. Both dividers
 * that exist (Featured Work and Expertise) used to declare
 * `--color-accent` at their own scope; the token is now
 * `--color-accent-soft` site-wide by decision of Facu. Only the margin stays
 * with the component — the colour is not a per-component decision.
 *
 * Note the trade-off this moved: `--color-accent` measured 6.55:1 on paper,
 * `--color-accent-soft` (#D98CB4) measures 2.36:1. The rule is now a soft
 * pink that reads as a tint rather than a line. That is below the 3:1 of
 * WCAG 1.4.11 for non-text contrast, which is defensible for a purely
 * decorative separator but worth knowing if a divider ever has to carry
 * meaning. */
hr {
	border: 0;
	border-top: var(--size-border) solid var(--color-accent-soft);
}

/* --- Accessibility helpers --- */
.screen-reader-text {
	position: absolute;
	width: 0.0625rem;
	height: 0.0625rem;
	padding: 0;
	margin: -0.0625rem;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	white-space: nowrap;
	border: 0;
}

.skip-link:focus {
	width: auto;
	height: auto;
	clip: auto;
	padding: var(--size-2) var(--size-4);
	background-color: var(--color-ink);
	color: var(--color-paper);
	z-index: var(--z-skip);
}

/* --- Layout --- */
#home {
	position: relative;
}

.container {
	max-width: var(--size-content);
	margin-inline: auto;
	padding-inline: var(--size-4);
}

.content {
	padding-block: var(--size-6);
}

.reading {
	max-width: var(--size-reading);
	margin-inline: auto;
}

/* --- Sticky footer (every page) ---
 * Short pages (contact success, 404) ended mid-viewport with blank paper
 * below the footer. `#page` becomes the column and `#content` grows, so
 * long pages measure byte-identical while short ones push the footer to
 * the viewport bottom. `100vh` fallback + `100svh` upgrade (same shape
 * as the featured media token): svh is stable while mobile chrome
 * shows and hides; dvh would jump the footer mid-scroll. Both are
 * viewport exceptions per AGENTS.md, not design values, and `flex: 1`
 * is structural — no token, no new class (`#page`/`#content` already
 * exist in the markup). Sticky header unaffected: nothing on the chain
 * gains `overflow` clipping, and the fixed `.hero__bg` is out of flow,
 * never a flex item. */
#page {
	display: flex;
	flex-direction: column;
	min-height: 100vh;
	min-height: 100svh;
}

#content {
	flex: 1;
}

/* --- Header --- */
.header {
	position: sticky;
	top: 0;
	z-index: var(--z-header);
	/* Component scope (Sistema 01, acotados desde `:root`): condense
	 * range del scroll (60→80vh, posición de viewport, no medida), alto
	 * del wordmark en mobile (token propio porque la escala no tiene
	 * paso 20px) y grosor del hamburger (tap target WCAG 2.2). */
	--size-header-condense-from: 60vh;
	--size-header-condense-to: 80vh;
	--size-header-logo: 1.25rem;
	--size-toggle-bar: 0.125rem;
	/* The element stays 60px tall at every scroll position while the bar
	 * paints 33px, so its bottom 27px is empty box over the content behind
	 * it. Left interactive, that strip swallowed clicks and text selection
	 * on whatever was passing underneath. The element opts out of hit
	 * testing and the row of real controls opts back in, below. */
	pointer-events: none;
}

/* The bar's own background is a pseudo-element, not this element: the
 * visible bar has to shrink while the element keeps its full height, and
 * only a transform can do that without touching layout. Absolute + a
 * scale from the top edge means no `overflow` is needed anywhere — which
 * matters because `#nav-mobile` is a `position: fixed` child of
 * `#masthead` and would be clipped by one.
 *
 * The fill is opaque paper, full stop. It spent two iterations trying to be
 * a veil — 80% with no filter, then 92% with a 6px backdrop blur — and both
 * were solving the wrong problem. Any translucency makes the bar inherit the
 * luminance of whatever crosses behind it: a dark photograph dragged the
 * strip from L 0.94 down to L 0.42 as it passed. Blurring it and then
 * pushing the alpha to 97% reduced that, it did not remove it, because the
 * last few percent of transparency is exactly where the movement survives.
 * With a solid fill there is nothing left for the content behind to change,
 * so no blur, no alpha and no fallback branch are needed — the strip's
 * luminance is now constant at 0.938 whether it sits over paper, a
 * photograph or the circle-text.
 *
 * `pointer-events: none` because the bar is decoration: the 27px the box
 * keeps below the painted bar is empty, and it must not catch clicks. */
.header::before {
	content: '';
	position: absolute;
	inset: 0;
	z-index: -1;
	background-color: var(--color-paper);
	pointer-events: none;
	transform-origin: top center;
}

.header__inner {
	display: flex;
	align-items: center;
	gap: var(--size-4);
	padding-block: var(--size-3);
}

/* Re-enables the real controls after `.header` opted out of hit testing.
 * Deliberately NOT the row: the row is still 60px tall, so enabling it
 * would put the click trap straight back. The controls themselves all sit
 * in the top ~22px — inside the 33px the bar still paints — so the empty
 * strip below them is left click-through. */
.header__brand,
.header__nav,
.header__toggle {
	pointer-events: auto;
}

.header__brand {
	text-decoration: none;
	color: var(--color-text);
}

.header__wordmark {
	display: block;
	/* Tamaño intrínseco del SVG (279×36); el img global ya fija
	 * max-width: 100% + height: auto. */
}

/* Scroll-driven condense. Replaces a class toggle plus a transition, which
 * fed back into itself: the bar's height changed, the document got 27px
 * shorter, `scrollY` was clamped and walked backwards, and the header
 * chased it — a visible flicker, with 14 backwards scroll events in one
 * crossing.
 *
 * Everything here is transform/opacity only and the box never changes, so
 * the document height is constant at every scroll position and the loop
 * cannot exist. The two scale factors are the current measures turned
 * into ratios: the bar paints 33/60 of its height, the wordmark 20/36.
 * The third move is what keeps the nav aligned with the logo — scaling the
 * wordmark does not move its box, so the flex row would not follow it.
 *
 * Below the `from` stop the bar is full size, across the range it
 * condenses, and from the `to` stop on it stays condensed. No
 * `overflow` and no clip, per the drawing: the pseudo is bounded by its
 * own transform, not by the header's edges. */
@keyframes header-condense-bar {
	from {
		transform: scaleY(1);
	}
	to {
		transform: scaleY(0.55);
	}
}

@keyframes header-condense-wordmark {
	from {
		transform: scale(1);
	}
	to {
		transform: scale(0.556);
	}
}

@keyframes header-condense-inner {
	from {
		transform: translateY(0);
	}
	to {
		transform: translateY(-0.875rem);
	}
}

/* Desktop only. On mobile the bar has a fixed height and the wordmark sits at
 * `--size-header-logo`, so there is nothing to condense: the element would
 * keep its layout box while painting a shorter bar, which is the 27px
 * click-through strip the comment on `.header` exists to manage — a trade
 * worth making on a row with one control in it, not on the desktop row with
 * a logo and six links. */
@media (min-width: 1024px) {
	@supports (animation-timeline: scroll()) {
		.header::before {
			animation: header-condense-bar var(--motion-ease-linear) both;
			animation-timeline: scroll(root);
			animation-range: var(--size-header-condense-from) var(--size-header-condense-to);
		}

		.header__wordmark {
			/* `left center` so the wordmark shrinks toward its own left
			 * edge, keeping it flush with the container the way the absolute
			 * version did. */
			transform-origin: left center;
			animation: header-condense-wordmark var(--motion-ease-linear) both;
			animation-timeline: scroll(root);
			animation-range: var(--size-header-condense-from) var(--size-header-condense-to);
		}

		.header__inner {
			animation: header-condense-inner var(--motion-ease-linear) both;
			animation-timeline: scroll(root);
			animation-range: var(--size-header-condense-from) var(--size-header-condense-to);
		}
	}
}

.header__tagline {
	display: block;
	font-size: var(--font-size-label);
	color: var(--color-text-muted);
}

.header__nav {
	margin-left: auto;
}

.menu {
	display: flex;
	gap: var(--size-4);
	list-style: none;
	margin: 0;
	padding: 0;
}

.menu a {
	text-decoration: none;
	color: var(--color-text);
	font-size: var(--font-size-label);
	/* The padding stays reserved on *every* item, not only the current
	 * one, because the current state is a background rather than a
	 * decoration. Without a reserved box the active item would grow the
	 * height of the row and shove its neighbours sideways the moment the
	 * page loads. Reserving it keeps the row identical in both states and
	 * only the fill changes.
	 *
	 * No radius and no border, by decision. That also retires the trick
	 * that had to reserve a transparent border: a border added on hover
	 * grows the box, and these links are `width: auto`, so
	 * `box-sizing: border-box` did not stop it — the measured row moved
	 * 2px per item. With no border left to add there is nothing to
	 * reserve. */
	padding: var(--size-2);
}

/* Two selectors, one declaration: `wp_nav_menu()` emits both signals, and
 * the theme only strips the classes for fragment-only links (see
 * functions.php). Matching either means the highlight follows whichever
 * signal survives, so the two can never disagree about which page is
 * current. */
.menu .current-menu-item a,
.menu a[aria-current] {
	text-decoration: none;
	background-color: var(--color-nav-active-bg);
}

/* Hover is a colour on the label, in the secondary accent: 6.62:1 on
 * paper, over the 4.5:1 it needs. It deliberately does not touch the
 * background — the fill already means "you are here", and a tinted fill
 * under the pointer would blur that difference.
 *
 * Focus keeps an `outline` rather than a border: with no border in the box
 * there is nothing left to colour, and an outline never enters layout at
 * all, so it cannot move the row the way the border did. */
.menu a:hover {
	color: var(--color-accent-secondary);
}

/* Shared focus patterns (Sistema 01): 6 rules, 2 declarations. Each
 * selector keeps its own specificity; source order is irrelevant here
 * because no other rule touches `outline` on these elements. `.btn`,
 * `.expertise__track` and `.about__svg-portrait` keep their own pattern
 * next to their component. */
.menu a:focus-visible,
.footer__contact-link:focus-visible,
#expertise .expertise__btn:focus-visible {
	outline: var(--size-outline) solid var(--color-text);
	outline-offset: var(--size-2);
}

.header__toggle:focus-visible,
.card__link:focus-visible,
.featured__link:focus-visible {
	outline: var(--size-outline) solid var(--color-accent);
	outline-offset: var(--size-2);
}

.header__toggle {
	display: none;
}

.header__mobile {
	display: none;
	/* The drawer is a `position: fixed` child of `#masthead`, so it would
	 * inherit `.header`'s `pointer-events: none` and go unclickable. It is
	 * an opaque full-screen panel anyway — it *should* catch the pointer,
	 * including the area behind the bar. */
	pointer-events: auto;
}

/* Mobile (≤ 1023.98px, desktop-first): la nav desktop se oculta y el
 * nav-mobile pasa a ser un drawer de alto completo que entra desde la
 * izquierda. Sin scrim: el panel cubre el viewport entero.
 * El padding superior despeja la fila del header (wordmark + toggle);
 * si un día hay tagline con wrap, sigue entrando en el token. */
@media (max-width: 1023.98px) {
	.header__nav {
		display: none;
	}

	/* La fila queda por encima del drawer: el toggle sigue siendo
	 * clickeable con el menú abierto (el drawer es fixed, no la tapa). */
	.header__inner {
		position: relative;
		z-index: var(--z-content);
	}

	.header__brand {
		margin-right: auto;
	}

	/* Fixed bar on mobile: the wordmark carries the height and the row's
	 * `padding-block` carries the rest, so the bar measures
	 * `12 + 20 + 12 = 44px` at every scroll position with no animation and
	 * nothing to re-measure.
	 *
	 * The `height` is a declaration, not a hint: the global `img { height:
	 * auto }` makes the browser use the SVG's *own* ratio, and wordmark.svg
	 * is 407x61 — not the 279x36 its `<img>` attributes claim. Left to
	 * `auto` the logo renders 41.81px tall on this row and the bar comes out
	 * at 65.81px, which is where the extra 6px over a nominal 60 came from.
	 * `width: auto` is what keeps the proportion. The `<img>` carries
	 * `width="279"` as a presentational hint, and a presentational hint wins
	 * over the intrinsic ratio: pinning only the height gave 279x20, a ratio
	 * of 13.95 against the 6.67 the SVG actually is. Releasing the width
	 * lets the height drive it and the logo comes out 133.4px wide.
	 *
	 * That also means the `width`/`height` attributes on this `<img>` are
	 * wrong — they say 279x36, the file is 407x61 — and they were wrong
	 * before this change too: with `height: auto` the browser ignored them
	 * and rendered 41.81px tall. Worth fixing in the template sometime; it
	 * is not fixed here because it would change the desktop row. */
	.header__wordmark {
		height: var(--size-header-logo);
		width: auto;
	}

	.header__toggle {
		flex-direction: column;
		align-items: center;
		justify-content: center;
		gap: var(--size-1);
		width: var(--size-5);
		height: var(--size-5);
		padding: 0;
		border: 0;
		background: none;
		color: var(--color-text);
		cursor: pointer;
	}

	/* Gate de JS (como html.js-motion): sin el script el drawer es
	 * inalcanzable y el botón sería un control muerto. */
	html.js-menu .header__toggle {
		display: flex;
	}

	.header__toggle-bar {
		display: block;
		width: var(--size-4);
		height: var(--size-toggle-bar);
		background-color: currentColor;
	}

	.header__mobile {
		display: block;
		position: fixed;
		inset: 0;
		z-index: 0;
		overflow-y: auto;
		overscroll-behavior: contain;
		background-color: var(--color-surface);
		padding: var(--size-9) var(--size-4) var(--size-6);
		transform: translate3d(-100%, 0, 0);
		visibility: hidden;
		/* visibility con delay: se oculta recién al terminar la salida. */
		transition:
			transform var(--motion-duration-fast) var(--motion-ease-out),
			visibility 0s linear var(--motion-duration-fast);
	}

	.header__mobile .menu {
		flex-direction: column;
		gap: var(--size-4);
	}

	/* Tap target: line-height heredado 24px + 16px de padding arriba y
	 * abajo = 56px de alto (mínimo WCAG 2.2: 24px). */
	/* Full width: a menu row is a target, and a target the width of its own
	 * word is a target the thumb has to find. `block` plus an explicit `100%`
	 * because `inline-block` would shrink-wrap to the text — which is what
	 * it did, 13.8%–19.9% of the menu. Height is unchanged: the box was
	 * already `24px` of line-height plus `16px` of padding top and bottom,
	 * and a block box keeps that. */
	.header__mobile .menu a {
		display: block;
		width: 100%;
		padding-block: var(--size-4);
	}
}

body.is-menu-open {
	overflow: hidden;
}

body.is-menu-open .header__mobile {
	transform: translate3d(0, 0, 0);
	visibility: visible;
	transition:
		transform var(--motion-duration-fast) var(--motion-ease-out),
		visibility 0s;
}

/* Reduced motion: el drawer aparece y desaparece sin deslizar. */
@media (prefers-reduced-motion: reduce) {
	.header__mobile,
	body.is-menu-open .header__mobile {
		transition: none;
	}

	/* The header does not condense at all: the bar keeps its full size
	 * for the whole scroll. The condense is a decorative response to
	 * scrolling, so removing it costs nothing functional. */
	.header::before,
	.header__inner,
	.header__wordmark {
		animation: none;
	}
}

/* --- Footer --- */
.footer {
	/* Solid body-minus-one-step backdrop: --color-subtle is paper
	 * (#F8F8F7) one shade darker (#EDEDEC), existing token. */
	background-color: var(--color-subtle);
	border-top: var(--size-border) solid var(--color-border);
	margin-top: var(--size-8);
	padding-block: var(--size-6);
}

.footer__inner {
	display: flex;
	flex-wrap: wrap;
	gap: var(--size-4);
	align-items: baseline;
	justify-content: space-between;
}

.footer__home {
	text-decoration: none;
	color: var(--color-text);
	font-weight: var(--font-weight-h5);
}

.footer__contact {
	display: flex;
	flex-direction: column;
	gap: var(--size-2);
}

.footer__contact-title {
	margin: 0;
	font-weight: var(--font-weight-h5);
	color: var(--color-text);
}

.footer__contact-link {
	transition: color var(--motion-duration-fast) var(--motion-ease-out);
}

.footer__contact-link:hover {
	color: var(--color-accent-secondary);
}

@media (prefers-reduced-motion: reduce) {
	.footer__contact-link {
		transition: none;
	}
}

.footer__legal {
	margin-top: var(--size-4);
	font-size: var(--font-size-label);
	color: var(--color-text-muted);
}

.footer__note {
	margin-top: var(--size-4);
}

/* Desktop: two archive spans, the grid's own language for "half a row":
 * two columns plus their gap is half the content minus half a gap
 * (`content/2 − 12px`). ~92 characters per line at 1440px. Below 1024px
 * there is no cap: the note takes the full row. */
@media (min-width: 1024px) {
	.footer__note {
		max-width: calc(50% - var(--size-5) / 2);
	}
}

.footer__note-title {
	font-family: var(--font-sans);
	font-size: var(--font-size-label);
	line-height: var(--font-line-height-label);
	font-weight: var(--font-weight-semibold);
	color: var(--color-text);
	margin: 0;
}

.footer__note-text {
	font-size: var(--font-size-label);
	line-height: var(--font-line-height-label);
	color: var(--color-text-muted);
	margin: var(--size-2) 0 0;
}

/* Mobile (≤ 1023.98px): el margen superior baja un escalón (64 → 48)
 * para acompañar el padding de las secciones. */
@media (max-width: 1023.98px) {
	.footer {
		margin-top: var(--size-7);
	}
}

/* --- Home sections (placeholder composition) --- */
.hero {
	position: relative;
	overflow: hidden;
	min-height: 75vh;
	display: flex;
	flex-direction: column;
	justify-content: center;
	/* Vertical only: the horizontal gutter is `.container`'s, shared with
	 * every other section. A padding here would double it. */
	padding-block: var(--size-9);
}

/* Backdrop: fixed behind the whole page, independent of #hero.
 * Covers the viewport, decorative, non-interactive. Opacity 10% is
 * in the SVG itself. Fixed so scroll(root) can drive it over 150vh
 * from the top of the document. */
.hero__bg {
	/* Component scope (Sistema 01, acotado desde `:root`): el wordmark
	 * se lee horizontal en mobile al 110% del viewport, centrado por
	 * este flex. Porcentaje contra la capa fixed (= viewport). */
	--size-bg-scale-mobile: 110%;
	position: fixed;
	inset: 0;
	z-index: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	pointer-events: none;
}

.hero__bg-img {
	display: block;
	width: 100%;
	max-width: var(--size-wide);
	height: auto;
}

.hero__bg svg {
	overflow: visible;
}

/* The hero positions its content above the fixed backdrop layer; the
 * horizontal inset comes from `.container` itself, like every other
 * section. It used to zero its own `padding-inline` and let `.hero`
 * supply the gutter instead, which put the headline at 96px on desktop
 * against the 16px every other section uses — a 80px misalignment across
 * 1024–1300 that then inverted to -16px past 1585 once the container
 * started centring on its max-width. Zeroing it also fought the mobile
 * rule, which was already correct at --size-4. */
.hero > .container {
	position: relative;
	z-index: var(--z-content);
	width: 100%;
}

/* Hero backdrop — independent fixed layer driven by the root
 * scroller over 450vh from the top. Each piece gets its own
 * --exit-x / --exit-y at load (random) and travels there.
 * After 450vh the layer leaves the render. Opacity and transform
 * share the full 0–450vh range for a gradual exit. */
@keyframes hero-bg-exit {
	from {
		translate: 0 0;
		opacity: 1;
	}
	to {
		translate: var(--exit-x, 0) var(--exit-y, 0);
		opacity: 0;
	}
}

@keyframes hero-bg-hide {
	0%,
	99.9% {
		visibility: visible;
	}
	100% {
		visibility: hidden;
	}
}

/* Each piece is a standalone SVG <g>; no extra transform-box needed. */
.hero__bg-piece {
	transform-box: fill-box;
	transform-origin: center;
}

@supports (animation-timeline: scroll()) {
	.hero__bg {
		animation: hero-bg-hide var(--motion-duration-med) var(--motion-ease-linear) both;
		animation-timeline: scroll(root);
		animation-range: 0 450vh;
	}
	.hero__bg-piece {
		animation: hero-bg-exit var(--motion-duration-med) var(--motion-ease-linear) both;
		animation-timeline: scroll(root);
		animation-range: 0 450vh;
	}
}

.hero__title {
	font-family: var(--font-serif);
	font-size: var(--font-size-h1);
	line-height: var(--font-line-height-h1);
	font-weight: var(--font-weight-h1);
	letter-spacing: var(--tracking-tight);
	margin: 0 0 var(--size-6);
}

.hero__lead {
	font-size: var(--font-size-h5);
	line-height: var(--font-line-height-h5);
	color: var(--color-text-muted);
	max-width: var(--size-hero-copy);
}

/* Inner paragraphs from TinyMCE: inherit the lead's styling. */
.hero__lead p {
	font-size: inherit;
	line-height: inherit;
	margin: 0 0 var(--size-4, 1rem);
}

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

/* Sin mobile en Figma: el clamp de H1 ya adapta, solo se ajusta el
 * padding vertical (el lateral viene de `.container`). */
@media (max-width: 1023.98px) {
	.hero {
		padding-block: var(--size-8);
	}

	.hero__title {
		margin-bottom: var(--size-5);
	}

	/* Mobile 01 / Prompt 01: el wordmark se lee horizontal en mobile,
	 * igual que en desktop. La capa es fixed con
	 * `align-items/justify-content: center`, así que el flex ya centra
	 * la caja: `width` al 110% del viewport con `height: auto` conserva
	 * la proporción apaisada de ambos SVGs sin rotar ni deformar. El 5%
	 * que sangra por lado vive dentro de la capa fixed (fuera de flujo),
	 * así que no suma scroll horizontal a la página. Sin `position:
	 * absolute` ni `translate(-50%,-50%)`. La animación es la misma de
	 * desktop (eje Y vertical en pantalla), por eso el JS ya no invierte
	 * ejes en mobile. */
	.hero__bg-img {
		width: var(--size-bg-scale-mobile);
		max-width: none;
		height: auto;
		transform: none;
	}
}

/* Air around the logos slider: it was hugging the Featured Work list, and
 * a marquee that touches the section above reads as part of it. */
.brands {
	padding-block: var(--size-7);
}

/* Full-bleed en todos los viewports: rompe el padding-inline del
 * container para que los logos entren por el borde izquierdo del
 * viewport y salgan por el derecho. El título queda en container.
 * El viewport conserva `overflow: visible` por pedido. */
.brands__viewport {
	margin-inline: calc(-1 * var(--size-4));
	overflow: visible;
}

.brands__track {
	display: flex;
	width: max-content;
	will-change: transform;
	/* El estado inicial ya es el punto de partida de la animación:
	 * sin salto antes de que el primer frame corra. */
	transform: translate3d(-50%, 0, 0);
	animation: brands-marquee 120s var(--motion-ease-linear) infinite;
}

.brands__list,
.brands__list--clone {
	display: flex;
	gap: var(--size-5);
	/* Gap final: `gap` solo separa items internos, así que sin esto
	 * los dos últimos logos de cada copia quedarían pegados (0px) en
	 * la unión. Con el padding, el ancho de una lista incluye su
	 * cierre y -50% del track sigue siendo exactamente una lista. */
	padding: 0 var(--size-5) 0 0;
	width: max-content;
	list-style: none;
	margin: 0;
}

.brands__item {
	display: flex;
	align-items: center;
	justify-content: center;
	flex: 0 0 auto;
	padding: var(--size-4);
	transition: background-color var(--motion-duration-fast) var(--motion-ease-out), box-shadow var(--motion-duration-fast) var(--motion-ease-out);
}

/* Hover desactivado: la tira es un marquee continuo y los logos no
 * reaccionan al cursor. Se conserva comentado para reutilización.
 * .brands__item:hover {
 * 	background-color: var(--color-highlight);
 * } */

.brands__logo {
	height: var(--size-5);
	width: auto;
	max-width: 100%;
	object-fit: contain;
	filter: grayscale(1);
}

/* Desktop: mismo marquee, logo un escalón arriba (32px). */
@media (min-width: 1024px) {
	.brands__logo {
		height: var(--size-6);
	}
}

/* El reveal por item no aplica dentro de un track en movimiento: el
 * stagger no se lee y, con el clip del viewport, el desplazamiento
 * vertical de entrada cortaría el logo contra el borde. La sección
 * sigue revelándose como bloque. */
html.js-motion .brands__item[data-reveal] {
	opacity: 1;
	transform: none;
	transition: none;
}

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

	to {
		transform: translate3d(0, 0, 0);
	}
}

/* Pausa (WCAG 2.2.2): hover y foco del teclado, o cuando el marquee no
 * está en viewport. La clase is-paused la pone vs-brands-marquee.js con
 * un IntersectionObserver; no hay scroll listener. */
.brands__track:hover,
.brands__track:focus-within,
.brands__track.is-paused {
	animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
	.brands__track {
		animation: none;
		transform: none;
		will-change: auto;
	}

	/* Sin movimiento la fila se arrastra con el dedo: los 20 logos siguen
	 * alcanzables y el scroll-x sigue contenido en el viewport. Mismo
	 * patrón que el track de Expertise. */
	.brands__viewport {
		overflow-x: auto;
		overscroll-behavior-x: contain;
		scrollbar-width: none;
	}

	.brands__viewport::-webkit-scrollbar {
		display: none;
	}
}

.section {
	padding-block: var(--size-7);
}

.section__title {
	font-size: var(--font-size-h2);
	line-height: var(--font-line-height-h2);
	margin: 0 0 var(--size-5);
	text-align: left;
}

/* --- Tipografía mobile: saltos de línea ---
 * Los tamaños y el interlineado viven en los tokens (los line-heights de
 * H1–H4 interpolan con clamp). Acá va solo el comportamiento de partida
 * de línea, que antes no existía en ningún lado y a 360–430px es donde
 * más se nota. Sólo en mobile: en desktop el texto suele partir en 1–2
 * líneas y no hay nada que balancear. */
@media (max-width: 1023.98px) {
	/* Heredado por todo el texto del sitio: evita la última línea con una
	 * sola palabra en los párrafos, y `break-word` (no `anywhere`) es la
	 * red de seguridad para palabras largas o URLs sin romper el ancho
	 * mínimo de los contenedores. */
	body {
		text-wrap: pretty;
		overflow-wrap: break-word;
	}

	/* `balance` reparte parejo las líneas de los títulos; el navegador lo
	 * ignora en bloques de más de 6 líneas, así que no hay riesgo en los
	 * párrafos largos. Se aplica por etiqueta para cubrir cualquier nivel
	 * que venga del contenido de la base. */
	h1,
	h2,
	h3,
	h4 {
		text-wrap: balance;
	}
}


/* Clases de rol: nivel visual cuando difiere del semántico. */
.h1 {
	font-family: var(--font-serif);
	font-size: var(--font-size-h1);
	line-height: var(--font-line-height-h1);
	font-weight: var(--font-weight-h1);
}

.h2 {
	font-family: var(--font-serif);
	font-size: var(--font-size-h2);
	line-height: var(--font-line-height-h2);
	font-weight: var(--font-weight-h2);
}

.h3 {
	font-family: var(--font-serif);
	font-size: var(--font-size-h3);
	line-height: var(--font-line-height-h3);
	font-weight: var(--font-weight-h3);
}

.h4 {
	font-size: var(--font-size-h4);
	line-height: var(--font-line-height-h4);
}

.h5 {
	font-size: var(--font-size-h5);
	line-height: var(--font-line-height-h5);
}

.section__more {
	margin-top: var(--size-5);
}

/* Mobile (≤ 1023.98px): las secciones genéricas bajan un escalón
 * (48 → 32). Regla del prompt de espaciados: los valores de escala de
 * sección desde size-6 hacia arriba bajan un escalón; los de size-5
 * hacia abajo quedan (ya son chicos). */
@media (max-width: 1023.98px) {
	.section {
		padding-block: var(--size-6);
	}
}

/* --- Work grid + cards ---
 * The grid holds the shared work card (template-parts/work-card.php), the
 * same component Featured Work uses; only the container differs. */
.grid {
	display: grid;
	/* One column up to the desktop breakpoint, so the archive reads the
	 * same as Featured Work on mobile. The old `auto-fill
	 * minmax(--size-grid-lg)` was viewport-dependent: it gave 2 columns at
	 * 768 and 3 at 1000, so the layout silently changed inside what the
	 * project treats as "mobile". */
	grid-template-columns: 1fr;
	gap: var(--size-5);
	/* Cards are as tall as their own media, so nothing stretches and leaves
	 * a void inside the border. */
	align-items: start;
}

/* Desktop: four columns with each card spanning two, so the card keeps
 * enough measure for the overlapping text card instead of being crushed
 * into a quarter of the page.
 *
 * The zigzag steps the even items down with the same positional parity
 * Featured Work uses for its chip colors and `--reveal-dir`. It is a
 * margin, not a transform, because the reveal animates `transform` on the
 * way in and a transform offset would fight it. It cannot be animated,
 * which is correct: the step is layout, not motion, and reduced-motion
 * never affects it.
 *
 * DOM order is untouched — the step is visual only, so the reading order
 * for screen readers stays 1, 2, 3… in source order. */
@media (min-width: 1024px) {
	.grid {
		grid-template-columns: repeat(4, 1fr);
		/* Room for the stepped card plus the row gap, so the step does not
		 * eat into the next row. */
		row-gap: var(--size-9);
		column-gap: var(--size-5);
	}

	.grid .featured__item {
		grid-column: span 2;
	}

	.grid .featured__item:nth-child(even) {
		margin-block-start: var(--size-9);
	}
}

.card {
	border: var(--size-border) solid var(--color-border);
	border-radius: var(--size-radius-sm);
	overflow: hidden;
	background-color: var(--color-surface);
	/* Motion values (duration/distance) have no tokens; only
	 * transform/opacity-adjacent properties animate (DoD). */
	transition: transform var(--motion-duration-fast) var(--motion-ease-out);
}

.card:hover,
.card:focus-within {
	transform: translateY(calc(var(--size-2) * -1));
}

.card:hover .card__title,
.card:focus-within .card__title {
	color: var(--color-accent);
}

.card:active {
	transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
	.card,
	.card:hover,
	.card:focus-within,
	.card:active {
		transform: none;
		transition: none;
	}
}

.card__link {
	display: block;
	text-decoration: none;
	color: inherit;
}

.card__title {
	font-size: var(--font-size-h5);
	line-height: var(--font-line-height-h5);
	margin: var(--size-3) var(--size-4) 0;
	text-align: left;
}

.card__brief {
	font-size: var(--font-size-label);
	color: var(--color-text-muted);
	margin: var(--size-2) var(--size-4) var(--size-4);
}

/* --- Button (Figma 304:883, Button/Primary/Medium, showIcon=false) ---
 *
 * The Figma component ships with a `showIcon` prop and this instance has
 * it false, so the ↗ is not rendered and the `gap` has nothing to separate.
 * The gap stays declared anyway: it is the component's own spacing and it
 * is what would hold the label and the icon apart the moment any caller
 * turns the icon back on. Without it, adding the icon later would silently
 * close the spacing instead of honouring the design.
 *
 * Two deliberate departures from the Figma node, both yours:
 *
 * - `--size-7` (3rem / 48px) for the height instead of the node's fixed
 *   56px. 56 is not on the spacing scale and I would rather land on a real
 *   token 8px short than mint one off-grid. To be replicated in Figma.
 * - `--color-accent-primary` for the border and the label, which is the
 *   node's `accent/primary` #EC95BD — that value had no token until this
 *   work, though it was already the pink baked into every SVG asset. */
.btn {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	gap: var(--size-2);
	min-height: var(--size-7);
	padding-inline: var(--size-5);
	border: var(--size-border) solid var(--color-accent-primary);
	border-radius: var(--size-radius-xs);
	background-color: var(--color-ink);
	color: var(--color-accent-primary);
	font-family: var(--font-sans);
	font-size: var(--font-size-label);
	line-height: var(--font-line-height-label);
	font-weight: var(--font-weight-regular);
	/* No `letter-spacing` declaration: the node's value is 0, which is what
	 * the cascade already gives. Only `--tracking-tight` exists as a token,
	 * and there is nothing to tokenise about a value that is the absence of
	 * a declaration. */
	text-align: center;
	text-decoration: none;
	white-space: nowrap;
	overflow: clip;
	cursor: pointer;
	/* The state swap is a colour inversion, so it gets the same treatment
	 * `.form__field` already has: the fast duration and the out-ease, on
	 * the two properties that actually change. Border stays
	 * `--color-accent-primary` in both states, so there is nothing to
	 * transition there.
	 *
	 * No `prefers-reduced-motion` override, and that is deliberate. The
	 * rule there is about movement — every `transition: none` in this file
	 * kills a transform — and a colour cross-fade does not move anything
	 * across the screen. `.form__field` transitions background and shadow
	 * without an override for the same reason. */
	transition:
		background-color var(--motion-duration-fast) var(--motion-ease-out),
		color var(--motion-duration-fast) var(--motion-ease-out);
}

.btn:hover {
	background-color: var(--color-accent-primary);
	color: var(--color-ink);
}

.btn:focus-visible {
	outline: var(--size-border) solid var(--color-accent-primary);
	outline-offset: var(--size-1);
}

/* Mobile: full-width CTA capped so it fills narrow viewports without
 * becoming a banner on tablets. `--size-expertise-card` (25rem ≈ the
 * ~425px asked) borrowed across jobs — same precedent as `.footer__note`:
 * documented coupling instead of a new token. Left-aligned under the cap,
 * like the rest of the column. */
@media (max-width: 1023.98px) {
	.btn {
		width: 100%;
		max-width: var(--size-expertise-card);
	}
}

/* --- Closing (Figma: Display/L title left + semibold actions) --- */
#cierre .section__title {
	font-family: var(--font-serif);
	font-size: var(--font-size-h2);
	line-height: var(--font-line-height-h2);
	text-align: left;
}

.cierre__actions {
	font-size: var(--font-size-body);
	line-height: var(--font-line-height-body);
	color: var(--color-text);
}

/* Mobile (≤ 1023.98px): los links de footer son texto sin padding
 * (17–20px de alto) y quedan bajo el mínimo táctil de 24px.
 * inline-block + padding-block size-1 los lleva a 25–28px sin mover el
 * flujo del texto ni tocar desktop.
 *
 * `.cierre__actions a` salió de esta lista: su único link es ahora un
 * `.btn`, y un botón con `min-height: var(--size-7)` ya mide 48px de alto
 * sin necesitar el padding de texto que motivaba el ajuste. */
@media (max-width: 1023.98px) {
	.footer__home,
	.footer__contact a {
		display: inline-block;
		padding-block: var(--size-1);
	}
}

.chips {
	display: flex;
	flex-wrap: wrap;
	gap: var(--size-2);
	list-style: none;
	/* Sin margen izquierdo: los chips arrancan al filo del texto (el
	 * derecho se conserva). Solo lo usa el single de Work. */
	margin: var(--size-3) var(--size-4) 0 0;
	padding: 0;
}

/* --- Chip: Work type + Featured Work ---
 * One base for both consumers, no per-section duplication. Type, size
 * and colours come from the Featured Work chip, which is the reference
 * for the two-colour system. Border and padding tokens are the same ones
 * both rules already used, unchanged. */
.chip,
.featured__chip {
	display: inline-block;
	align-self: flex-start;
	margin: 0;
	font-family: var(--font-serif);
	font-size: var(--font-size-h5);
	line-height: var(--font-line-height-h5);
	color: var(--color-text);
	background-color: var(--wp--preset--color--highlight-celeste-soft);
	border: var(--size-border) solid var(--color-highlight-celeste);
	border-radius: var(--size-radius-pill);
	padding: var(--size-1) var(--size-3);
}

/* Chip parity — positional, the same criterion as the Featured Work
 * alternation: odd keeps the celeste base, even goes rosa. Featured
 * chips hang off `.featured__item`; Work type chips hang off `.card` in
 * the archive grid, so the card is the position. Any number of Works
 * from the admin alternates automatically. A single case study has no
 * preceding position, so its chips stay celeste. */
.featured__item:nth-child(even) .featured__chip,
.card:nth-child(even) .chip {
	background-color: var(--wp--preset--color--highlight);
	border-color: var(--color-accent-soft);
}

/* Mobile: the chips drop one step on the size scale. `--font-size-body-lg`
 * is the level immediately below `--font-size-h5`, with its own line-height,
 * so the glyph and its leading come from the same place. Padding, border and
 * both colours are untouched — only the type inside changes, which is what
 * makes the chip smaller without it becoming a different component.
 *
 * One rule covers all three places on purpose: `.chip` (Single work) and
 * `.featured__chip` (Home and the listing) already share the base rule
 * above, and a breakpoint that only caught one of them would leave the same
 * chip at two sizes. */
@media (max-width: 1023.98px) {
	.chip,
	.featured__chip {
		font-size: var(--font-size-body-lg);
		line-height: var(--font-line-height-body-lg);
	}
}

/* --- Featured work (Figma 180:683): media with overlapping text card.
 * Alternation is positional: odd items right-align the media with the
 * card overlapping on the left, even items mirror via nth-child. */
/* More air around Featured Work so the list is not cramped between the
 * heading and the logos slider below. */
.featured {
	padding-block: var(--size-9);
}

.featured__title {
	font-family: var(--font-serif);
	font-size: var(--font-size-h2);
	line-height: var(--font-line-height-h2);
	font-weight: var(--font-weight-h2);
	text-align: left;
	margin: 0 0 var(--size-7);
}

/* Margin only: the divider's colour is set once on `hr`, so the two rules
 * in the site cannot drift into two different rules. */
.featured__divider {
	margin: 0 0 var(--size-7);
}

.featured__list {
	display: flex;
	flex-direction: column;
	gap: var(--size-9);
}

/* Air between the stacked selected Works. This used to also carry a
 * border-top separator; it was removed per OK de Facu, which left the cases
 * reading as one continuous list. The padding stays, so the gap below comes
 * from `gap` on `.featured__list` and the padding is the air that opens
 * each case. */
.featured__item {
	padding-block: var(--size-5);
}

@media (min-width: 1024px) {
	.featured__list {
		/* 7.5rem inline a propósito (Sistema 01): era el único consumo
		 * de --size-10, medida de un solo uso fuera de la escala 1–9. */
		gap: 7.5rem;
	}

	.featured__item {
		padding-block: var(--size-6);
	}
}

.featured__link {
	position: relative;
	display: flex;
	align-items: center;
	width: 100%;
	text-decoration: none;
	color: inherit;
}

.featured__item:nth-child(odd) .featured__link {
	justify-content: flex-end;
}

.featured__item:nth-child(even) .featured__link {
	justify-content: flex-start;
}

.featured__media {
	margin: 0;
	width: 100%;
	max-width: var(--size-featured-media);
}

.featured__img {
	display: block;
	width: 100%;
	height: auto;
	aspect-ratio: 16 / 9;
	object-fit: cover;
	border-radius: var(--size-radius-xl);
}

/* Portrait work media follows the design system's 4:5 variant instead of
 * the 16:9 landscape one, so the tall creative isn't center-cropped. */
.featured__img--portrait {
	aspect-ratio: 4 / 5;
}

.featured__card {
	position: absolute;
	top: var(--size-featured-card-offset);
	width: 100%;
	max-width: var(--size-featured-card);
	display: flex;
	flex-direction: column;
	gap: var(--size-5);
	background-color: var(--color-paper);
	border-radius: var(--size-radius-md);
	padding: var(--size-6);
	/* La card de texto se superpone a la media, así que necesita sombra
	 * para separarse del fondo en vez de recortarse contra la imagen. */
	box-shadow: var(--shadow-lg);
}

.featured__item:nth-child(odd) .featured__card {
	left: var(--size-5);
}

.featured__item:nth-child(even) .featured__card {
	left: auto;
	right: var(--size-5);
}

/* Featured card vertical position (Home desktop only, per-item modifier
 * from the Card position field; top prints no modifier and stays on the
 * base rule above). Scoped to `.featured__list` so the archive never
 * sees it, and to desktop because on mobile the card is back in the
 * flow (`position: static` below) where top/center/bottom are
 * meaningless. Bottom mirrors the top offset from the lower edge.
 * Center uses the `translate` property rather than `transform` so it
 * composes with the reveal, which owns `transform`. 50%/-50% are
 * structural, not design values. Works on both orientations: each
 * measures against its own link box. */
@media (min-width: 1024px) {
	.featured__list .featured__card--bottom {
		top: auto;
		bottom: var(--size-featured-card-offset);
	}

	.featured__list .featured__card--center {
		top: 50%;
		translate: 0 -50%;
	}
}

/* Archive variant: the card lives in a grid cell spanning two of four
 * columns, which is narrower than the 1248px row Featured Work uses. The
 * desktop media/card measures below are tuned for that row, so here they
 * are relative to the cell: the media fills it, and the text card takes a
 * fixed share with enough left for the overlap to still read.
 *
 * The overlap is the point of the component, so it is preserved rather than
 * dropped — what changes is only the ratio. `max-width` still caps the
 * text card so a very wide cell does not stretch a paragraph past a
 * comfortable measure. */
@media (min-width: 1024px) {
	.grid .featured__media {
		max-width: 100%;
	}

	/* Bottom-anchored overlap. The card leaves the absolute layer and
	 * returns to the flow, then is pulled up by the overlap token, so
	 * `--size-overlap-panel` of it sits on the media and the rest hangs
	 * below. Same geometry as the mobile treatment above
	 * (`position: static`), so the two are one idea rather than two.
	 *
	 * The base rule positions the card from the top with a fixed offset,
	 * which is why portrait used to swallow the photo: the offset is the
	 * same on a 344px landscape and a 765px portrait, so on the tall one
	 * the card started near the top and ended hundreds of pixels above
	 * the bottom edge. Anchoring to the bottom edge makes the overlap
	 * identical in both orientations, because both measure the same
	 * distance up from the same place.
	 *
	 * In the flow it also stops being an absolute box, so the item grows
	 * to include the hanging part — no padding patch on the item.
	 */
	.grid .featured__link {
		flex-direction: column;
		align-items: stretch;
	}

	.grid .featured__card {
		position: static;
		width: 78%;
		max-width: 100%;
		margin-top: calc(-1 * var(--size-overlap-panel));
	}

	/* The horizontal alternation was expressed as `left`/`right` on the
	 * absolute card. Static ignores those, so the parity moves to the
	 * flex alignment of the link: the odd/even criterion and which side
	 * each one lands on are unchanged. */
	.grid .featured__item:nth-child(odd) .featured__link {
		align-items: flex-end;
	}

	.grid .featured__item:nth-child(even) .featured__link {
		align-items: flex-start;
	}
}

/* The card leaves a gap between itself and the image's lateral edge.
 *
 * Desktop, the media fills the cell and the parity alignment parks the card
 * flush against one side of it — odd against the right, even against the
 * left. A card pasted to the photo edge reads as part of the photo, so each
 * parity pulls in by one step on the side it is flush to. The margin goes on
 * that side, not the opposite one: the alignment already decided which edge
 * is flush, and this only breaks the contact.
 *
 * Below 1024px the card is `position: static` with `max-width: none`
 * (the mobile block above), so it spans the full cell and is flush on *both*
 * sides — there is no parity alignment to lean on, but the same margin
 * still opens the corresponding side, so the alternation reads the same at
 * every width.
 *
 * `--size-6` is 2rem, the step the prompt asked for. Margins rather than
 * `transform`: the offset is part of layout, so it cannot drift out of the
 * cell and cannot create horizontal overflow, and the card keeps its 78%
 * measure on desktop. */
.grid .featured__item:nth-child(odd) .featured__card {
	margin-right: var(--size-6);
}

.grid .featured__item:nth-child(even) .featured__card {
	margin-left: var(--size-6);
}

/* The brief is the one field that does not survive the DESKTOP cell: the full
 * excerpt runs to ~530 characters, which at 477px of card became a 442px
 * block — taller than the 344px media, covering 88% of it and reading as a
 * text panel with a photo behind it rather than a photo with a caption over
 * it. So the clamp belongs to the four-column grid and to nothing else.
 *
 * Mobile has the opposite geometry: one column, the card as wide as the
 * picture, and the whole brief reads without anyone losing the end of a
 * sentence behind an ellipsis. So the clamp is scoped to 1024px up rather
 * than cancelled below it — the rule is simply absent where it does not
 * apply, which is the same reason the desktop `width: 78%` never reaches
 * mobile.
 *
 * Home keeps the whole excerpt at every width (its card is 624px wide, so
 * the same text is two lines shorter and the card still sits within the
 * media). The clamp keeps the ellipsis on the text node, so nothing is
 * added to the markup and the reading order is unchanged; the full text is
 * always in the DOM for assistive tech either way. */
@media (min-width: 1024px) {
	.grid .featured__brief {
		display: -webkit-box;
		-webkit-box-orient: vertical;
		-webkit-line-clamp: 5;
		overflow: hidden;
	}
}

.featured__heading {
	margin: 0;
	font-family: var(--font-serif);
	font-size: var(--font-size-h3);
	line-height: var(--font-line-height-h3);
	font-weight: var(--font-weight-h2);
	color: var(--color-text);
}

.featured__brief {
	margin: 0;
	font-size: var(--font-size-body);
	line-height: var(--font-line-height-body);
	color: var(--color-text);
}

.featured__item--no-media .featured__card {
	position: static;
}

@media (max-width: 1023.98px) {
	.featured {
		padding-block: var(--size-7);
	}

	.featured__title {
		margin-bottom: var(--size-6);
	}

	.featured__divider {
		margin-bottom: var(--size-6);
	}

	.featured__list {
		gap: var(--size-6);
	}

	.featured__link {
		flex-direction: column;
		align-items: stretch;
	}
	.featured__media {
		max-width: none;
	}
	/* Mobile breaks the grid: the image gives up one step of width, the
	 * bigger margin lands on one side, and the card goes into that margin
	 * and over the picture — odd to the right, even to the left, the way
	 * the reference mock does it.
	 *
	 * The image is aligned with an auto inline margin rather than by
	 * sizing the figure: the figure stays 100% of the cell and the image
	 * takes `100% - inset` inside it, so `margin-inline-start: auto` is
	 * what pins it to one edge. That keeps the card's percentages working
	 * against the same box, which matters because the card is sized off
	 * the *image*, not the cell.
	 */
	.featured__img {
		width: calc(100% - var(--size-4));
	}

	/* Odd: image to the right, so the wide margin is on the left — and the
	 * card goes into that margin, overhanging the image by `--size-2` while
	 * still clearing the container edge by the other half of the inset.
	 * Even mirrors it. */
	.featured__item:nth-child(odd) .featured__img {
		margin-inline-start: auto;
	}

	.featured__item:nth-child(even) .featured__img {
		margin-inline-end: auto;
	}

	/* Height ceiling for the picture. Only a 4:5 portrait can outgrow a
	 * viewport — 1239px tall at 1023px wide — and it has to be capped on
	 * the *width* of the media box: capping the height of the image would
	 * break its proportion, and `object-fit: cover` would then crop it.
	 *
	 * The cap goes on the ITEM, which is the box the figure and the card
	 * both measure from. Capping only the figure was the first attempt and
	 * it was wrong: the card is a share of the *image*, so with the figure
	 * capped at 720px and the card still measuring against the cell, the
	 * card came out 110% as wide as the picture.
	 *
	 * The capped item also moves to its parity side, so the whole block
	 * stays offset instead of clinging to the start edge. On an uncapped
	 * item there is no free space for the auto margin to take, so the rule
	 * is inert there and only the portrait pays for it.
	 *
	 * `4 / 5` is the same ratio as `aspect-ratio` on
	 * `.featured__img--portrait` above; it has to be written out because
	 * CSS cannot read an `aspect-ratio` back as a value. */
	.featured__item--portrait {
		max-width: calc(var(--size-featured-media-max-height) * 4 / 5);
	}

	.featured__item:nth-child(odd).featured__item--portrait {
		margin-inline-start: auto;
	}

	.featured__item:nth-child(even).featured__item--portrait {
		margin-inline-end: auto;
	}

	/* The card keeps participating in layout — a negative `margin-top`,
	 * not a `transform` — so the hanging part pushes the next item down
	 * instead of sliding under it.
	 *
	 * Its width is the image's width, character for character: same
	 * `100% - inset` and nothing scaled. The two boxes are the same size
	 * and only their *position* differs, which is what makes the pair read
	 * as one composition instead of a caption that happens to be near a
	 * photo. `margin-inline-start: 0` is therefore literal: the card has
	 * no margin of its own, it is pushed to one edge by the parity below
	 * and that is the whole displacement.
	 *
	 * A definite `width` on a column flex item falls back to the
	 * flex-start edge, so the free space has to be handed to one side
	 * explicitly — that is what the `auto` in each parity rule is for. */
	.featured__card {
		position: static;
		max-width: none;
		padding: var(--size-5);
		width: calc(100% - var(--size-4));
		margin-inline-start: 0;
		margin-top: calc(-1 * var(--size-media-card-overlap));
	}

	/* Position modifiers are desktop-only: down here the card is back in
	 * the flow, so only `translate` would still move it — neutralised. */
	.featured__card--center {
		translate: none;
	}

	.featured__card--bottom {
		bottom: auto;
	}

	/* Odd: card to the left, image to the right. Even mirrors it. */
	.featured__item:nth-child(odd) .featured__card {
		margin-inline-end: auto;
	}

	.featured__item:nth-child(even) .featured__card {
		margin-inline-start: auto;
		margin-inline-end: 0;
	}

	/* Archive only (`.grid` is the Work archive container, never Home).
	 * The Work 02 offset is `--size-6` on one side only, chosen for the
	 * desktop grid where the card is 78% of a wide cell and the zigzag is
	 * the point. Here the side comes from the parity instead, so those
	 * margins are neutralised and the `margin-inline-end` from the pair
	 * above is what places each card.
	 *
	 * These need the full parity selector to out-specify the rules at the
	 * all-widths level, and they live inside the mobile block so the
	 * desktop zigzag is untouched. */
	.grid .featured__item:nth-child(odd) .featured__card,
	.grid .featured__item:nth-child(even) .featured__card {
		margin-inline: 0;
	}

	.grid .featured__item:nth-child(odd) .featured__card {
		margin-inline-end: auto;
	}

	.grid .featured__item:nth-child(even) .featured__card {
		margin-inline-start: auto;
		margin-inline-end: 0;
	}

	/* Rhythm: the `gap` becomes the single owner of the space between
	 * cases, and the item's own vertical padding goes to zero. Split
	 * across both, the two together gave 7rem between the hanging card
	 * and the next picture on Home; here the distance *is* the gap, so
	 * changing the rhythm means changing one value. */
	.featured__item {
		padding-block: 0;
	}

	/* The listing is a `.grid`, not a `.featured__list`, so it has its own
	 * `gap` to bring to the same 2rem — otherwise the two surfaces end up with
	 * different rhythms for the same component. */
	.grid {
		gap: var(--size-6);
	}
}

/* --- Single work --- */
.work__title {
	margin: 0 0 var(--size-3);
}

.work__meta {
	font-size: var(--font-size-label);
	color: var(--color-text-muted);
	margin: 0 0 var(--size-5);
}

.work__hero {
	margin: 0 0 var(--size-6);
}

/* Work media on the case study keeps the largest corner radius of the
 * system (xl, same as the Work media component, both orientations)
 * and adds the largest shadow. Both live on the img itself so the
 * shadow follows the rounded corners instead of the figure's box. */
.work__hero img {
	border-radius: var(--size-radius-xl);
	box-shadow: var(--shadow-lg);
}

.work__brief {
	font-size: var(--font-size-h5);
	line-height: var(--font-line-height-h5);
}

/* Mobile: the case-study brief steps down to the body copy size. H5 is a
 * fixed 20px level with no fluid minimum, so it stays 20/32 on every phone;
 * body (16/24) is the reading measure the rest of the site uses. Same
 * selector later in source wins, no `!important`. */
@media (max-width: 1023.98px) {
	.work__brief {
		font-size: var(--font-size-body);
		line-height: var(--font-line-height-body);
	}
}

.work__back {
	display: inline-block;
	margin-top: var(--size-6);
}

/* --- Expertise: horizontal track (native scroll, hidden scrollbar) --- */
.expertise__track {
	display: flex;
	gap: var(--size-5);
	overflow-x: auto;
	scrollbar-width: none;
	scroll-snap-type: x mandatory;
	/* Same formula as padding-inline, so a snapped card starts on the
	 * container margin instead of hugging the viewport edge. */
	scroll-padding-inline: max(var(--size-4), calc((100vw - var(--size-content)) / 2));
	padding-block: var(--size-2);
	/* First card starts at the content margin, last card ends with it. */
	padding-inline: max(var(--size-4), calc((100vw - var(--size-content)) / 2));
}

.expertise__track::-webkit-scrollbar {
	display: none;
}

.expertise__track:focus-visible {
	outline: var(--size-outline) solid var(--color-text);
	outline-offset: var(--size-1);
}

/* While the script tweens the row, snap gets out of the way: every
 * tween lands exactly on a snap point (destinations are quantised to
 * the card grid), so when snap comes back there is nothing left to
 * correct — the end-of-travel cut disappears. Native swipe keeps
 * full snap the whole time; only scripted travel lifts it. */
.expertise__track.is-animating {
	scroll-snap-type: none;
}

.expertise__track > .card {
	flex: 0 0 auto;
	scroll-snap-align: start;
	/* Desktop width overridden to 25rem under #expertise (Figma). */
	width: clamp(calc(var(--size-9) * 3), 30vw, calc(var(--size-9) * 4));
}

/* Mobile (< 1024px): snap, padding and scrollbar already live in the
 * base rule above (the row is native on every viewport now). What is
 * left here is touch behaviour: the slider script only calls `scrollBy`
 * on the track — it never writes heights or transforms. The vertical
 * gesture keeps working with a finger resting on the cards, because
 * `touch-action` is left at its default and only the *horizontal*
 * chaining is contained.
 *
 * `overscroll-behavior-x: contain` is the piece that stops a horizontal
 * swipe from chaining to the page. It is deliberately `-x` only: the
 * vertical axis keeps its default chaining, which is exactly what the
 * page scroll needs.
 */
@media (max-width: 1023.98px) {
	.expertise__track {
		overscroll-behavior-x: contain;
		align-items: stretch;
	}
}

/* --- Expertise: Figma fidelity (Home frame) ---
 * Section bg subtle, heading serif 56/84, divider, 400px cards
 * (radius 6) with number 14 ink, title serif 32 on pink highlight
 * (padding 8/4, radius 4), desc 18/24. Native horizontal row stays. */
#expertise {
	position: relative;
	z-index: var(--z-content);
	background-color: transparent;
	padding-block: var(--size-8);
	/* Slider microinteraction rungs (Sistema 01, acotados desde `:root`):
	 * la escala compartida no tiene nada en estas ventanas (fast
	 * overshoots hover, med overshoots travel). El script los lee por
	 * getComputedStyle sobre esta sección — nunca copias. */
	--motion-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
	--motion-slider-travel: 0.45s;
	--motion-slider-travel-max: 0.6s;
	--motion-slider-hover: 0.18s;
	--motion-slider-press: 0.1s;
	--motion-slider-fade: 0.2s;
}

/* Background layer (no markup): fades in over the 60vh before the
 * section starts, full exactly at its top. Content stays above it. */
#expertise::before {
	content: "";
	position: absolute;
	inset: 0;
	z-index: 0;
	background-color: var(--color-subtle);
	opacity: var(--exp-bg, 1);
	pointer-events: none;
}

#expertise > * {
	position: relative;
	z-index: 1;
}

#expertise .section__title {
	font-family: var(--font-serif);
	font-size: var(--font-size-h2);
	line-height: var(--font-line-height-h2);
	text-align: left;
	margin: 0;
}

/* Margin only, same as `.featured__divider` — the colour lives on `hr`. */
#expertise .divider {
	margin: var(--size-7) 0;
}

#expertise .expertise__track {
	gap: var(--size-8);
}

#expertise .card {
	border: 0;
	border-radius: var(--size-radius-sm);
	background-color: transparent;
	padding: var(--size-5);
}

#expertise .card:hover,
#expertise .card:focus-within {
	transform: none;
}

#expertise .card:hover .card__title,
#expertise .card:focus-within .card__title {
	color: var(--color-text);
}

#expertise .card__index {
	font-size: var(--font-size-label);
	line-height: var(--font-line-height-label);
	color: var(--color-text);
	margin: 0 0 var(--size-5);
}

#expertise .card__title {
	font-family: var(--font-serif);
	font-size: var(--font-size-h3);
	line-height: var(--font-line-height-h3);
	margin: 0 0 var(--size-5);
}

#expertise .card__title-text {
	background-color: var(--color-highlight);
	padding-block: var(--size-1);
	padding-inline: var(--size-2);
	border-radius: var(--size-radius-xs);
	box-decoration-break: clone;
	-webkit-box-decoration-break: clone;
}

#expertise .card__brief {
	font-size: var(--font-size-body-lg);
	line-height: var(--font-line-height-body);
	margin: 0;
}

#expertise .expertise__track > .card {
	width: var(--size-expertise-card);
}

/* --- Expertise slider controls (Figma 319:1652 atom + 319:1689 pair;
 * usage 244:3299, superseding kit 319:1426) ---
 * Pair 88×32: two 32px buttons (size-6) with a 24px gap (size-5),
 * centred under the row with a 48px margin (size-7, the section gap in
 * Figma 244:3299 — the usage instance 318:1008 sits at x=676, i.e.
 * centred, 48px below the grid). Natural = bare accent-primary arrow;
 * hover = transparent interior with an ink ring and ink arrow (no disc
 * fill — verified against the Light Hover SVG: circle has stroke only);
 * press = accent-primary outline disc with accent-primary arrow (exact
 * per Figma now — the old kit's white-on-white press is gone, and so
 * is the `:active` deviation that worked around it). The controls render
 * only once the slider script confirms the row overflows
 * (`.is-slider`): without JS there are no dead buttons, just the
 * native swipe row.
 *
 * Figma gaps, documented (§9): `focus-visible` and `disabled` exist
 * in neither the old kit nor the new version, so they follow the
 * system — the menu's outline pattern for focus, and a 0.4 opacity
 * for disabled (no disabled precedent anywhere in the theme; a
 * functional state value, not a design token). Contrast flag: the new
 * natural arrow measures ~2.06:1 on paper (~1.87 on subtle) against
 * 6.55:1 before — implemented as Figma dictates (existing
 * `--color-accent-primary` token), pending design confirmation. No
 * dark-theme variant: the project has no dark mode. No mobile variant
 * either: 32px holds on every viewport (≥ 24px WCAG 2.2 minimum). */
#expertise .expertise__controls {
	display: none;
	align-items: center;
	justify-content: center;
	gap: var(--size-5);
	margin-top: var(--size-7);
}

#expertise.is-slider .expertise__controls {
	display: flex;
}

#expertise .expertise__btn {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: var(--size-6);
	height: var(--size-6);
	padding: 0;
	border: var(--size-border) solid transparent;
	border-radius: var(--size-radius-full);
	background-color: transparent;
	color: var(--color-accent-primary);
	cursor: pointer;
	/* Colour swap like `.btn` plus a subtle grow: transform and opacity
	 * are the only animated movement (compositor-side); colours ride
	 * along. Hover runs on the slider rung; press answers faster via
	 * the `:active` override below; disabled fades opacity on its own
	 * rung. Colour transitions keep no `prefers-reduced-motion`
	 * override (same reason as `.btn`); the transform gets one below
	 * because, unlike colour, it moves. */
	transition:
		background-color var(--motion-slider-hover) var(--motion-ease-out),
		color var(--motion-slider-hover) var(--motion-ease-out),
		border-color var(--motion-slider-hover) var(--motion-ease-out),
		transform var(--motion-slider-hover) var(--motion-ease-out),
		opacity var(--motion-slider-fade) var(--motion-ease-out);
}

#expertise .expertise__btn:hover {
	background-color: transparent;
	border-color: var(--color-text);
	color: var(--color-text);
	transform: scale(1.06);
}

#expertise .expertise__btn:active {
	background-color: transparent;
	border-color: var(--color-accent-primary);
	color: var(--color-accent-primary);
	transform: scale(0.97);
	/* Press answers on its own rung (80–120ms); release still eases
	 * back on hover timing. Same property list, only faster. */
	transition-duration: var(--motion-slider-press);
}

/* Reduced motion kills the movement, not the state: the grow/press
 * scales go flat while colour and opacity transitions (state changes
 * the prompt explicitly keeps) survive. */
@media (prefers-reduced-motion: reduce) {
	#expertise .expertise__btn:hover,
	#expertise .expertise__btn:active {
		transform: none;
	}
}

#expertise .expertise__btn:disabled {
	opacity: 0.4;
	cursor: default;
}

/* One authoritative arrow asset per direction (Figma 319:1652,
 * "Flecha original" chevron pair, Natural geometry). Each SVG keeps
 * its 32×32 canvas so the arrow sits exactly where Figma drew it;
 * the icon fills the button's content box (30px under border-box
 * with the always-reserved 1px border), so states never shift it.
 * No mirroring: the pair's left/right are distinct drawings (not an
 * exact `scaleX(-1)` of each other). States ride on `currentColor`
 * plus the ring, per the component (bare / ink / outline). */
#expertise .expertise__btn-icon {
	display: block;
	width: 100%;
	height: auto;
}

/* Mobile (≤ 1023.98px): la sección grande baja un escalón (64 → 48),
 * igual que .featured; el divider acompaña (48 → 32). Este bloque va
 * después de la regla base con ID para ganarle por orden a igual
 * especificidad. */
@media (max-width: 1023.98px) {
	#expertise {
		padding-block: var(--size-7);
	}

	#expertise .divider {
		margin: var(--size-6) 0;
	}

	#expertise .expertise__track {
		gap: var(--size-3);
	}

	/* Controls follow the same step down as the divider above
	 * (48 → 32): they are internal rhythm, not section padding. */
	#expertise .expertise__controls {
		margin-top: var(--size-6);
	}

	/* El stack vertical mobile ya existía arriba, pero la regla base
	 * (#expertise .expertise__track > .card, con ID) lo anulaba y la
	 * card quedaba en 400px fijos: 26–56px de scroll-x real en
	 * viewports angostos. Repetido acá, después de la base.
	 *
	 * Ahora el track vuelve a ser una fila horizontal, así que la card
	 * necesita un ancho propio otra vez — pero uno relativo a la caja
	 * del track, no los 400px fijos. Al 78% deja asomar la siguiente,
	 * que es lo que comunica que la fila se puede recorrer: a ancho
	 * completo no hay nada fuera del marco que sugiera el gesto. Es el
	 * mismo 78% que ya usa la card del listado de Work. */
	#expertise .expertise__track > .card {
		width: 78%;
	}

	/* Mobile density: compact padding (8px) and label-size copy (14px
	 * with its own line-height) so the row reads without scrolling the
	 * page. Title and index keep their scale; only the running text
	 * steps down. */
	#expertise .card {
		padding: var(--size-2);
	}

	#expertise .card__brief {
		font-size: var(--font-size-label);
		line-height: var(--font-line-height-label);
	}
}

small {
	font-size: var(--font-size-label);
	line-height: var(--font-line-height-label);
}

/* Contact shares the fixed backdrop now, so its container gets the same
 * positioned layer as Home (`.hero > .container`) and About
 * (`#about > .container`): without it the background would paint over the
 * text and the form. Same token, same pattern, no new values. */
#contact > .container {
	position: relative;
	z-index: var(--z-content);
}

/* Contact lead: same tier as the hero and about leads (serif-free 20/32
 * muted paragraph under the title). No max-width of its own: the `.reading`
 * column already constrains the measure. */
.contact__lead {
	font-size: var(--font-size-h5);
	line-height: var(--font-line-height-h5);
	color: var(--color-text-muted);
	margin: 0 0 var(--size-5);
}

/* --- Forms --- */
.form {
	max-width: var(--size-reading);
}

.form__field {
	margin: 0 0 var(--size-4);
}

.form__field label {
	display: block;
	font-size: var(--font-size-label);
	margin-bottom: var(--size-2);
}

.form__field input,
.form__field textarea {
	width: 100%;
	font: inherit;
	padding: var(--size-2) var(--size-3);
	border: var(--size-border) solid var(--color-border);
	border-radius: var(--size-radius-sm);
	background-color: var(--color-surface);
	color: var(--color-text);
}

/* The submit is a `.btn` (Figma 304:883), so the old pill style is gone.
 * Only what a `<button>` needs on top of an `<a class="btn">` stays: the
 * UA font and the tap highlight, neither of which `.btn` sets for us. */
.form__actions button {
	font: var(--font-size-label) / var(--font-line-height-label) var(--font-sans);
	-webkit-appearance: none;
	appearance: none;
}

.form__honeypot {
	position: absolute;
	left: -9999px;
}

.form__errors {
	color: var(--color-accent);
}

.form__success {
	font-size: var(--font-size-h5);
}

/* --- About page --- */
/* The fixed backdrop (z-index 0) would paint over static content: like
 * `.hero > .container`, the About container gets its own positioned
 * layer above it, so the photo and the text always read over the
 * background. Same token, same pattern, no new values. */
#about > .container {
	position: relative;
	z-index: var(--z-content);
}

.about {
	max-width: var(--size-reading);
	margin-inline: auto;
}

.about__title {
	margin: 0 0 var(--size-5);
}

.about__hero {
	margin: 0 0 var(--size-6);
}

/* --- About vector portrait: embedded SVG with pointer parallax ---
 * The wrap reports --mx/--my in [-1, 1] (see vs-about-svg.js). Only
 * transform/opacity move, so the layers stay composited and the paint
 * order of the SVG never changes: the portrait (no transform, ever)
 * keeps every overlap exactly where it was drawn.
 *
 * Distances are structural caps (8px/4px/5deg), not design tokens —
 * same category as the motion distances. The badge reveal keeps the
 * shared ease-out token. */
.about__svg-wrap {
	--mx: 0;
	--my: 0;
}

/* Visible (not the SVG default hidden): the parallax layers travel
 * outside the viewBox and must not get cut mid-motion — same call as
 * the hero backdrop (`.hero__bg svg`). Transforms never affect layout
 * or scroll, so the bleed cannot page-scroll. */
.about__svg {
	display: block;
	width: 80%;
	height: auto;
	margin-inline: auto;
	overflow: visible;
}

/* Scroll adds a second axis through --sy (0 at top, 1 after one
 * viewport): the bg drifts up while the dot sinks, peeking out below.
 * It rides the `translate` property so it composes with the pointer
 * `transform` instead of fighting it. */
.about__svg-bg {
	translate: 0 calc(var(--sy, 0) * -24px);
	transform: translate3d(calc(var(--mx) * -6px), calc(var(--my) * -6px), 0);
}

.about__svg-dot {
	translate: 0 calc(var(--sy, 0) * 16px);
}

.about__svg-comma {
	transform:
		translate3d(calc(var(--mx) * 4px), calc(var(--my) * 4px), 0)
		rotate(calc(var(--mx) * 4deg));
	transform-box: fill-box;
	transform-origin: center;
}

.about__svg-portrait:focus-visible {
	/* 2px = --size-outline exacto (0.125rem); el offset 3px queda literal:
	 * la escala no tiene paso 3px y 4px cambiaría el diseño (pendiente). */
	outline: var(--size-outline) solid var(--color-accent-secondary);
	outline-offset: 3px;
}

/* Badge reveal in two steps: the white box fades first (slow), then
 * the letters stagger in reading order with per-letter delays that
 * the script writes reversed (the source lists them right to left).
 * Without JS every letter shares delay 0 and they arrive together. */
.about__svg-badge-bg {
	opacity: 0;
	transform: translateY(8px);
	transition:
		opacity 0.55s var(--motion-ease-out),
		transform 0.55s var(--motion-ease-out);
}

.about__svg-badge-text path {
	opacity: 0;
	transform: translateY(5px);
	transition:
		opacity 0.4s var(--motion-ease-out),
		transform 0.4s var(--motion-ease-out);
	transition-delay: var(--d, 0s);
}

/* The badge answers to the portrait (hover or keyboard focus), not to
 * the whole wrap: it appears over the photo. Hovering the badge itself
 * keeps it visible so it never blinks out under the cursor. */
.about__svg-portrait:hover ~ .about__svg-badge .about__svg-badge-bg,
.about__svg-portrait:focus ~ .about__svg-badge .about__svg-badge-bg,
.about__svg-badge:hover .about__svg-badge-bg {
	opacity: 1;
	transform: none;
}

.about__svg-portrait:hover ~ .about__svg-badge .about__svg-badge-text path,
.about__svg-portrait:focus ~ .about__svg-badge .about__svg-badge-text path,
.about__svg-badge:hover .about__svg-badge-text path {
	opacity: 1;
	transform: none;
}

@media (prefers-reduced-motion: reduce) {
	.about__svg-bg,
	.about__svg-comma,
	.about__svg-dot {
		transform: none;
		translate: none;
	}

	.about__svg-badge-bg,
	.about__svg-badge-text path {
		opacity: 1;
		transform: none;
		transition: none;
	}
}

.about__content p {
	margin: 0 0 var(--size-4);
}

.about__content .about-lead {
	font-size: var(--font-size-h5);
	line-height: var(--font-line-height-h5);
}

/* "Tools & platforms" was an <h3>; it is an <h2> now, so the page heading
 * has no skipped level between it and this one. The browser's default
 * heading margins are per tag — 1em for h3, 0.83em for h2 — so the tag
 * change alone would have pulled the gap 3.4px tighter above and below and
 * shortened the page by 7px. `1em` puts back exactly what the h3 default
 * gave, and stays tied to the block's own font size rather than to a fixed
 * length, so it follows the type scale if the size ever changes.
 *
 * `.about-lead` alone cannot carry this: the class is also on the
 * paragraphs around it, and they keep their 16px. */
.about__content .wp-block-heading.about-lead {
	margin-block: 1em;
}

/* --- Motion base: declarative reveal (JS adds .js-motion + .is-visible).
 * Default state is visible (progressive enhancement). Direction comes
 * from --reveal-dir, set by positional parity in each layout. */
[data-reveal],
[data-reveal-x] {
	--reveal-delay: 0s;
}

html.js-motion [data-reveal] {
	opacity: 0;
	transform: translate3d(0, var(--motion-reveal-distance), 0);
}

html.js-motion [data-reveal-x] {
	opacity: 0;
	transform: translate3d(calc(var(--reveal-dir, 1) * var(--motion-reveal-distance)), 0, 0);
}

html.js-motion [data-reveal-x]:not(.is-visible) {
	will-change: transform, opacity;
}

html.js-motion [data-reveal].is-visible,
html.js-motion [data-reveal-x].is-visible {
	opacity: 1;
	transform: none;
	transition:
		opacity var(--motion-duration-med) var(--motion-ease-out),
		transform var(--motion-duration-med) var(--motion-ease-out);
	transition-delay: var(--reveal-delay);
}

/* Directional parity for zigzag layouts (same positional criterion). */
.featured__item:nth-child(odd) {
	--reveal-dir: -1;
}

.featured__item:nth-child(even) {
	--reveal-dir: 1;
}

/* Text enters after the media. */
.featured__item .featured__card[data-reveal-x] {
	--reveal-delay: var(--motion-reveal-stagger);
}

/* Dividers grow left to right on scroll. */
html.js-motion .divider[data-reveal],
html.js-motion .featured__divider[data-reveal] {
	opacity: 1;
	transform: scaleX(0);
	transform-origin: left center;
}

/* Dividers grow left to right on scroll. `slow` rather than `med` because
 * the divider is a 1px rule travelling the page width: at 0.7s the draw
 * read as a wipe that had already finished before the eye tracked it. Same
 * ease-out as every other reveal — the draw is slower, not shaped
 * differently. */
html.js-motion .divider[data-reveal].is-visible,
html.js-motion .featured__divider[data-reveal].is-visible {
	transform: scaleX(1);
	transition: transform var(--motion-duration-slow) var(--motion-ease-out);
}

/* Lateral reveals become vertical on stacked layouts: no page h-scroll. */
@media (max-width: 1023.98px) {
	html.js-motion [data-reveal-x] {
		transform: translate3d(0, var(--motion-reveal-distance), 0);
	}
}

/* --- Display heading reveal: per-character cascade with a line mask.
 * Modelled on pactmedia.org, reimplemented without a library: the JS
 * splits the heading into masked lines and inline-block chars, and the
 * animation is a single CSS transition per char (no tween per element).
 * Reference values: 0.7s, 0.04s stagger, 10deg tilt, chars start 200%
 * below their line. Tilts, distances and per-char delays have no tokens
 * (same exception as the motion distances noted on .card). */
[data-reveal-chars] {
	--title-tilt: 10deg;
	--title-rise: 200%;
	--title-stagger: 0.04s;
	/* Cascade stops growing past this many chars, so a long heading
	 * cannot stretch the whole entrance. */
	--title-stagger-cap: 100;
}

/* The split only exists when JS built it, and the hidden state only
 * while the IntersectionObserver has not fired: without JS, or if the
 * split bailed on inline markup, the heading renders normally. */
html.js-motion [data-reveal-chars].is-split:not(.is-visible) .title__char {
	transform: translate3d(0, var(--title-rise), 0) rotate(var(--title-tilt));
}

html.js-motion [data-reveal-chars].is-split.is-visible .title__char {
	transform: none;
	transition: transform var(--motion-duration-med) var(--motion-ease-out);
	transition-delay: calc(var(--title-stagger) * min(var(--title-index, 0), var(--title-stagger-cap)));
}

/* Mask: each line clips its own rising chars. `inline-block` on the
 * chars is required, transforms don't apply to plain inline boxes.
 * The clip edge gets 0.08em of breathing room per side: round-letter
 * overshoot and descender tips render complete at rest, and more of
 * the 200% rise stays visible, so the entrance reads as progressive
 * instead of a jump. overflow-clip-margin extends the clip without
 * touching layout, so the line keeps its exact size; a 200% start
 * still clears the extended edge by ~0.9em, so the initial state stays
 * fully hidden. Where clip-margin is unsupported the `hidden` fallback
 * keeps the mask as tight as today — no regression. Motion distances
 * have no tokens (see the cascade note above). */
[data-reveal-chars] .title__split {
	display: block;
}

/* Words are atomic: line breaks happen only at the real spaces between
 * them, never between the char spans inside. The JS already wraps every
 * word in `.title__word`, but nothing styled it, so the words stayed
 * plain inline boxes and the browser could break between the
 * inline-block chars mid-word ("G / lobal" on narrow screens — worse
 * under mobile `text-wrap: balance`, which redistributes exactly those
 * per-char break opportunities). `inline-block` makes each word one
 * unit that moves whole to the next line; `max-width: 100%` lets a
 * single word wider than the line wrap between chars instead of
 * overflowing the page (the line mask's `overflow: clip` is the
 * backstop). Structural, no tokens — same category as the `width: 100%`
 * used across the theme. Spaces stay real text nodes (see
 * vs-motion-base.js), never margins. */
[data-reveal-chars] .title__word {
	display: inline-block;
	max-width: 100%;
}

[data-reveal-chars] .title__line {
	display: block;
	overflow: hidden;
	overflow: clip;
	overflow-clip-margin: 0.08em;
	backface-visibility: hidden;
}

[data-reveal-chars] .title__char {
	display: inline-block;
	/* Never promote the chars: a long heading would stack one composited
	 * layer per char for the length of the cascade. */
	will-change: auto;
}

/* Mobile: same effect, tighter cascade. 0.04s x 16 chars is 0.64s of
 * stagger alone, which is too slow on a phone. */
@media (max-width: 1023.98px) {
	[data-reveal-chars] {
		--title-stagger: 0.025s;
		--title-stagger-cap: 8;
	}
}

/* The lateral travel must never scroll the page. */
.featured {
	/* Home-only measures (Sistema 01, acotados desde `:root`): the 30rem
	 * text card (narrower than the 39rem base so the panel stops eating
	 * the image, Zendesk reference), the 72px overlap it keeps over the
	 * picture, and the band the media derives from both
	 * (`--size-5` + card − overlap). */
	--size-featured-card-compact: 30rem;
	--size-featured-card-overlap: 4.5rem;
	--size-featured-card-band: calc(var(--size-5) + var(--size-featured-card-compact) - var(--size-featured-card-overlap));
	/* Imágenes 01 · Prompt 01 (trial `is-featured-natural`): hard cap for
	 * the natural image, 50rem = 800px. Named by size (a `--size-*`
	 * token, no loose value in the rules): no uploaded Featured image
	 * renders wider than this, and none renders wider than its own
	 * intrinsic width either (the `width: auto` + `max-width` pair in the
	 * flag block). Scoped here like the other featured measures so the
	 * archive (`.grid`) can never read it. */
	--size-featured-natural-max: 50rem;
	overflow-x: clip;
}

/* --- Rotating circular text (decorative, exported from Figma) ---
 * Fixed viewport layer (single node): fixed never adds page scroll,
 * so no clipping wrapper is needed. transform: none on ancestors. */
.circle-text {
	position: fixed;
	top: -10rem;
	right: -20rem;
	z-index: 0;
	pointer-events: auto;
	margin: 0;
	width: clamp(20rem, 45vw, 43.75rem);
	aspect-ratio: 1 / 1;
	transition:
		opacity var(--motion-duration-med) var(--motion-ease-out),
		visibility 0s;
}

.circle-text__img {
	display: block;
	width: 100%;
	height: auto;
	will-change: transform;
}

/* Mobile (≤ 1023.98px): el círculo nunca toma input aquí — el hover
 * pause es una afordancia de desktop que no existe en touch, y la
 * medición previa (0px de solape a 390/425, un filo de ~26px a 768) no
 * tapa contenido relevante. Los taps pasan al contenido de abajo.
 * Desktop conserva pointer-events: auto (ver .circle-text arriba) y su
 * hover pause intacto.
 *
 * El diámetro pasa de `--size-grid-sm` (16rem, fijo) a uno que sigue al
 * viewport: `min(100vw, 43.75rem)`, o sea el tope que ya usa el clamp de
 * desktop. El tamaño fijo era la razón por la que el sello no se leía —
 * a 256px el texto del anillo queda en around 11px de altura, y el
 * `right` anterior lo dejaba además bien adentro en vez de sangrando.
 * Ahora el sello crece con la pantalla: 390px a 390 de viewport, y
 * topado en 700px desde 768 en adelante, que es el mismo máximo de
 * desktop.
 *
 * La sangría se deriva, no se tunea: con el centro 0.1 del diámetro fuera
 * del borde derecho, el `right` tiene que ser -0.6 del diámetro
 * (-0.1 del centro, -0.5 del radio). Medido en los tres anchos: 40.0%
 * del diámetro dentro del viewport y el centro 0.1D fuera, exacto. El
 * `top` no se toca, así que la posición vertical es la de siempre. */
@media (max-width: 1023.98px) {
	.circle-text {
		pointer-events: none;
		width: min(100vw, 43.75rem);
		right: calc(-0.6 * min(100vw, 43.75rem));
		top: calc(var(--size-8) * -1);
	}
}

/* Brands and following sections cover the bg naturally */
.brands {
	position: relative;
	z-index: var(--z-content);
	/* The track is `width: max-content` with the logo set twice, and the
	 * viewport above is deliberately `overflow: visible`, so the row
	 * escaped the section: 6173px of box inside a 1280px viewport, which
	 * pushed the document's scrollWidth to 3087px. A `position: fixed`
	 * layer (`.hero__bg`) is sized by the viewport, not the scrollWidth, so
	 * the hero backdrop stopped covering the page and the far right read
	 * as a cut.
	 *
	 * Clipping on the SECTION, not on `.brands__viewport`: the viewport
	 * keeps the visible overflow it was asked for, and the escape is
	 * contained one level up. `clip` rather than `hidden` so the section
	 * does not become a scroll container (which would make it a sticky
	 * containment edge for anything inside) and so no programmatic
	 * scrolling is implied. Same criterion already used by `.featured`. */
	overflow-x: clip;
}

@media (prefers-reduced-motion: reduce) {
	.circle-text__img {
		transform: none !important;
	}
	.hero__bg,
	.hero__bg-piece {
		animation: none !important;
		visibility: visible !important;
		opacity: 1 !important;
		translate: none !important;
	}
}

/* --- Featured Work: compact card and wide frame (Cierre 01) ---
 * Client-approved proposal, merged to base: the text card is the 30rem
 * panel with label chip and h4 heading, and the frame is 90% of the
 * viewport from 1280px up.
 *
 * Invariant: the card always sits over the image, because the image is
 * always `--size-featured-card-band` narrower than the row — the 72px
 * overlap depends on the band, never on the row width. The media rule is
 * mandatory: without it the card and the image drift apart with no error.
 * Anchored to `.featured__list` (Home only): the archive renders the same
 * classes inside `.grid` and keeps its own measures — except the chip,
 * which is global by client decision (Estandarización de Chips): every
 * chip on every surface renders the 14px reference, winning over the base
 * rule and the mobile step by source order. Card/heading/media below are
 * (0,2,0), beating the base rules and the `.h3` role class with no
 * `!important`. */
@media (min-width: 1024px) {
	.featured__list .featured__card {
		max-width: var(--size-featured-card-compact);
	}
}

.featured__list .featured__card {
	padding: var(--size-4);
	gap: var(--size-3);
}

.featured__list .featured__heading {
	font-size: var(--font-size-h4);
	line-height: var(--font-line-height-h4);
}

.featured__chip,
.chip {
	font-size: var(--font-size-label);
	line-height: var(--font-line-height-label);
	padding: var(--size-1) var(--size-2);
}

@media (min-width: 1280px) {
	.container {
		max-width: var(--size-row-wide);
	}

	.featured__list .featured__media {
		max-width: calc(var(--size-row-wide) - 2 * var(--size-4) - var(--size-featured-card-band));
	}
}

/* --- Featured Work: natural image trial (Imágenes 01 · Prompt 01) ---
 * Trial behind `is-featured-natural` (see inc/feature-flags.php and
 * docs/feature-flags.md). Nothing below edits a base rule: every
 * declaration lives under the flag selector, so with the constant in
 * `false` the Home renders byte-identical to today (rollback = one
 * constant; merge = delete the constant, its map line, this block and
 * `--size-featured-natural-max`).
 *
 * What changes with the flag on (Home only: every selector is anchored
 * to `.featured__list`, which the Work archive never renders — its
 * container is `.grid`, so `--archive` is unreachable by construction):
 * the image keeps the exact aspect ratio it was uploaded with (the
 * `width`/`height` WordPress already prints on the `<img>`), never
 * renders wider than its intrinsic width and never wider than
 * `--size-featured-natural-max` (50rem = 800px), and shrinks when its
 * column is narrower. Narrower-than-column images sit against the outer
 * grid edge by zigzag parity (odd right, even left), reusing the same
 * `:nth-child` criterion as the motion reveals — no JS, no content
 * field. The text card stays anchored to the IMAGE, not the column: the
 * link shrinks to the image (`fit-content`) and the card keeps its
 * constant stand-off from the image edge (`compact − 2rem` = 28rem, for
 * 2rem of overlap), plus today's top offset and vertical modifiers,
 * which are deliberately untouched.
 * Specificity (0,3,1)+ beats the base rules and the `.h3` role class
 * with no `!important`. No `max-height` anywhere in this block (per
 * requirement; see the tall-format risk in docs/feature-flags.md).
 * Motion: layout only, reveals (`data-reveal-x`, stagger, reduced-motion
 * kill-switch) untouched. `srcset`/`sizes`, `width`/`height`, lazy
 * loading and `alt` are markup and stay intact. */
/* The inline `style="aspect-ratio:{w}/{h}"` set by the PHP template
 * (vsoncini_media_aspect_style) carries the original upload's
 * proportion, overriding the base 16/9 / 4/5 class rules. This block
 * supplies `object-fit: fill` + `width: auto` so the browser renders
 * the image at that ratio without cropping — no attr() needed.
 * The --portrait variant rule no longer needs its own aspect-ratio
 * declaration: the inline style already handles it via PHP, and
 * without the old attr() fallback there is no generic rule left to
 * override, so the cascade from the base `.featured__img` rule
 * (16/9) is the fallback — which the inline style wins over anyway. */
body.is-featured-natural .featured__list .featured__img {
	height: auto;
	width: auto;
	object-fit: fill;
}

@media (min-width: 1024px) {
	/* The link stops being the full row and becomes the image box: the
	 * card is absolute against it, so against the image. `fit-content`
	 * sizes to the figure (the card is out of flow and contributes
	 * nothing); `max-width: 100%` keeps it inside the item. */
	body.is-featured-natural .featured__list .featured__link {
		width: fit-content;
		max-width: 100%;
	}

	/* Outer-edge parking by the same odd/even criterion as the reveals:
	 * odd images dock right, even images dock left. */
	body.is-featured-natural .featured__list .featured__item:nth-child(odd) .featured__link {
		margin-inline-start: auto;
		margin-inline-end: 0;
	}

	body.is-featured-natural .featured__list .featured__item:nth-child(even) .featured__link {
		margin-inline-start: 0;
		margin-inline-end: auto;
	}

	body.is-featured-natural .featured__list .featured__img,
	body.is-featured-natural .featured__list .featured__img--portrait {
		max-width: min(100%, var(--size-featured-natural-max));
	}

	/* Card keeps its 30rem measure even when the image box is narrower
	 * than that (small uploads): `width: 100%` would shrink it with the
	 * link and detach the overlap. The lateral stand-off is constant —
	 * `compact − 2rem` outside the image edge — which is exactly 2rem
	 * of overlap at any image width (while the image stays ≥32px wide,
	 * true for every case in §7). `--size-6` is the 2rem step, so no new
	 * token. Top/center/bottom untouched. */
	body.is-featured-natural .featured__list .featured__card {
		width: var(--size-featured-card-compact);
		max-width: var(--size-featured-card-compact);
	}

	body.is-featured-natural .featured__list .featured__item:nth-child(odd) .featured__card {
		left: calc(-1 * (var(--size-featured-card-compact) - var(--size-6)));
		right: auto;
	}

	body.is-featured-natural .featured__list .featured__item:nth-child(even) .featured__card {
		left: auto;
		right: calc(-1 * (var(--size-featured-card-compact) - var(--size-6)));
	}
}

/* One archive column off the image tramo (decisión aprobada: una, no
 * dos — two columns leave 140–316px against a 480px card and break the
 * component; see docs/feature-flags.md). 1024–1279px still uses the
 * fixed 50rem media, so the column is cut from it against `100vw`
 * (scrollbar error ≈4px, negligible); from 1280px up the media derives
 * from the row, so the column is cut from the same derivation. Tokens
 * only: every length is an existing `--size-*` or the new cap. */
@media (min-width: 1024px) and (max-width: 1279.98px) {
	body.is-featured-natural .featured__list .featured__media {
		width: auto;
		max-width: calc(var(--size-featured-media) - (100vw - 2 * var(--size-4) - 3 * var(--size-5)) / 4);
	}
}

@media (min-width: 1280px) {
	body.is-featured-natural .featured__list .featured__media {
		width: auto;
		max-width: calc(var(--size-row-wide) - 2 * var(--size-4) - var(--size-featured-card-band) - (var(--size-row-wide) - 2 * var(--size-4) - 3 * var(--size-5)) / 4);
	}
}

@media (max-width: 1023.98px) {
	/* Mobile keeps today's scheme (inset image, parity auto-margins,
	 * static card with negative-margin overlap): the only change is the
	 * image no longer upscales past its intrinsic width or the 800px
	 * cap. Figure/link/card untouched, so widths and overlap match. */
	body.is-featured-natural .featured__list .featured__img,
	body.is-featured-natural .featured__list .featured__img--portrait {
		max-width: min(calc(100% - var(--size-4)), var(--size-featured-natural-max));
	}
}

@media (prefers-reduced-motion: reduce) {
	html {
		scroll-behavior: auto;
	}
	html.js-motion [data-reveal],
	html.js-motion [data-reveal-x],
	html.js-motion [data-reveal-chars],
	html.js-motion [data-reveal-chars] .title__char,
	html.js-motion .divider[data-reveal],
	html.js-motion .featured__divider[data-reveal] {
		opacity: 1;
		transform: none;
		transition: none;
		transition-delay: 0s;
	}
}
