/* ===================================================================
   Gathercole Theme — Stylesheet
   =================================================================== */

/* ---------- Tokens ---------- */
:root {
	/* Light mode (default/base) — off-white background, off-black text */
	--color-bg: #F7F6F3;
	--color-bg-raised: #FFFFFF;
	--color-text: #131313;
	--color-text-muted: #6B6B6E;
	--color-border: rgba(19, 19, 19, 0.10);
	--color-accent: #0265FD;

	--glass-bg: rgba(255, 255, 255, 0.55);
	--glass-border: rgba(19, 19, 19, 0.08);
	--glass-blur: 18px;
	--shadow-color: rgba(19, 19, 19, 0.12);
	--media-bg: #EAE9E5;
	--media-bg-2: #DEDDD8;
	--color-text-subtle-bg: rgba(19, 19, 19, 0.05);

	--font-display: -apple-system, "SF Pro Display", "Segoe UI", "Inter", system-ui, sans-serif;
	--font-body: -apple-system, "SF Pro Text", "Segoe UI", "Inter", system-ui, sans-serif;

	--content-width: 720px;
	--wide-width: 1100px;

	--space-section: clamp(2.5rem, 6vw, 4.5rem);
	--nav-height: 68px;
	--side-rail-width: 56px;

	--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);

	/* Typographic leading (line-height) scale. Display/heading text reads
	   best tight (1.1–1.3) so multi-line headings read as one compact
	   block; body copy needs the extra room (1.5+) for comfortable
	   paragraph reading (WCAG 1.4.12 recommends ≥1.5 for body text).
	   Applied explicitly below wherever a heading-level element would
	   otherwise silently inherit body's looser 1.6 line-height. */
	--leading-tight: 1.15;  /* short, usually single-line display headings */
	--leading-snug: 1.3;    /* headings/titles that may wrap to 2+ lines */
	--leading-normal: 1.6;  /* body copy / long-form paragraphs */
}

/* ---------- Optional typefaces (Customizer: Typography) ----------
   Self-hosted rather than linked from Google Fonts' CDN, so choosing
   either option doesn't add a third-party request or depend on an
   external host staying up. Both files are variable fonts — one file
   per style (normal/italic) covers the whole weight axis, so a single
   `font-weight: <min> <max>` range declaration is enough for every
   weight the site actually uses (400–700), rather than needing a
   separate @font-face block per weight.

   Declaring these unconditionally is harmless even when nobody has
   selected them: a browser only ever fetches a font file the first
   time it needs to paint text that resolves to that family, so unless
   the Customizer's inline override below actually points --font-display
   or --font-body at one of these, neither file is ever requested. See
   gathercole_typography_inline_css() in inc/customizer.php for how a
   chosen font gets applied. */
@font-face {
	font-family: 'Crimson Pro';
	font-style: normal;
	font-weight: 100 900;
	font-display: swap;
	src: url('../fonts/crimson-pro-normal.woff2') format('woff2');
}
@font-face {
	font-family: 'Crimson Pro';
	font-style: italic;
	font-weight: 100 900;
	font-display: swap;
	src: url('../fonts/crimson-pro-italic.woff2') format('woff2');
}
@font-face {
	font-family: 'Plus Jakarta Sans';
	font-style: normal;
	font-weight: 100 900;
	font-display: swap;
	src: url('../fonts/plus-jakarta-sans-normal.woff2') format('woff2');
}
@font-face {
	font-family: 'Plus Jakarta Sans';
	font-style: italic;
	font-weight: 100 900;
	font-display: swap;
	src: url('../fonts/plus-jakarta-sans-italic.woff2') format('woff2');
}

/* Dark mode via OS preference — only applies when no manual choice is
   stored (i.e. html has no data-theme attribute yet). */
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) {
		--color-bg: #121212;
		--color-bg-raised: #1A1A1A;
		--color-text: #F2F1ED;
		--color-text-muted: #8A8A8E;
		--color-border: rgba(255, 255, 255, 0.10);
		--color-accent: #3B82FF;

		--glass-bg: rgba(18, 18, 18, 0.6);
		--glass-border: rgba(255, 255, 255, 0.14);
		--shadow-color: rgba(0, 0, 0, 0.35);
		--media-bg: #1C1C20;
		--media-bg-2: #26262C;
		--color-text-subtle-bg: rgba(255, 255, 255, 0.06);
	}
}

/* Manual override via the theme toggle — always wins regardless of OS
   preference, since the attribute selector is more specific. */
html[data-theme="dark"] {
	--color-bg: #121212;
	--color-bg-raised: #1A1A1A;
	--color-text: #F2F1ED;
	--color-text-muted: #8A8A8E;
	--color-border: rgba(255, 255, 255, 0.10);
	--color-accent: #3B82FF;

	--glass-bg: rgba(18, 18, 18, 0.6);
	--glass-border: rgba(255, 255, 255, 0.14);
	--shadow-color: rgba(0, 0, 0, 0.35);
	--media-bg: #1C1C20;
	--media-bg-2: #26262C;
	--color-text-subtle-bg: rgba(255, 255, 255, 0.06);
}

html[data-theme="light"] {
	--color-bg: #F7F6F3;
	--color-bg-raised: #FFFFFF;
	--color-text: #131313;
	--color-text-muted: #6B6B6E;
	--color-border: rgba(19, 19, 19, 0.10);
	--color-accent: #0265FD;

	--glass-bg: rgba(255, 255, 255, 0.55);
	--glass-border: rgba(19, 19, 19, 0.08);
	--glass-blur: 18px;
	--shadow-color: rgba(19, 19, 19, 0.12);
	--media-bg: #EAE9E5;
	--media-bg-2: #DEDDD8;
	--color-text-subtle-bg: rgba(19, 19, 19, 0.05);
}

/* Footer logo swap: follows the same dark/light logic as the color tokens. */
.site-footer__logo--dark { display: none; }
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) .site-footer__logo--light { display: none; }
	html:not([data-theme]) .site-footer__logo--dark { display: block; }
}
html[data-theme="dark"] .site-footer__logo--light { display: none; }
html[data-theme="dark"] .site-footer__logo--dark { display: block; }
html[data-theme="light"] .site-footer__logo--light { display: block; }
html[data-theme="light"] .site-footer__logo--dark { display: none; }

/* ---------- Reset (lean, not a full normalize) ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; color-scheme: light dark; }
html[data-theme="dark"] { color-scheme: dark; }
html[data-theme="light"] { color-scheme: light; }
body {
	margin: 0;
	background: var(--color-bg);
	color: var(--color-text);
	font-family: var(--font-body);
	font-size: 17px;
	line-height: 1.6;
	-webkit-font-smoothing: antialiased;
}

/* Noise texture overlay — a random-pixel canvas tile is written into
   background-image by the JS in main.js (canvas approach gives true
   1px-dot grain; SVG feTurbulence was blurry regardless of frequency).
   multiply on light mode darkens grain spots ~7% at opacity 0.08;
   screen on dark mode lightens grain spots by the same amount. */
.site-noise {
	position: fixed;
	inset: 0;
	z-index: 9999;
	pointer-events: none;
	opacity: 0.08;
	background-repeat: repeat;
	mix-blend-mode: multiply;
}
html[data-theme="dark"] .site-noise { mix-blend-mode: screen; }
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) .site-noise { mix-blend-mode: screen; }
}
img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
ul, ol { margin: 0; padding: 0; list-style: none; }
h1, h2, h3, p { margin: 0; }
/* figure and blockquote both carry a default browser UA margin
   (1em 40px) that nothing else in this file resets — invisible until
   block-editor content actually uses an Image or Quote block, at
   which point it silently insets them 40px per side and adds its own
   vertical margin on top of the site's own content rhythm
   (.post-article__content > * + *). Zeroing it here, same as the
   heading/paragraph reset above, lets width/spacing be controlled
   entirely by the components that actually style figure/blockquote. */
figure, blockquote { margin: 0; }

/* text-wrap: balance evens out line lengths in headings, so you don't
   get an orphaned single word stranded on its own last line — most
   useful on short, large text like headings. Browsers internally cap
   how much text they'll balance (rendering cost grows with length), so
   this is safe to apply broadly without worrying about long headings.
   text-wrap: pretty is balance's lighter-weight sibling, intended for
   longer body copy — it only nudges wrapping to avoid orphan words
   rather than fully balancing every line. Both are progressive
   enhancements: unsupported browsers just fall back to normal wrapping,
   no fallback rule needed. */
h1, h2, h3 { text-wrap: balance; }
p { text-wrap: pretty; }

@media (prefers-reduced-motion: reduce) {
	html { scroll-behavior: auto; }
	* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

:focus-visible {
	outline: 2px solid var(--color-accent);
	outline-offset: 3px;
}

/* ===================================================================
   Page intro — coordinated load sequence.

   A single fade-up keyframe applied with animation-fill-mode: both,
   which keeps each element at opacity:0 during its delay period
   (without any JS needed) and holds it at opacity:1 once done.

   Sequence on the front page:
     1. Nav           (0.05s)
     2. About content (0.35s, 0.5s staggered)
     3. About CTAs    (0.7s)
     4. Skills section (0.8s)
     5. Work section  (0.9s — CSS handles heading; JS handles cards)
     6. Side rails    (1.1s)

   On inner pages (single posts, 404, etc.) the nav and side rails use
   a much shorter delay so they're simply present rather than making
   the user wait through an unnecessary dramatic entrance.

   Disabled entirely for prefers-reduced-motion — see the override at
   the end of this block. CSS animations take precedence over
   transitions on the same properties while running, so the existing
   scroll-reveal transition system on [data-reveal] elements co-exists
   cleanly: is-revealed can be added by the IntersectionObserver at any
   point without visually interfering with this animation.
   =================================================================== */
@keyframes gathercole-intro {
	from { opacity: 0; transform: translateY(10px); }
	to   { opacity: 1; transform: translateY(0);    }
}

/* Side rails need their own variant that doesn't touch transform, because
   .side-rail already relies on transform: translateY(-50%) for vertical
   centering — the standard keyframe would overwrite that and misalign them. */
@keyframes gathercole-intro-fade {
	from { opacity: 0; }
	to   { opacity: 1; }
}

/* Nav: always first, very short delay so it feels near-instant.
   Animates .site-nav__inner (the glass pill) rather than .site-nav
   (its parent wrapper) — animating opacity on a parent creates a
   stacking context that prevents backdrop-filter on child elements
   from sampling behind the nav, breaking the glass blur entirely.
   Animating the pill itself has no such issue. */
.site-nav__inner {
	animation: gathercole-intro-fade 0.5s var(--ease-standard) 0.05s both;
}

/* About section elements — only exist on the front page */
.about__top     { animation: gathercole-intro 0.55s var(--ease-standard) 0.35s both; }
.about__content { animation: gathercole-intro 0.55s var(--ease-standard) 0.5s  both; }
.about__cta-row { animation: gathercole-intro 0.5s  var(--ease-standard) 0.7s  both; }

/* Skills section — sits directly between About and Work in the DOM,
   often already inside the initial viewport (About is short), so it
   was missing from this sequence entirely and just appeared instantly
   with no entrance. Heading and columns fade in together as one step,
   same style as everything else here — no per-column stagger, unlike
   Work's card grid below. */
.skills [data-reveal] {
	animation: gathercole-intro 0.5s var(--ease-standard) 0.8s both;
}

/* Skills section, "Stack" layout (Customizer → Skills & Tools → Layout
   = Stack — see template-parts/section-skills-stack.php): the section
   itself carries class="stack", not "skills", so it needs its own
   rule here rather than being covered by .skills [data-reveal] above.
   Without this, its [data-reveal] elements (the heading, and
   .stack__viewport's icon row) fell through to the generic
   IntersectionObserver-driven scroll-reveal instead of this page-load
   sequence — which, for a section sitting this close to the top of the
   page, fires almost immediately with no deliberate delay, so the
   heading was appearing before About's own (deliberately delayed)
   intro had finished, and the icon row — sized/positioned differently
   — wasn't reliably inside the observer's viewport threshold at load,
   so it sat invisible until an actual scroll triggered it.
   Same 0.8s delay as Grid's heading above; the icon row gets its own
   slightly later delay (below) so it visibly follows the heading in,
   rather than both appearing in the same instant. */
.stack [data-reveal] {
	animation: gathercole-intro 0.5s var(--ease-standard) 0.8s both;
}
.stack .stack__viewport[data-reveal] {
	animation: gathercole-intro 0.5s var(--ease-standard) 0.95s both;
}

/* Work section — heading elements and the arrow pair together.
   The cards themselves are triggered via JS setTimeout (see main.js)
   timed to fire at ~950ms, in sync with this 0.9s CSS delay. */
.work .section__header-text [data-reveal],
.work .cardgrid-arrows {
	animation: gathercole-intro 0.55s var(--ease-standard) 0.9s both;
}

/* Side rails: fade only — no transform — so the existing
   translateY(-50%) vertical centering is never overwritten by the
   animation's own transform keyframes. */
.side-rail {
	animation: gathercole-intro-fade 0.5s var(--ease-standard) 0.2s both;
}
.front-page .side-rail {
	animation-delay: 1.1s;
}
/* Tablet toggle uses bottom positioning (not translateY), so the
   standard fade-up is fine here. */
#theme-toggle-tablet {
	animation: gathercole-intro 0.5s var(--ease-standard) 0.2s both;
}
.front-page #theme-toggle-tablet {
	animation-delay: 1.1s;
}

@media (prefers-reduced-motion: reduce) {
	.site-nav__inner,
	.about__top,
	.about__content,
	.about__cta-row,
	.skills [data-reveal],
	.stack [data-reveal],
	.stack .stack__viewport[data-reveal],
	.work .section__header-text [data-reveal],
	.work .cardgrid-arrows,
	.side-rail,
	#theme-toggle-tablet {
		animation: none;
	}
}

/* ===================================================================
   Site Nav — the signature element. Fixed, centered glass pill,
   permanently visible (no longer fades in on scroll, now that there's
   no Hero section for it to sit transparently over).
   =================================================================== */

.site-nav {
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
	z-index: 100;
	display: flex;
	justify-content: center;
	padding-top: 20px;
	pointer-events: none; /* re-enabled on the inner pill */
}

.site-nav__inner {
	position: relative; /* anchors the mobile scroll-hint chevron */
	pointer-events: auto;
	display: flex;
	align-items: center;
	gap: 28px;
	/* Equal on all sides so the first/last link's highlight pill sits
	   the same distance from the container's left/right edge as it
	   does from the top/bottom edge — an asymmetric 6px/14px here
	   previously left visibly more breathing room on the sides than
	   above/below. */
	padding: 9px;
	border-radius: 999px;
	border: 1px solid transparent;
	background: transparent;
	backdrop-filter: blur(0px);
	-webkit-backdrop-filter: blur(0px);
	transition:
		background 0.5s var(--ease-standard),
		border-color 0.5s var(--ease-standard),
		backdrop-filter 0.5s var(--ease-standard),
		box-shadow 0.5s var(--ease-standard),
		transform 0.5s var(--ease-standard);
}

.site-nav__menu {
	display: flex;
	align-items: center;
	gap: 4px;
	position: relative; /* anchors the absolutely-positioned active indicator */
}

.site-nav__link {
	position: relative; /* sits above the indicator (z-index) */
	z-index: 1;
	font-size: clamp(0.78rem, 2.4vw, 0.9rem);
	/* line-height: 1 so the text's own line box doesn't add invisible
	   top/bottom "leading" beyond the glyphs — without it, equal
	   top/bottom and left/right padding still ends up looking taller
	   than it is wide, since default line-height reserves extra space
	   above/below the actual ink that horizontal padding has no
	   equivalent of. With it, equal padding on all sides reads as
	   genuinely equal, which .site-nav__indicator (sized to match this
	   link's own box) inherits directly. Padding is 9px, not the 8px
	   this started at — kept the "equal on all sides" fix but sized it
	   back up a little, since 8px alone (on top of removing the old
	   line-height's extra invisible space) had shaved more height off
	   the pill than intended. */
	line-height: 1;
	color: var(--color-text-muted);
	padding: 9px;
	border-radius: 999px;
	white-space: nowrap; /* never wrap a link label mid-word */
	transition: color 0.25s var(--ease-standard), background 0.25s var(--ease-standard);
}
.site-nav__link:hover { color: var(--color-text); background: var(--glass-border); }

/* Sliding active-section indicator — a single pill that physically moves
   and resizes to sit behind whichever link is currently active, rather
   than each link toggling its own background on/off. JS sets --indicator-x
   and --indicator-w (in px) to the active link's position/width; the
   transform/width transitions handle the actual "slide" animation. */
.site-nav__indicator {
	position: absolute;
	top: 0;
	left: 0;
	height: 100%;
	width: var(--indicator-w, 0px);
	border-radius: 999px;
	background: var(--glass-border);
	transform: translateX(var(--indicator-x, 0px));
	transition: transform 0.35s var(--ease-standard), width 0.35s var(--ease-standard), opacity 0.25s var(--ease-standard);
	opacity: 0;
	pointer-events: none;
}
.site-nav__indicator.is-visible { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
	.site-nav__indicator { transition: opacity 0.25s var(--ease-standard); }
}

/* The nav is permanently visible (no more fade-in-on-scroll, now that
   there's no Hero section for it to sit transparently over). Its glass
   chrome and menu are always in what used to be the "scrolled" state. */
.site-nav__inner {
	background: var(--glass-bg);
	border-color: var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	box-shadow:
		0 8px 32px var(--shadow-color),
		inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

/* Active link indicator text color — the sliding span above handles the
   background/pill; this just keeps the active link's text readable
   against it. */
.site-nav__link.is-active { color: var(--color-text); }

/* Hidden by default — only relevant once the menu can actually scroll,
   which only happens at the mobile breakpoint below. */
.site-nav__scroll-hint {
	display: none;
}

/* Below this width the full link set may not comfortably fit even at the
   smallest end of the clamp() above. Rather than collapsing into a
   hamburger/dropdown, the menu becomes horizontally scrollable — same
   links, same layout, just swipeable. A soft fade at the trailing edge,
   plus a small chevron sitting in front of it, signals there's more to
   scroll, since a flat clipped edge alone reads as "broken" rather than
   "scrollable". */
@media (max-width: 640px) {
	.site-nav__inner {
		max-width: calc(100vw - 48px);
		/* A dedicated fade sits behind the chevron specifically (on top
		   of .site-nav__inner itself, not the scrollable menu), so link
		   text scrolling underneath is guaranteed to fade out before it
		   reaches the icon — independent of the menu's own trailing-edge
		   mask, which fades the menu's content but doesn't add an opaque
		   backdrop of its own. */
		/* The nav's outer drop-shadow (0 8px 32px var(--shadow-color),
		   set unconditionally further down) reads as a visible dark
		   halo cast over whatever content is scrolled underneath the
		   fixed pill on a narrow viewport — most noticeable in light
		   mode over the testimonial cards. Dropped here; the inset
		   highlight is kept so the glass pill still has its edge. */
		box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
	}
	.site-nav__inner::after {
		content: '';
		position: absolute;
		top: 1px;
		bottom: 1px;
		right: 1px;
		width: 34px;
		border-radius: 0 999px 999px 0;
		background: linear-gradient(to right, transparent, var(--glass-bg) 65%);
		pointer-events: none;
		z-index: 1;
	}
	.site-nav__menu {
		overflow-x: auto;
		-webkit-overflow-scrolling: touch;
		scrollbar-width: none; /* Firefox */
		-ms-overflow-style: none; /* old Edge */
		mask-image: linear-gradient(to right, black calc(100% - 28px), transparent 100%);
		-webkit-mask-image: linear-gradient(to right, black calc(100% - 28px), transparent 100%);
		/* Leaves room on the right so the last link's text doesn't sit
		   directly underneath the chevron. */
		padding-right: 18px;
	}
	.site-nav__menu::-webkit-scrollbar { display: none; }
	/* Horizontal-only override: keeps the same 9px vertical as the base
	   rule, just trims the sides slightly so more links fit before the
	   scrollable menu kicks in. */
	.site-nav__link { padding-left: 10px; padding-right: 10px; }
	.site-nav__scroll-hint {
		display: block;
		position: absolute;
		top: 50%;
		right: 6px;
		transform: translateY(-50%);
		width: 16px;
		height: 16px;
		/* More transparent than the standard muted-text color, so it
		   reads as a subtle hint rather than a strong UI element. */
		color: var(--color-text-muted);
		opacity: 0.45;
		z-index: 2; /* sits above the ::after fade */
		pointer-events: none; /* purely decorative, never intercepts taps */
	}
}

/* ===================================================================
   Side Rails — fixed theme toggle (left) and social links (right),
   both vertically centered, same glass treatment as the nav.
   =================================================================== */

.side-rail {
	position: fixed;
	top: 50%;
	transform: translateY(-50%);
	z-index: 90;
}
/* Sit 25px outside the centered content column (--wide-width), not the
   raw viewport edge — so the rails track the content as the window resizes.
   The 64px floor guarantees clearance even where the calc would otherwise
   push them flush against the content (viewports just above the 900px
   breakpoint where rails reappear).

   Both `left: Npx` and `right: Npx` measure to the element's OUTER edge
   (the one facing the viewport edge), so the element's own width always
   sits between that position and the content-facing edge. Both rails
   need their own rendered width subtracted from the offset to get a
   real, equal gap to the content on both sides. */
.side-rail--left { left: max(64px, calc(50% - (var(--wide-width) / 2) - 38px - 25px)); }   /* toggle: 30px track + 4px padding each side = 38px */
.side-rail--right { right: max(64px, calc(50% - (var(--wide-width) / 2) - 38px - 25px)); } /* social rail: 30px icon + 4px padding each side = 38px (matches the toggle rail's own 38px on the left) */

/* ---------- Theme toggle ---------- */
.theme-toggle {
	display: block;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	border-radius: 999px;
	padding: 4px;
	cursor: pointer;
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

.theme-toggle__track {
	position: relative;
	display: flex;
	flex-direction: column;
	align-items: center;
	width: 30px;
	border-radius: 999px;
}

.theme-toggle__option {
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 7px 0;
	width: 100%;
	position: relative;
	z-index: 2;
	color: var(--color-text-muted);
	transition: color 0.35s var(--ease-standard);
}
.theme-toggle__label {
	/* Icon-only design — labels stay for screen readers, hidden visually. */
	position: absolute;
	width: 1px;
	height: 1px;
	padding: 0;
	margin: -1px;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	white-space: nowrap;
	border: 0;
}
.theme-toggle__icon { width: 14px; height: 14px; }

/* Sliding thumb — sits behind whichever option is active */
.theme-toggle__thumb {
	position: absolute;
	left: 2px;
	right: 2px;
	top: 2px;
	height: calc(50% - 2px);
	border-radius: 999px;
	background: var(--color-bg-raised);
	box-shadow: 0 2px 10px var(--shadow-color);
	transition: transform 0.4s var(--ease-standard);
	z-index: 1;
}

/* Default (no JS / before hydration): dark option active, thumb at top */
.theme-toggle[data-active="dark"] .theme-toggle__thumb,
.theme-toggle:not([data-active]) .theme-toggle__thumb {
	transform: translateY(0);
}
.theme-toggle[data-active="light"] .theme-toggle__thumb {
	transform: translateY(100%);
}

.theme-toggle[data-active="dark"] .theme-toggle__option--dark,
.theme-toggle:not([data-active]) .theme-toggle__option--dark {
	color: var(--color-text);
}
.theme-toggle[data-active="light"] .theme-toggle__option--light {
	color: var(--color-text);
}

/* ---------- Mobile-only horizontal theme toggle ----------
   Sits beside the nav pill (not in the fixed side rail) on small
   screens only. Reuses the same .theme-toggle button/icon/thumb
   classes as the desktop version, but with a horizontal track instead
   of vertical.

   Height matches the nav pill via flexbox stretch, not a height: 100%
   percentage — .site-nav (the row) has no explicit height of its own,
   so a percentage here would have nothing definite to resolve against
   and silently collapses to the element's own tiny content height
   instead. Leaving height unset lets the default align-items: stretch
   on .site-nav do the equalizing correctly; the internal track then
   uses height: 100% against *this* button, which by then has a real,
   stretch-resolved height to inherit from.

   Width is set explicitly (rather than left to auto/content-driven
   sizing) so it can be deliberately scaled up — at the old auto width,
   two icons crammed into ~8px of padding each looked squashed. 102px is
   roughly 1.5x the previous natural width (~68px: 4px button padding +
   two 30px options). The gap to the nav pill is controlled separately
   by .site-nav's own `gap`, so widening this doesn't affect that. */
.theme-toggle--mobile {
	display: none; /* shown only below the mobile breakpoint, see media query */
	pointer-events: auto;
	width: 102px;
}
.theme-toggle__track--horizontal {
	flex-direction: row;
	align-items: center;
	width: 100%;
	height: 100%;
}
.theme-toggle__track--horizontal .theme-toggle__option {
	flex: 1;
	justify-content: center;
	padding: 0 8px;
}
.theme-toggle__thumb--horizontal {
	left: 2px;
	right: auto;
	top: 2px;
	bottom: 2px;
	height: auto;
	width: calc(50% - 2px);
	transition: transform 0.4s var(--ease-standard);
}
/* Horizontal thumb slides left/right instead of up/down — these rules
   override the vertical translateY() set earlier in the cascade,
   scoped specifically to the horizontal variant. */
.theme-toggle[data-active="dark"] .theme-toggle__thumb--horizontal,
.theme-toggle:not([data-active]) .theme-toggle__thumb--horizontal {
	transform: translateX(0);
}
.theme-toggle[data-active="light"] .theme-toggle__thumb--horizontal {
	transform: translateX(100%);
}

@media (max-width: 640px) {
	.site-nav {
		gap: 10px;
		padding-left: 24px;
		padding-right: 24px;
	}
	.site-nav__inner {
		flex: 1;
		min-width: 0; /* allow the pill to shrink so the toggle has room */
	}
	.theme-toggle--mobile {
		display: block;
		flex-shrink: 0;
	}
}

/* Tablet portrait (641–920px): the desktop side rails are hidden at
   ≤920px, but the mobile toggle in the nav pill is only shown at
   ≤640px — leaving a gap where no toggle is visible at all. This
   fixed bottom-right pill fills that gap. Hidden at both ends so it
   never overlaps either of the existing toggles.
   Uses the same horizontal track as the mobile toggle; width matches
   .theme-toggle--mobile so the two-icon layout isn't cramped.
   --toggle-bottom is set by JS (see main.js) to keep the toggle
   above the footer divider as the user scrolls into the footer; it
   falls back to a flat 24px when that variable isn't set yet. */
.theme-toggle--tablet {
	display: none;
}
@media (min-width: 641px) and (max-width: 920px) {
	.theme-toggle--tablet {
		display: flex;
		position: fixed;
		bottom: var(--toggle-bottom, 24px);
		right: 24px;
		z-index: 90;
		pointer-events: auto;
		width: 102px;
		height: 56px;
		transition: bottom 0.2s var(--ease-standard);
	}
}
.social-rail {
	display: flex;
	flex-direction: column;
	gap: 4px;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	border-radius: 999px;
	/* Same 4px as .theme-toggle on the opposite rail — both wrap
	   identical 30px content (icons here, the toggle's track there),
	   so matching padding makes the two pills read as a matched pair
	   instead of the toggle looking snug and this one looking padded
	   out. Was 12px 8px, noticeably more inset than its counterpart. */
	padding: 4px;
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.social-rail__link {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 30px;
	height: 30px;
	border-radius: 50%;
	color: var(--color-text-muted);
	transition: color 0.25s var(--ease-standard), background 0.25s var(--ease-standard);
}
.social-rail__link svg { width: 16px; height: 16px; }
.social-rail__link:hover {
	color: var(--color-text);
	background: var(--glass-border);
}

@media (max-width: 920px) {
	.side-rail { display: none !important; }
}

/* ===================================================================
   Generic Section scaffolding
   =================================================================== */
.section { padding: var(--space-section) 24px; }

/* About is the first section on the page (Hero was removed), so it needs
   extra breathing room before the content starts — double the normal
   top padding here specifically, rather than changing --space-section
   globally, which would also widen every other section-to-section gap. */
.section.about { padding-top: calc(var(--space-section) * 2); }

/* On mobile specifically, About sits too close to the nav (which is now
   permanently visible rather than fading in) — push it down a further
   50% on top of the doubling above, i.e. a 3x multiplier total, mobile
   only. Desktop/tablet keep the 2x from the rule above. */
@media (max-width: 640px) {
	.section.about { padding-top: calc(var(--space-section) * 3); }
}

/* Tablet portrait (641–920px): more breathing room than the base 2x
   but not as much as mobile — 2.5x lands the content in a comfortable
   position below the nav pill. */
@media (min-width: 641px) and (max-width: 920px) {
	.section.about { padding-top: calc(var(--space-section) * 2.5); }
}

.section__inner { max-width: var(--wide-width); margin: 0 auto; }
.section__eyebrow {
	font-family: var(--font-display);
	font-size: 0.8rem;
	line-height: var(--leading-tight);
	color: var(--color-text-muted);
	letter-spacing: 0.04em;
	margin: 0 0 5px;
}
.section__title {
	font-family: var(--font-display);
	font-size: clamp(1.875rem, 3.8vw, 2.625rem);
	font-weight: 600;
	line-height: var(--leading-tight);
	letter-spacing: -0.02em;
	margin: 0 0 16px;
	text-wrap: balance;
}
.section__subheading {
	max-width: 520px;
	font-size: 1.0625rem;
	line-height: var(--leading-normal);
	color: var(--color-text-muted);
	margin: 0 0 40px;
}

/* Work/Trips only: wraps the section heading (eyebrow + title +
   subheading, still output as a normal stacked block via
   gathercole_section_heading()) alongside the carousel arrow pair,
   placing the arrows top-right and vertically centered against the
   whole heading block — matching the card grid's own right edge,
   since both share the same .section__inner max-width. */
.section__header-row {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 16px;
}

/* ---------- About ---------- */
.about__top {
	display: flex;
	align-items: center;
	gap: 14px;
	margin-bottom: 28px;
}
.about__avatar {
	width: 44px;
	height: 44px;
	border-radius: 50%;
	object-fit: cover;
	display: block;
	flex-shrink: 0;
}
.about__avatar--placeholder {
	display: flex;
	align-items: center;
	justify-content: center;
	background: var(--media-bg);
	color: var(--color-text-muted);
	font-family: var(--font-display);
	font-weight: 600;
	font-size: 1.1rem;
}
.about__identity {
	display: flex;
	flex-direction: column;
	gap: 2px;
	/* Name + role inherit the body's line-height: 1.6 by default, which
	   pads each line with space well beyond its actual text — that
	   padding, not the 2px gap, is what reads as a big gap between the
	   two lines. Tightening it closes that gap, and as a bonus keeps
	   the two-line block's total height close to the 44px avatar next
	   to it (17px × 1.25 × 2 lines + 2px gap ≈ 44.5px), which is why
	   the top and bottom currently look aligned with the avatar in the
	   first place — this keeps that intact rather than breaking it. */
	line-height: 1.25;
}
.about__name { font-weight: 600; color: var(--color-text); text-wrap: balance; }
.about__role { color: var(--color-text-muted); }

.about__content { max-width: var(--content-width); }
/* Acts as the page's headline — there's no separate Hero section, so
   this lead sentence is the largest, most attention-grabbing text a
   visitor sees, and it now reads as one: a clear step up from the
   section titles below it, tightened to --leading-snug so the wrap
   (most bios run 1–2 lines) reads as a confident single statement
   rather than the loose paragraph spacing body copy needs. */
.about__lead {
	font-size: clamp(1.5rem, 4vw, 2.375rem);
	font-weight: 500;
	letter-spacing: -0.015em;
	line-height: var(--leading-snug);
	color: var(--color-text);
}
/* Two lead paragraphs exist in the markup (Customize → About → "Bio —
   lead paragraph" and its mobile-only override below it) so mobile can
   carry different, shorter wording — only one is ever shown per
   viewport. front-page.php already falls back to the desktop lead's
   text on mobile when no mobile-specific text is set, so this is a
   pure visibility switch, not a content decision. */
.about__lead--mobile { display: none; }
@media (max-width: 640px) {
	.about__lead--desktop { display: none; }
	.about__lead--mobile { display: block; }
}
.about__body {
	margin-top: 16px;
	font-size: 1.0625rem;
	line-height: var(--leading-normal);
	font-weight: 400;
	letter-spacing: normal;
	color: var(--color-text-muted);
}
.about__body:first-child { margin-top: 0; }

.about__cta-row {
	display: flex;
	flex-wrap: wrap;
	gap: 16px;
	margin-top: 32px;
}

/* Inline company mention — icon + name, flowing as plain text within the
   bio sentence (no pill/background). The icon is rendered as a CSS mask
   rather than an <img>, so it picks up `currentColor` and always matches
   the surrounding text color in both light and dark mode, regardless of
   what color the uploaded logo file actually is. Requires the logo to be
   effectively single-color/silhouette — a mask only reads alpha, not the
   original colors. */
.about-company {
	display: inline-flex;
	align-items: baseline;
	gap: 4px;
	font-weight: 600;
	color: inherit;
	transition: color 0.25s var(--ease-standard), transform 0.25s var(--ease-standard);
}
a.about-company--underline .about-company__name { text-decoration: underline; text-underline-offset: 2px; }
a.about-company--hover-highlight:hover,
a.about-company--hover-muted:hover,
a.about-company--hover-light:hover {
	transform: scale(0.96);
}
a.about-company--hover-highlight:hover { color: var(--color-accent); }
a.about-company--hover-muted:hover { color: var(--color-text-muted); }
/* Lighter than muted — mixes the muted colour toward white so it
   reads as a softer, more subtle grey in both light and dark mode. */
a.about-company--hover-light:hover { color: color-mix( in srgb, var(--color-text-muted) 55%, white ); }

.about-company__icon {
	display: inline-block;
	width: 0.78em;
	height: 0.78em;
	align-self: center;
	background-color: currentColor;
	-webkit-mask-image: var(--about-company-icon-url);
	mask-image: var(--about-company-icon-url);
	-webkit-mask-size: contain;
	mask-size: contain;
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;
	-webkit-mask-position: center;
	mask-position: center;
}
.about-company__name { font-size: inherit; }

/* Inline rotating-phrase span — cycles through Customizer-supplied
   phrases via the typewriter effect in main.js. Color and weight are
   both configurable in the Customizer (About Section), applied here via
   modifier classes rather than inline styles so they stay themeable and
   consistent with the rest of the design tokens.

   No width-locking here — the span sizes naturally to whatever text is
   currently typed, so the next word always sits directly after it with
   no gap. What stops the surrounding paragraph's line count from
   changing as characters type/delete is a min-height set in JS directly
   on the paragraph itself, sized to whichever configured phrase wraps
   the most lines. */
.about-rotating--blue { color: var(--color-accent); }
.about-rotating--black { color: inherit; }
.about-rotating--bold { font-weight: 700; }
.about-rotating--regular { font-weight: 400; }

.about-rotating::after {
	content: '';
	display: inline-block;
	width: 2px;
	height: 1em;
	margin-left: 2px;
	/* Deliberately var(--color-text), not currentColor — the cursor
	   should read as part of the normal sentence (black in light mode,
	   white in dark mode) even when the rotating word itself is blue,
	   rather than blinking in the same accent color as the word. */
	background: var(--color-text);
	vertical-align: -0.1em;
	animation: gathercole-cursor-blink 1s step-end infinite;
}
@keyframes gathercole-cursor-blink {
	0%, 50% { opacity: 1; }
	50.01%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
	.about-rotating::after { animation: none; opacity: 1; }
}

/* ---------- Work ---------- */

/* Shared "load more" card-grid scaffolding, used by Work and Trips
   (both: 3 per batch, button on mobile, carousel arrow on desktop/
   tablet) and reused in spirit by Blog (5 per batch, button only, no
   wrapper/arrow needed since it's a single column on every viewport). */
.cardgrid-wrap {
	position: relative;
}

.work__grid {
	display: grid;
	grid-template-columns: 1fr; /* single column by default (mobile) */
	gap: 24px;
}
@media (min-width: 641px) {
	/* Tablet/desktop: all posts are rendered upfront on page load (no
	   AJAX), so instead of a vertical grid this becomes a horizontal,
	   snap-scrolling carousel. The arrow just scrolls the track — it
	   never fetches anything. */

	/* Pull the wrap into the section's 24px padding on each side so
	   the first and last card's side shadows are not clipped by the
	   scroll container. The grid's matching inline-padding restores
	   the content width to what it was, so card widths are unchanged. */
	.work .cardgrid-wrap,
	.trips .cardgrid-wrap {
		margin-inline: -24px;
	}

	.work__grid {
		display: flex;
		grid-template-columns: none;
		overflow-x: auto;
		scroll-snap-type: x mandatory;
		scroll-behavior: smooth;
		scrollbar-width: none;
		-ms-overflow-style: none;
		cursor: grab;
		/* overflow-x:auto forces overflow-y:auto (CSS won't allow one
		   axis auto and the other visible). padding gives shadows room
		   inside the scroll container. Vertical: 40px covers the hover
		   state (8px offset + 28px blur + 4px card lift = 40px).
		   Inline: 24px matches the section's full padding — the maximum
		   safe extension before causing page-level horizontal scroll.
		   Asymmetric block padding: top only needs 14px (covers the
		   at-rest upward shadow: 12px blur - 2px downward offset = 10px,
		   plus a small buffer). Bottom needs 40px for the hover state.
		   Using 40px symmetrically was pulling the grid 40px up into the
		   section header, making the arrow buttons unclickable.
		   This is the default for .work__grid generally (Work's classic
		   card, and Trips' own classic-cards carousel, which shares this
		   same class) — see the min-width: 641px override further below
		   for the much larger clearance Work's folder cards need there
		   specifically, since that pop-out only shows once the carousel
		   itself is active. */
		padding-top: 14px;
		padding-bottom: 40px;
		padding-inline: 24px;
		margin-top: -14px;
		margin-bottom: -40px;
		/* Tell snap alignment to account for the 24px inline padding
		   so cards snap flush to their correct visible position. */
		scroll-padding-inline: 24px;
	}
	.work__grid.is-drag-scrolling {
		cursor: grabbing;
		user-select: none; /* stop card text being selected while dragging across it */
	}
	.work__grid::-webkit-scrollbar { display: none; }
	.work__grid > .work-card {
		flex: 0 0 calc((100% - 2 * 24px) / 3); /* 3 cards visible at a time, matching the old 3-col grid */
		min-width: 0;
		scroll-snap-align: start;
	}
	/* While the progress bar is being dragged, scroll-snap fights the
	   manual scrollLeft updates and makes the drag feel jumpy — JS adds
	   this class for the duration of the drag and removes it on release. */
	.work__grid.is-drag-scrolling {
		scroll-snap-type: none;
	}
}

/* Work's folder cards (Customizer → Work Cards → Style) only ever show
   at 641px+ (see .work-card__folder-view/.work-card__classic-view —
   matching the horizontal-carousel breakpoint itself, so the folder
   design keeps fluidly scaling down through the whole tablet range
   instead of switching to the classic card partway through it) and
   their hover pop-out rises much higher/lower than the classic card's
   modest hover lift — .work__grid is the only clipping ancestor
   between .work-folder and the page root, so whatever room isn't
   reserved here clips the image/paper outright, not just a shadow.
   Scoped to .work specifically (not bare .work__grid, which Trips'
   own classic-cards carousel also uses) so Trips keeps the smaller
   default above regardless of viewport — it never shows a folder card
   and doesn't need this extra clearance. Negative margins pull the
   grid back to its original visual position/height despite the extra
   padding — the scrollbar's actual box is bigger, but nothing outside
   it shifts as a result. */
@media (min-width: 641px) {
	.work .work__grid {
		padding-top: 170px;
		padding-bottom: 70px;
		margin-top: -170px;
		margin-bottom: -70px;
		/* .work-folder__back's drop-shadow (see that rule) isn't
		   clipped by the folder's own box — filters render outside an
		   element's bounds — so a card just past the visible scroll
		   area can still peek its shadow in as a stray line near the
		   edge. Fading only the outer 20px on each side hides that
		   spillover; every actually-visible card's own content sits
		   well inside that margin, so nothing real fades with it.
		   Both edges: this isn't just the "next" card once scrolled —
		   any previous card sitting just off-screen to the left casts
		   the same spillover rightward, same as the next card does
		   leftward on the right. */
		-webkit-mask-image: linear-gradient(to right, transparent 0, black 20px, black calc(100% - 20px), transparent 100%);
		mask-image: linear-gradient(to right, transparent 0, black 20px, black calc(100% - 20px), transparent 100%);
		/* mask-image (just above) makes .work__grid itself a new
		   stacking context — the same trap .work-card--folder:hover's
		   own transform caused for its contents (see that rule's z-index
		   further down for the fuller explanation): the hovered card's
		   z-index: 6 only wins locally inside whatever context it's in,
		   and .work__grid's own context (z-index: auto by default) still
		   loses to .cardgrid-arrows' explicit z-index: 5 as a whole,
		   pulling the arrows back in front of everything again.
		   position: relative is required here, not optional — z-index
		   only has any effect on an element that's either positioned
		   (not static) or a flex/grid ITEM of its own parent, and
		   .work__grid is neither: it's position: static, and its own
		   parent (.cardgrid-wrap) is a plain block, not a flex/grid
		   container. Without this, the z-index below is silently inert
		   — exactly what happened in the version before this one. */
		position: relative;
		z-index: 6;
		/* margin-top: -170px doesn't just "cancel out" the padding —
		   it physically shifts this element's whole box (padding
		   included) 170px further up the page than its normal flow
		   position, so that empty top-padding zone now geometrically
		   overlaps .section__header-row (the arrows) above it. That
		   empty space is still part of .work__grid's hit-test box even
		   though nothing is painted there, and now that it's genuinely
		   stacked above the arrows (the point of the z-index above),
		   it was intercepting drags and arrow clicks in that overlap
		   area instead of letting them fall through — "grabbing" the
		   carousel from way above the actual cards, and the arrows
		   going dead. pointer-events: none here, restored to auto on
		   the actual cards below, means only real card content (or
		   whatever's directly on it, e.g. still pointer-events: none
		   further in for the decorative image/paper) can capture a
		   click/drag; the empty padding area now correctly passes
		   through to .cardgrid-arrows underneath it instead. */
		pointer-events: none;
	}
	.work .work__grid > .work-card {
		pointer-events: auto;
	}
}

/* Cards beyond the initial mobile batch are rendered into the page on
   load (so desktop/tablet has the full set for the carousel above) but
   stay hidden on mobile until "View more" reveals them client-side —
   no network round trip either way.

   Compounded with .cardgrid-card--pending alone (a single class,
   specificity 0,0,1,0) is .work-card's own `display: block` rule
   further down this file — also 0,0,1,0, but later in the cascade, so
   it was silently winning the tie and these cards showed on mobile
   regardless. Targeting .work-card.cardgrid-card--pending together
   (0,0,2,0) makes this rule unambiguously win no matter where either
   rule sits in the file. */
@media (max-width: 640px) {
	.work-card.cardgrid-card--pending {
		display: none;
	}
}

/* Newly-revealed cards (mobile "View more", or Blog's AJAX append) fade
   and drift in the same way as the initial scroll-reveal set, so
   additional content doesn't just pop into place abruptly. */
.cardgrid-card--incoming {
	opacity: 0;
	transform: translateY(8px);
	transition: opacity 0.6s var(--ease-standard), transform 0.6s var(--ease-standard);
}
.cardgrid-card--incoming.is-revealed {
	opacity: 1;
	transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
	.cardgrid-card--incoming { opacity: 1; transform: none; transition: none; }
}

/* "View more" button — mobile (Work, Trips) and all viewports (Blog). */
.cardgrid-more {
	display: block;
	margin: 28px auto 0;
	padding: 12px 28px;
	border-radius: 999px;
	background: none;
	border: 1px solid var(--color-border);
	color: var(--color-text);
	font-size: 0.9rem;
	font-weight: 600;
	cursor: pointer;
	transition: background 0.25s var(--ease-standard), border-color 0.25s var(--ease-standard);
}
.cardgrid-more:hover { background: var(--color-text-subtle-bg); border-color: var(--color-text-muted); }
.cardgrid-more[data-loading="true"] { opacity: 0.6; pointer-events: none; }
.cardgrid-more[hidden] { display: none; }

/* Work and Trips: "View more" is mobile-only, replaced by the carousel
   arrow above 640px. Blog has no arrow/carousel, so its button stays
   visible on every viewport — scoped here so this rule doesn't catch it. */
@media (min-width: 641px) {
	.work .cardgrid-more,
	.trips .cardgrid-more {
		display: none;
	}
}

/* Carousel arrows now live in the section header (see
   .section__header-row below), as a pair next to each other on the
   right — not attached to the card grid itself at all anymore. Same
   breakpoint as the carousel itself (641px+, where .work__grid
   becomes a horizontal scroller) since below that there's nothing to
   scroll — mobile uses the "View more" button instead. */
.cardgrid-arrows {
	display: none;
}
@media (min-width: 641px) {
	.cardgrid-arrows {
		display: flex;
		gap: 8px;
		flex-shrink: 0;
		/* Keeps the arrows clickable above a folder card's hover-popped
		   image/papers when they happen to overlap (those go up to
		   z-index: 4 — see .work-folder__image-frame's hover rule). */
		position: relative;
		z-index: 5;
	}
}
.cardgrid-arrow {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 36px;
	height: 36px;
	border-radius: 999px;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
	cursor: pointer;
	color: var(--color-text-muted);
	transition: color 0.25s var(--ease-standard);
}
.cardgrid-arrow:hover { color: var(--color-text); }
.cardgrid-arrow svg { width: 18px; height: 18px; }
.cardgrid-arrow[data-loading="true"] { opacity: 0.5; pointer-events: none; }

/* Both arrows (Work and Trips) now stay visible together at all times
   the carousel actually has more content than fits — [hidden] here
   only covers the "nothing to scroll either way" case (2 or fewer
   items total), set once on load/resize rather than something that
   toggles often, so a plain instant hide is fine. Clicking an arrow
   with nothing further to scroll to in that specific direction
   doesn't hide/disable it — main.js shakes it instead (.is-shaking
   below) as feedback that this is the start/end of the carousel. */
.cardgrid-arrow[hidden] { display: none; }

/* A few quick side-to-side wobbles — main.js adds this class for one
   animation cycle (then it's safe to re-add immediately even before
   a previous shake finishes, see shakeArrow()) when an arrow is
   clicked but there's nothing further to scroll to in that direction. */
@keyframes cardgrid-arrow-shake {
	0%, 100% { transform: translateX(0); }
	20% { transform: translateX(-4px); }
	40% { transform: translateX(3px); }
	60% { transform: translateX(-3px); }
	80% { transform: translateX(2px); }
}
.cardgrid-arrow.is-shaking {
	animation: cardgrid-arrow-shake 0.4s var(--ease-standard);
}

/* Carousel progress bar — desktop/tablet only, mirrors how far through
   the Work carousel the user has scrolled. JS sizes/positions the fill
   to match the grid's visible-vs-total-width ratio and scroll offset;
   hidden entirely (mobile, or nothing to scroll) via [hidden]. */
.cardgrid-progress {
	display: none;
}
@media (min-width: 641px) {
	.cardgrid-progress {
		display: block;
		position: relative;
		max-width: 240px;
		height: 5px;
		margin: 20px auto 0;
		border-radius: 999px;
		background: var(--color-border);
		overflow: hidden;
		cursor: grab;
	}
	.cardgrid-progress[hidden] { display: none; }
	.cardgrid-progress.is-dragging { cursor: grabbing; }
	.cardgrid-progress__fill {
		position: absolute;
		top: 0;
		left: 0;
		height: 100%;
		min-width: 40px;
		border-radius: 999px;
		background: var(--color-text-muted);
		transition: transform 0.2s var(--ease-standard);
		pointer-events: none;
	}
	.cardgrid-progress.is-dragging .cardgrid-progress__fill {
		transition: none;
	}
}

.work-card {
	display: block;
	border-radius: 22px;
	overflow: hidden;
	background: var(--color-bg-raised);
	border: 1px solid var(--color-border);
	box-shadow: 0 2px 12px var(--shadow-color);
	transition: transform 0.4s var(--ease-standard), box-shadow 0.4s var(--ease-standard);
}
.work-card:hover {
	transform: translateY(-4px);
	box-shadow: 0 8px 28px -4px var(--shadow-color);
}
/* Inset image: margin pulls the media div 10px away from card edges
   on the top and sides. border-radius follows outer(22) - padding(10)
   = 12px so the image corner curve looks concentric with the card. */
.work-card__media {
	aspect-ratio: 4 / 3;
	overflow: hidden;
	background: var(--media-bg);
	margin: 10px 10px 0;
	border-radius: 12px;
}
.work-card__image { width: 100%; height: 100%; object-fit: cover; transition: transform 0.6s var(--ease-standard); }
.work-card:hover .work-card__image { transform: scale(1.04); }
.work-card__placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, var(--media-bg), var(--media-bg-2)); }
.work-card__body { padding: 9px 20px 16px; }
.work-card__title { font-family: var(--font-display); font-size: 1.05rem; font-weight: 600; line-height: var(--leading-snug); margin-bottom: 6px; text-wrap: balance; }
/* Trip location: same colour as the title (color-text, not the muted
   grey used for the date) but visually subordinate via smaller size
   and lighter weight — sits as a subtitle between heading and date. */
.trip-card__location {
	font-size: 0.85rem;
	font-weight: 400;
	color: var(--color-text);
	/* Midway between the original -11px (flush against the title) and
	   -5px (doubled gap, turned out too much) — half that increase. */
	margin-top: -8px;
	margin-bottom: 4px;
}
.work-card__meta { font-size: 0.85rem; color: var(--color-text-muted); }
.work-card__tags { display: flex; flex-wrap: wrap; gap: 6px; }
.work-card__tag {
	display: inline-block;
	padding: 3px 10px;
	border-radius: 999px;
	background: var(--color-text-subtle-bg);
	border: 1px solid var(--color-border);
	font-size: 0.78rem;
	color: var(--color-text-muted);
}
/* Optional per-category color, set via the Pill Color field on the
   term (wp-admin → Work → Project Types) — see
   gathercole_project_types_array() and the term-meta admin UI in
   inc/custom-post-types.php. Tinted (a soft background + the color
   itself for text/border) rather than a solid fill, so an arbitrary
   admin-chosen color still reads comfortably against both light and
   dark mode without needing to calculate contrast per color. Falls
   back to the plain grey pill above when a category has no color set
   — data-tag-color is only present on the element when one is. */
.work-card__tag[data-tag-color] {
	background: color-mix(in srgb, var(--tag-color) 16%, var(--color-bg));
	border-color: color-mix(in srgb, var(--tag-color) 45%, transparent);
	color: color-mix(in srgb, var(--tag-color) 65%, var(--color-text));
}
.work__empty, .blog__empty { color: var(--color-text-muted); }
.work__empty a { color: var(--color-accent); text-decoration: underline; }

/* ---------- Work cards: folder alternative ----------
   Alternative to the plain image card above (Customizer → Work Cards
   → Style). Back and front are two <img>-loaded SVGs (Customizer-
   uploaded artwork, not drawn from CSS shapes). The featured image +
   two decorative "files" sit centered in the folder at rest, tucked
   behind the front layer; on hover/tap the front pocket shrinks and
   tilts open from its bottom edge while the photo rises above it —
   the two papers rise and fan out too, but stay tucked behind the
   flap the whole time, only the photo actually clears it. Kept side
   by side with the classic card rather than replacing it, so
   switching back to Classic needs nothing rebuilt. Trip cards share
   .work-card's base rules but never get this modifier class, so
   they're unaffected either way.
   Back/front are two separate files rather than one, and loaded via
   <img> rather than inlined SVG markup: both were exported from
   Illustrator and reuse the same internal IDs (id="Layer_1",
   id="linear-gradient"), so inlining both into the same document
   makes their gradients collide and one layer render blank. <img>
   loads each as an isolated document, avoiding that entirely.
   .work-folder replaces .work-card__media (which clips its image) —
   overflow stays visible here on purpose, since the whole point is
   the image's tip showing past the folder's own top edge.

   Shows from 641px up (the same breakpoint .work__grid itself becomes
   a horizontal carousel at), so the folder keeps fluidly scaling down
   through the whole tablet range instead of switching to the classic
   card partway through it — only true mobile below that falls back to
   the classic card by default. The design leans on :hover to reveal
   the featured image, which a touch-only device at any width can't do
   the same way (a tap just leaves it stuck "open" instead of a hover
   you can move away from) — but that's handled independently, by
   detecting actual hover capability via matchMedia('(hover: hover)')
   rather than by viewport width (see main.js, the tap-to-reveal
   handler further down this file's discussion of .work-card--folder),
   so lowering this width threshold doesn't reopen that problem. The
   template renders BOTH this view and a plain .work-card__classic-view
   (identical to the Classic style's own markup) into every folder
   card, and these two rules show only the one that fits — the classic
   card below 641px, the folder at 641px and up. */
.work-card__folder-view {
	display: none;
	/* Centres .work-folder__label and .work-folder__tag, both of which
	   sit below .work-folder as plain in-flow content now. */
	text-align: center;
}
@media (min-width: 641px) {
	.work-card__folder-view {
		display: block;
	}
	.work-card__classic-view {
		display: none;
	}
}
/* Customizer → Work Cards → Mobile Layout = Folders: mirrors the rule
   just above, but below 640px instead of above it, so the folder view
   (and its scroll-triggered open/close — see the IntersectionObserver
   in main.js) shows on true mobile too instead of falling back to the
   classic card there — tablet already gets the folder view by default
   now, so this only needs to cover what's genuinely left. Scoped to
   the section's own data attribute (set from that Customizer setting —
   see front-page.php) rather than applying unconditionally, so the
   default "Cards" choice leaves mobile exactly as it already was. */
@media (max-width: 640px) {
	.work[data-mobile-layout="folders"] .work-card__folder-view {
		display: block;
	}
	.work[data-mobile-layout="folders"] .work-card__classic-view {
		display: none;
	}
}
.work-folder {
	/* The markup uses <span> (inline by default) — inline elements
	   ignore width/aspect-ratio entirely, which collapses this (and
	   every absolutely-positioned child inset: 0 against it) to zero
	   size. This exact regression happened once already when the
	   folder was built from CSS shapes instead of these SVGs; carry
	   the fix forward whenever this rule gets rewritten. */
	display: block;
	position: relative;
	/* Lets .work-folder__front's hover transform render as a 3D fold
	   instead of just squashing flat vertically. */
	perspective: 1400px;
	/* auto, not 100% — a block box's width already fills the
	   available space minus its own margins by default. width: 100%
	   here instead made the box 100% of the container PLUS another
	   28px from the margin below, overflowing past the right edge
	   whenever nothing clips/scrolls it away (exactly what was
	   happening on mobile, where .work__grid is a plain block grid
	   rather than desktop's horizontal scroll carousel). Same fix
	   already applied to .post-article__content > .alignfull
	   elsewhere in this file. */
	width: auto;
	/* Matches the SVGs' own native viewBox — do not stretch this to a
	   different ratio, or the two layers stop lining up with each
	   other. */
	aspect-ratio: 216 / 177;
	margin: 14px 14px 0;
}
/* .work-card's own white background/border/shadow (and overflow:
   hidden, which would clip the image as it pops up past the folder's
   own top edge on hover) are meant for the classic image-card
   variant — the folder sits directly on the page background instead,
   with the artwork's own baked-in shading doing the work of
   separating it visually.
   Shows from 641px up, matching .work-card__folder-view above — see
   that rule's comment for why touch/hover isn't a reason to gate this
   on a wider breakpoint. Below 641px, the classic card's own markup
   shows instead (see .work-card__classic-view) and needs to keep its
   normal white chrome rather than have this strip it away. */
@media (min-width: 641px) {
	.work-card--folder {
		background: transparent;
		border: none;
		box-shadow: none;
		overflow: visible;
	}
	/* z-index here — not just on .work-folder__image-frame further
	   down — because .work-card:hover's own transform (translateY, from
	   the shared base rule) makes the hovered card a new stacking
	   context. Without a z-index on the card itself, everything inside
	   it (however high its own internal z-index climbs) is still
	   capped at that context's default stacking level, which loses to
	   .cardgrid-arrows' explicit z-index: 5 regardless. Raising the
	   card's own z-index above that fixes it for the whole card at
	   once, image/paper included. */
	.work-card--folder:hover {
		box-shadow: none;
		z-index: 6;
	}
	.work-card--folder.is-open {
		z-index: 6;
	}
}
/* Mobile Layout = Folders (see the data-mobile-layout rule above):
   mirrors the 641px+ block above it, stripping the classic card's
   white chrome so the folder graphic sits directly on the page
   background here too, instead of inside a clipped white panel sized
   for the image card it isn't.
   Tablet (641–920px) already gets the default horizontal-carousel
   folder view above, so this only needs to cover genuine mobile now —
   a horizontal carousel doesn't fit there, so folder mode instead
   falls back to a single-column vertical stack, giving each folder its
   own full-width row to open into as the page scrolls (see the
   IntersectionObserver in main.js) — plain page-scroll, not the
   horizontal carousel, so its own arrows (which would otherwise sit
   there doing nothing) are hidden too. */
@media (max-width: 640px) {
	.work[data-mobile-layout="folders"] .work-card--folder {
		background: transparent;
		border: none;
		box-shadow: none;
		overflow: visible;
	}
	.work[data-mobile-layout="folders"] .work-card--folder.is-open {
		z-index: 6;
	}
	.work[data-mobile-layout="folders"] .cardgrid-wrap {
		margin-inline: 0;
	}
	.work[data-mobile-layout="folders"] .work__grid {
		display: grid;
		grid-template-columns: 1fr;
		overflow-x: visible;
		scroll-snap-type: none;
		cursor: auto;
		padding: 0;
		margin: 0;
	}
	.work[data-mobile-layout="folders"] .work__grid > .work-card {
		flex: none;
		width: 100%;
		min-width: 0;
	}
	.work[data-mobile-layout="folders"] .cardgrid-arrows {
		display: none;
	}
}
.work-folder__back,
.work-folder__front {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
}
.work-folder__back {
	z-index: 1;
	/* filter: drop-shadow (not box-shadow) — box-shadow on an <img>
	   paints in the box's own background layer, behind the image's
	   rendered pixels, so it never actually shows; drop-shadow instead
	   follows the artwork's own alpha channel, casting a shadow shaped
	   like the visible folder silhouette rather than the <img>'s
	   rectangular bounding box. --shadow-color keeps it consistent
	   with the site's other shadows (and correctly adapts between
	   light/dark mode) rather than a hardcoded rgba() value. Applied
	   to the back layer specifically, at the lowest z-index (1) of the
	   three folder layers, so it naturally renders behind
	   .work-folder__front (3) too — nothing extra needed for that. */
	filter: drop-shadow(0 6px 14px var(--shadow-color));
}
/* Bottom-anchored transform-origin: the hover/is-open fold (see the
   consolidated hover block further down) shrinks and tilts this from
   its bottom edge, so the bottom stays put against the folder back
   and only the top flares outward — a real "flap opening" rather
   than the whole card tilting or leaving a gap at the bottom
   corners. perspective on the parent (.work-folder) is what makes
   the rotateX render as a 3D fold instead of just squashing flat. */
.work-folder__front {
	z-index: 3;
	transform-origin: bottom;
	transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Featured image + two plain white "files" behind it, all tucked
   behind the front layer at rest. display: grid with every child
   sharing grid-area: 1/1 stacks all three on top of each other in
   one spot (justify/align centers each within that shared cell) —
   simpler than flex + absolute-centering would have been for three
   overlapping items. No z-index here (deliberately, unlike the
   earlier version) — that would trap paper/image-frame inside this
   element's own stacking context, capping them at whatever z-index
   this got even on hover. Leaving it off lets .work-folder__image-
   frame's own z-index (see the hover block further down) compare
   directly against .work-folder__front's, so the photo can actually
   rise above the flap. Insets are symmetric on every side so the
   group sits centered in the whole back card at rest. */
.work-folder__contents {
	position: absolute;
	left: 12%;
	right: 12%;
	top: 12%;
	bottom: 12%;
	display: grid;
	justify-items: center;
	align-items: center;
	overflow: visible;
}
.work-folder__paper,
.work-folder__image-frame {
	grid-area: 1 / 1;
	width: 107.25%;
	aspect-ratio: 16 / 9;
	/* Rest-position tuning history: -10% (with captions still on the
	   artwork) read as too high once they moved below the folder
	   entirely; 12% (centered on folder-back.svg's own bounds) then
	   read as too low. 1% splits the two. This only changes where the
	   group STARTS: the hover/is-open transform further down is a
	   separate, independent value computed from this same
	   untransformed layout position, so the end position/scale on
	   hover is completely unaffected by this value — only the distance
	   (and how it looks) travelled to get there changes. Eyeballed,
	   not measured against the actual artwork. */
	transform: translateY(1%) rotate(var(--paper-tilt, 0deg));
	transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
	/* Both sit behind .work-folder__front's z-index: 3 at rest. */
	z-index: 2;
	/* Purely decorative from a click's perspective — lets clicks pass
	   straight through to whatever's actually underneath (the card's
	   own <a> link normally, or the carousel arrows on the rare
	   occasion they overlap) instead of being intercepted by these. */
	pointer-events: none;
}
/* Plain rotated "files" with nothing on them — pure decoration, so a
   folder always reads as holding more than one thing without
   depending on extra post data. Both sit behind the real photo. */
.work-folder__paper {
	background: #fff;
	border-radius: 8px;
	box-shadow: 0 6px 14px rgba(0, 0, 0, 0.18);
}
/* --paper-tilt-hover: a different rotation to settle into on hover
   (see the hover rule below), not just the same value as rest — so
   each layer visibly turns by its own amount rather than only lifting
   and growing in place. */
.work-folder__paper--1 {
	--paper-tilt: -8deg;
	--paper-tilt-hover: -16deg;
	z-index: 1;
}
.work-folder__paper--2 {
	--paper-tilt: 6deg;
	--paper-tilt-hover: 13deg;
	z-index: 2;
}
/* The real photo's frame — white bezel + hairline border + soft
   shadow, the same "polaroid" treatment as .trips-photos__frame, so
   this photo reads the same way a Trip card's does. Sits flat at rest
   (--paper-tilt defaults to 0deg). No z-index override here anymore —
   it shares the rest-state z-index: 2 from the rule above, and gets
   bumped above .work-folder__front on hover instead (see below). */
.work-folder__image-frame {
	--paper-tilt-hover: -4deg;
	box-sizing: border-box;
	padding: 6px;
	border-radius: 10px;
	background: #fafafa;
	box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 10px 20px rgba(0, 0, 0, 0.25);
}
.work-folder__image-frame img,
.work-folder__image-frame .work-folder__placeholder {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: cover;
	/* Nested-radius rule of thumb: inner radius = outer radius − inset,
	   so the two curves stay concentric — outer is 10px, the frame's
	   padding (inset) is 6px, so inner is 10 − 6 = 4px. */
	border-radius: 4px;
}
.work-folder__placeholder {
	background: linear-gradient(135deg, var(--media-bg), var(--media-bg-2));
}
/* Real hover only — touch has no :hover, so main.js instead toggles
   .is-open on tap or scroll position (see the "Folder work cards"
   blocks near the end of main.js). Kept as separate rule blocks below
   rather than one combined selector, both so the :hover half never
   applies on a device that can't hover in the first place, and because
   .is-open's own paper/image-frame transform further down is
   deliberately a smaller pop than this one — see the comment there.
   Only .work-folder__image-frame gets bumped to z-index: 6 — above
   .work-folder__front's 3 — so just the real photo rises in front of
   the flap; the papers stay behind it at their rest z-index: 2. That
   alone doesn't clear .cardgrid-arrows' z-index: 5 though:
   .work-card:hover's own transform makes the hovered card a new
   stacking context, which traps this z-index inside it — beating the
   arrows needs the CARD itself raised above them instead, which is
   what .work-card--folder:hover/.is-open's own z-index: 6 (set
   further up, alongside its other desktop-only rules) actually does.
   The arrows stay perfectly clickable regardless: they're
   pointer-events: none (see the shared .work-folder__paper,
   .work-folder__image-frame rule above), so being visually covered
   never blocks a click from reaching whatever's underneath. All three
   (papers + image) still move up and scale together, each turning to
   its own --paper-tilt-hover angle so the fan visibly reshuffles
   rather than just growing in place. The flap itself shrinks and
   tilts open from its bottom edge (transform-origin: bottom, set on
   .work-folder__front above) in the same motion. */
@media (hover: hover) {
	.work-card--folder:hover .work-folder__image-frame {
		z-index: 6;
	}
	.work-card--folder:hover .work-folder__paper,
	.work-card--folder:hover .work-folder__image-frame {
		transform: translateY(-55%) scale(1.19) rotate(var(--paper-tilt-hover, 0deg));
	}
	.work-card--folder:hover .work-folder__front {
		transform: scaleY(0.7) rotateX(-36deg);
	}
}
.work-card--folder.is-open .work-folder__image-frame {
	z-index: 6;
}
/* ~25% less travel/growth than :hover's own values above (-55%/1.19)
   — .is-open only ever applies on mobile/tablet (scroll-triggered; see
   main.js), where the folder's title and category pill sit much
   closer beneath it than desktop's roomier layout, and the full-size
   pop-out was rising far enough to cover them. */
.work-card--folder.is-open .work-folder__paper,
.work-card--folder.is-open .work-folder__image-frame {
	transform: translateY(-41%) scale(1.14) rotate(var(--paper-tilt-hover, 0deg));
}
.work-card--folder.is-open .work-folder__front {
	transform: scaleY(0.7) rotateX(-36deg);
}
@media (prefers-reduced-motion: reduce) {
	.work-folder__paper,
	.work-folder__image-frame,
	.work-folder__front {
		transition: none;
	}
}
/* Title, below the folder and centred (via .work-card__folder-view's
   own text-align: center) — like a file's name on the desktop — for
   BOTH Customizer → Work Cards → Folder caption position options now,
   not just Below Icon. It used to overlay the front artwork, but
   narrow-viewport widths wrapped it to two lines there and broke the
   illusion — folder icons don't carry a two-line label. */
.work-folder__label {
	display: block;
	margin-top: 10px;
	font-family: var(--font-display);
	font-size: 0.92rem;
	font-weight: 600;
	color: var(--color-text);
	line-height: var(--leading-snug);
}
/* Inside Card keeps the card panel around the folder (see
   .work-card--folder's own desktop-only chrome further up) — this
   gives the label horizontal breathing room against the panel's
   sides for a long title, and the view itself bottom padding so
   whichever renders last (the tag below, if there is one, otherwise
   the label) doesn't sit flush against the panel's bottom edge —
   matching .work-card__body's own bottom padding on the classic
   card. Below Icon has no panel to pad away from, so both are
   excluded there. */
.work-card--folder:not(.work-card--label-below) .work-folder__label {
	padding: 0 16px;
}
.work-card--folder:not(.work-card--label-below) .work-card__folder-view {
	padding-bottom: 16px;
}
/* Category, below the title and centred — same pill treatment as
   .work-card__tag on the classic card. Previously overlaid on the
   folder artwork itself (bottom-left corner); moved to sit with the
   title instead so both pieces of text share one consistent
   placement rather than the category floating separately on the
   icon. */
.work-folder__tag {
	display: inline-block;
	margin-top: 6px;
	padding: 3px 10px;
	border-radius: 999px;
	background: var(--color-text-subtle-bg);
	border: 1px solid var(--color-border);
	font-size: 0.72rem;
	color: var(--color-text-muted);
}
.work-folder__tag[data-tag-color] {
	background: color-mix(in srgb, var(--tag-color) 16%, var(--color-bg));
	border-color: color-mix(in srgb, var(--tag-color) 45%, transparent);
	color: color-mix(in srgb, var(--tag-color) 65%, var(--color-text));
}

/* Below Icon: strips the card's own chrome — a desktop icon doesn't
   sit inside a panel — since a soft rounded highlight behind icon +
   label on hover stands in for it instead, closer to how Finder
   highlights a selected icon than a card lifting up. Same 641px+
   breakpoint and reasoning as .work-card--folder above: this class
   only ever appears alongside it (caption position is a folder-only
   setting), and mobile below that needs the classic fallback's normal
   chrome left alone. */
@media (min-width: 641px) {
	.work-card--label-below {
		background: none;
		border: none;
		box-shadow: none;
		border-radius: 14px;
		padding: 10px;
		transition: background 0.25s var(--ease-standard);
	}
	.work-card--label-below:hover {
		background: var(--color-text-subtle-bg);
		box-shadow: none;
		transform: none;
	}
}
.work-card--label-below .work-folder {
	max-width: 168px;
	margin: 0 auto;
}

/* ---------- Trips: overlapping photos layout ----------
   Alternative to the classic card carousel above (see Customizer →
   Trips Layout). A 184×248 filmstrip of every trip, staggered
   left-to-right with a small vertical offset and a baked-in tilt, each
   in its own little white "polaroid" frame built from padding + a
   white background rather than requiring pre-framed image assets.
   Hovering scales the card up, straightens it, dims the other cards
   (same "spotlight" intent as .cardgrid, but via filter rather than
   opacity — see the comment on that rule below for why), and reveals
   the title + year beneath. Shadow and stacking don't change on
   hover — stacking is left to plain DOM order (later cards overlap
   earlier ones), no z-index juggling needed.

   Each card's horizontal position is index × --trips-photo-gap rather
   than a precomputed pixel value from PHP, so the gap (and therefore
   how much cards overlap) can be redeclared per breakpoint in one place
   and both the card position and the container's total width — also
   gap-relative — stay in sync automatically. Desktop/tablet use a tight
   165px gap (visible overlap, captions only shown one at a time on
   hover); mobile widens it since captions are always-on there instead
   (see the mobile block at the bottom) and adjacent ones would
   otherwise collide.

   Horizontally scrollable at every width. Native scrollbar hidden and
   replaced with the same small, centered .cardgrid-progress bar the
   Work/Trips card carousels use, wired up via the identical
   data-cardgrid/data-cardgrid-progress attributes. Click-and-drag
   scrolling reuses that same carousel's JS too (.trips-photos-wrap is
   included in its selector). overflow-x: auto still forces overflow-y
   to auto (a CSS mixed-axis rule), which is why .trips-photos is
   taller than just the 184×248 card — the extra height leaves room for
   the vertical stagger, the hover scale-up, and the caption below each
   card, so that forced auto never actually needs to clip any of it. */
/* Arrow pair in the section header, next to the Trips heading — same
   .cardgrid-arrow button styling as the classic Work/Trips carousels
   (reused directly, not redeclared). Desktop/tablet only: mobile hides
   this pair below, since dragging/swiping the strip is how mobile
   navigates it instead. */
.trips-photos-arrows {
	display: flex;
	gap: 8px;
	flex-shrink: 0;
}
.trips-photos-wrap {
	display: flex;
	justify-content: flex-start;
	overflow-x: auto;
	-webkit-overflow-scrolling: touch;
	scrollbar-width: none;
	-ms-overflow-style: none;
	cursor: grab;
	/* Bottom trimmed from 8px to 5px (a 40% cut, matching the same
	   reduction on .cardgrid-progress's own top margin below) — the two
	   together were reading as more gap before the progress bar than
	   intended. */
	padding: 20px 24px 5px;
	/* Fade the leading/trailing edges rather than clipping the last
	   photo flat — the same technique .work__grid uses for its own
	   carousel, as the "there's more to scroll" cue. Desktop/tablet
	   only: the mobile override below removes this, so photos there
	   scroll fully to the screen's true edges instead. */
	-webkit-mask-image: linear-gradient(to right, transparent 0, black 24px, black calc(100% - 24px), transparent 100%);
	mask-image: linear-gradient(to right, transparent 0, black 24px, black calc(100% - 24px), transparent 100%);
}
.trips-photos-wrap::-webkit-scrollbar { display: none; }
.trips-photos-wrap.is-drag-scrolling {
	cursor: grabbing;
	user-select: none;
}
.trips-photos {
	--trips-photo-gap: 165px;
	position: relative;
	flex-shrink: 0;
	/* (count − 1) gaps between cards, plus one card's own width. */
	width: calc((var(--trips-photo-count, 1) - 1) * var(--trips-photo-gap) + 184px);
	/* Covers the 184×248 card plus its ±8px vertical stagger plus room
	   below for the caption. Reliable now that the caption is a plain
	   sibling of the scaling .trips-photos__frame (see below) rather
	   than a child of it — previously the hover scale(1.1) transform
	   also scaled and pushed down the caption underneath it (transforms
	   apply to a whole element's rendered subtree), so a 2-line title
	   could push the year past this height and get clipped by the
	   overflow-y the wrap's overflow-x forces. Decoupling them means
	   this only has to fit the caption at its own unscaled size — a
	   real fix, not just a bigger guess. */
	height: 340px;
}
.trips-photos__item {
	position: absolute;
	top: var(--trips-photo-y, 0px);
	left: calc(var(--trips-photo-index, 0) * var(--trips-photo-gap));
	display: block;
	width: 184px;
	height: 248px;
	/* Base stacking order so later (higher-index) cards don't always
	   paint over earlier ones where they overlap — :hover/.is-current
	   below raises whichever card is selected above every other card
	   regardless of index. */
	z-index: 1;
}
/* The visual polaroid card — separated from .trips-photos__item so the
   hover scale/rotate transform below only ever affects the photo
   itself, never the caption sitting below it (a sibling of this, not a
   child — see .trips-photos__caption further down). */
.trips-photos__frame {
	position: relative;
	display: block;
	width: 100%;
	height: 100%;
	box-sizing: border-box;
	border-radius: 10px;
	/* The polaroid card itself: fixed off-white regardless of theme,
	   like a real printed photo, rather than following --color-bg-raised
	   (which would flip dark in dark mode and stop reading as paper). */
	background: #FAFAFA;
	/* Halved from 12px — the original bezel read as too thick. */
	padding: 6px;
	/* Exact values requested: a hairline border plus a soft, barely-
	   there ambient shadow — not the heavier drop shadow used
	   elsewhere on the site (kept as literal values rather than the
	   theme's --shadow-color, which is tuned for that heavier look). */
	box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 6px 12px rgba(0, 0, 0, 0.04);
	transform: rotate(var(--trips-photo-tilt, 0deg));
	/* Framer Motion drives the original via spring physics (JS, not a
	   CSS timing function); this cubic-bezier overshoots slightly past
	   its end value before settling, which reads as the same snappy,
	   slightly bouncy feel without needing a JS spring library for one
	   hover effect. */
	transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
	will-change: transform;
}
/* .is-current mirrors :hover for arrow-driven navigation (see
   main.js): clicking an arrow can't trigger a real :hover (nothing's
   actually under the pointer), so JS toggles this class onto whichever
   card the arrow just scrolled to instead, and every rule below that
   responds to :hover responds to .is-current identically. JS clears it
   the moment a real hover starts elsewhere (so the mouse always wins)
   or the page is scrolled. */
.trips-photos__item:hover,
.trips-photos__item.is-current {
	/* Pops the selected card above its (overlapping) neighbours on
	   every viewport — otherwise a card scaled up by hover/.is-current
	   could still get visually covered by a later, unselected card
	   sitting on top of it in DOM order. */
	z-index: 2;
}
.trips-photos__item:hover .trips-photos__frame,
.trips-photos__item.is-current .trips-photos__frame {
	transform: scale(1.1) rotate(0deg);
}
.trips-photos__image, .trips-photos__placeholder {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: cover;
	/* Nested-radius rule of thumb: inner radius = outer radius − inset,
	   so the two curves stay concentric instead of the inner one
	   looking flatter/sharper than the frame around it. Outer is 10px,
	   the frame's padding (inset) is 6px, so inner is 10 − 6 = 4px. */
	border-radius: 4px;
}
.trips-photos__placeholder {
	background: linear-gradient(135deg, var(--media-bg), var(--media-bg-2));
}
/* White-wash spotlight overlay, as a ::after on .trips-photos__frame
   rather than box-shadow/filter on the <img> — box-shadow (even inset)
   paints behind an <img>'s own pixels, not over them, since a replaced
   element's rendered content sits above its background/shadow layer;
   it never actually showed. A pseudo-element genuinely paints on top,
   since it's normal (non-replaced) content. inset: 6px matches the
   frame's own padding, so it covers exactly the photo, not the white
   frame around it — the frame doesn't need to change, it's already
   white. */
.trips-photos__frame::after {
	content: '';
	position: absolute;
	inset: 6px;
	border-radius: 4px;
	background: #fff;
	opacity: 0;
	transition: opacity 0.3s var(--ease-standard);
	pointer-events: none;
}
.trips-photos:has(> .trips-photos__item:hover, > .trips-photos__item.is-current) > .trips-photos__item:not(:hover):not(.is-current) .trips-photos__frame::after {
	opacity: 0.65;
}

/* Title + year. Positioned outside the card's own 184×248 box
   (top: 100%) rather than overlaid on top of the photo, so the photo
   itself stays unobstructed. Sits at opacity 0 rather than
   display:none/visibility:hidden so it's always in the accessibility
   tree, not just when visually hovered — a screen reader user isn't
   relying on a mouse hover to reach it. Hover-revealed on desktop/
   tablet; always visible on mobile instead (see that block below). */
.trips-photos__caption {
	position: absolute;
	top: 100%;
	left: 0;
	width: 100%;
	padding-top: 10px;
	text-align: center;
	opacity: 0;
	transform: translateY(-4px);
	transition: opacity 0.22s var(--ease-standard), transform 0.22s var(--ease-standard);
	pointer-events: none;
}
.trips-photos__item:hover .trips-photos__caption,
.trips-photos__item:focus-visible .trips-photos__caption,
.trips-photos__item.is-current .trips-photos__caption {
	opacity: 1;
	transform: translateY(0);
}
.trips-photos__caption-title {
	display: block;
	font-family: var(--font-display);
	font-size: 0.85rem;
	font-weight: 600;
	color: var(--color-text);
	line-height: var(--leading-snug);
	/* Full title, wrapping rather than truncating — no
	   white-space/text-overflow clipping here on purpose. */
}
/* Lighter font, as asked — muted colour and regular weight rather than
   the title's semibold, plus a smaller size, so it reads as secondary
   to the title above it. */
.trips-photos__caption-year {
	display: block;
	margin-top: 2px;
	font-size: 0.78rem;
	font-weight: 400;
	color: var(--color-text-muted);
}

/* Mobile: touch devices can't cleanly hover (the effect would just
   stick after a tap), matching the same reasoning already applied to
   the classic work/trip cards elsewhere — so hover, the spotlight dim,
   and the hover-reveal are all turned off here. In their place,
   captions are always visible instead of hover-only.

   Gap is tightened from the 220px this was previously widened to,
   down to 175px — still looser than desktop's 165px so multiple
   always-visible captions have a fighting chance of not colliding, but
   noticeably closer than before. This is a judgement call, not a
   measured-safe number: centred caption text is usually narrower than
   its 184px box, so some card overlap doesn't necessarily mean the
   *text* overlaps too, but a run of unusually long titles could still
   touch. Scrolling itself stays active at every width via the base
   rules above; the progress bar, hidden on mobile for the classic
   carousels, is switched back on here since there's no "View more"
   button in this layout to fall back on instead. */
@media (max-width: 640px) {
	/* Header arrow pair is desktop/tablet-only — mobile navigates by
	   dragging/swiping the strip itself instead (the progress bar below
	   is the "there's more" cue there). */
	.trips-photos-arrows {
		display: none;
	}
	/* No edge fade on mobile, unlike desktop/tablet — photos scroll
	   fully to (and off) the screen's true edges instead of fading out
	   just before it. Right padding removed too, so the last photo can
	   actually reach the right edge when scrolled all the way; the
	   wrap's own left padding is kept, becoming the only remaining
	   inset for the first photo once the breakout below removes
	   .section's page-level padding.
	   Full-bleed breakout: same margin/width technique as .alignfull in
	   post content elsewhere in this file, so this element alone
	   ignores both .section's 24px side padding and .section__inner's
	   max-width, reaching the screen's true edges — mobile only, as
	   asked; nothing else in the section (heading, arrows, progress
	   bar) is affected. */
	.trips-photos-wrap {
		width: auto;
		max-width: 100vw;
		margin-left: calc(50% - 50vw);
		margin-right: calc(50% - 50vw);
		padding-right: 0;
		-webkit-mask-image: none;
		mask-image: none;
	}
	.trips-photos {
		--trips-photo-gap: 175px;
	}
	/* Desktop/tablet raise the active card's z-index above its
	   neighbours (see the base rule above) — on mobile that reads as
	   clunky, so this resets it back to the shared base tier. */
	.trips-photos__item:hover,
	.trips-photos__item.is-current {
		z-index: 1;
	}
	.trips-photos__item:hover .trips-photos__frame {
		transform: rotate(var(--trips-photo-tilt, 0deg));
	}
	.trips-photos:has(> .trips-photos__item:hover, > .trips-photos__item.is-current) > .trips-photos__item:not(:hover):not(.is-current) .trips-photos__frame::after {
		opacity: 0;
	}
	/* .is-current still scales up on mobile — only :hover's scale is
	   cancelled just above, since mobile has no arrows to hover with.
	   main.js instead tracks whichever card the user has scrolled/
	   dragged nearest to and marks that one .is-current. Scaled down
	   from the base rule's 1.1x to 1.06x: a photo staggered upward
	   (--trips-photo-y: -8px) has less headroom above it before
	   hitting .trips-photos-wrap's own clipping edge (overflow-y is
	   forced to auto by the mixed-axis rule on overflow-x), and 1.1x
	   was just enough to clip its top on mobile. */
	.trips-photos__item.is-current .trips-photos__frame {
		transform: scale(1.06) rotate(0deg);
	}
	/* Immediate neighbours get a smaller bump too, so the active card
	   doesn't pop against flat 1x cards either side — a gentle taper
	   back to normal size rather than an abrupt jump. A 4% bump is
	   subtle enough that it doesn't need its own stacking tier above
	   the shared base z-index. */
	.trips-photos__item.is-current + .trips-photos__item .trips-photos__frame,
	.trips-photos__item:has(+ .trips-photos__item.is-current) .trips-photos__frame {
		transform: scale(1.04) rotate(var(--trips-photo-tilt, 0deg));
	}
	.trips-photos__caption {
		opacity: 1;
		transform: translateY(0);
	}

	[data-cardgrid-progress="trips-photos"] {
		display: block;
		position: relative;
		max-width: 240px;
		height: 5px;
		/* 40% cut from 12px, matching the desktop override below. */
		margin: 7px auto 0;
		border-radius: 999px;
		background: var(--color-border);
		overflow: hidden;
		cursor: grab;
	}
	[data-cardgrid-progress="trips-photos"][hidden] { display: none; }
	[data-cardgrid-progress="trips-photos"].is-dragging { cursor: grabbing; }
	[data-cardgrid-progress="trips-photos"] .cardgrid-progress__fill {
		position: absolute;
		top: 0;
		left: 0;
		height: 100%;
		min-width: 40px;
		border-radius: 999px;
		background: var(--color-text-muted);
		transition: transform 0.2s var(--ease-standard);
		pointer-events: none;
	}
	[data-cardgrid-progress="trips-photos"].is-dragging .cardgrid-progress__fill {
		transition: none;
	}
}

/* Desktop/tablet: .cardgrid-progress's own margin-top (20px, shared
   with the classic Work/Trips carousels — see that rule) cut by 40%
   for this instance specifically, without touching the shared value
   those carousels still use. */
@media (min-width: 641px) {
	[data-cardgrid-progress="trips-photos"] {
		margin-top: 12px;
	}
	/* Hover/.is-current scales .trips-photos__frame up to 1.1x from its
	   own center, so on hover ~12px of the frame extends below its
	   original unscaled edge — more than the base 10px padding-top
	   could clear, so the frame overlapped the caption underneath it.
	   Mobile doesn't need the extra clearance: hover there never scales
	   the frame (see the mobile override above), so it keeps the
	   tighter base value. */
	.trips-photos__caption {
		padding-top: 22px;
	}
	/* Same 1.1x hover scale also grows the frame ~12px above its own
	   unscaled top edge — for a card staggered upward
	   (--trips-photo-y: -8px), that reaches to about -20px, just past
	   the wrap's base 20px padding-top, clipping it slightly (the
	   forced overflow-y: auto from overflow-x: auto has no slack to
	   spare). Same fix as .work__grid's own hover clearance: extra
	   padding-top, matched by an equal negative margin-top so the
	   wrap's visual position/gap above the photos doesn't shift —
	   only the invisible clipping headroom increases. Desktop/tablet
	   only: mobile's own hover scale is already reduced (see
	   .trips-photos__item.is-current .trips-photos__frame in the
	   mobile block above) specifically so it doesn't need this. */
	.trips-photos-wrap {
		padding-top: 40px;
		margin-top: -20px;
	}
}

/* ---------- Blog (writing list) ---------- */
/* Wraps .blog__list and the "View more" button together so the button
   centers against the list's own narrower column, not the wider
   .section__inner it sits in (which is left-flush at --wide-width,
   leaving room for e.g. a sidebar) — otherwise margin:auto on the
   button alone centers it against the full section width instead. */
.blog__col {
	max-width: var(--content-width);
}
.blog__list {
	border-top: 1px solid var(--color-border);
	opacity: 0;
	transition: opacity 1.4s var(--ease-standard);
}
.blog__list.is-revealed { opacity: 1; }
.blog-row {
	display: flex;
	align-items: center;
	gap: 16px;
	padding: 18px 12px;
	border-bottom: 1px solid var(--color-border);
	position: relative;
	isolation: isolate;
}
.blog-row::before {
	content: '';
	position: absolute;
	inset: 4px 0;
	border-radius: 6px;
	background: transparent;
	transition: background 0.2s var(--ease-standard);
	z-index: -1;
}
.blog-row:hover::before {
	background: var(--color-text-subtle-bg);
}
.blog-row__icon {
	flex-shrink: 0;
	width: 40px;
	height: 40px;
}
.blog-row__icon svg { display: block; width: 100%; height: 100%; }
.blog-row__title { flex: 1; min-width: 0; font-weight: 500; line-height: var(--leading-snug); }
.blog-row__tag { flex-shrink: 0; white-space: nowrap; }
.blog-row__date { flex-shrink: 0; font-size: 0.85rem; color: var(--color-text-muted); white-space: nowrap; }
/* Mobile: drop the date entirely rather than shrink it further — with
   it gone, the tag (already the last flex child, after the flexible
   title) lands flush at the row's right edge on its own. */
@media (max-width: 640px) {
	.blog-row__date { display: none; }
}

/* ---------- Testimonials ---------- */
.testimonials__grid {
	/* CSS multi-column (the previous approach here) balances columns by
	   total content height, not row position — it doesn't guarantee
	   the first item in column 1 and the first item in column 2 start
	   at the same height, and how far off they end up depends on how
	   the quote text happens to wrap at a given width. That's exactly
	   why this looked fine on some viewports and not others: it was
	   never actually guaranteed anywhere, just coincidentally close at
	   certain widths. Grid guarantees row-by-row top alignment between
	   both columns on every viewport, deterministically. */
	display: grid;
	grid-template-columns: repeat( 2, 1fr );
	column-gap: 24px;
	row-gap: 28px;
}
.testimonials__empty { color: var(--color-text-muted); }
.testimonials__empty a { color: var(--color-accent); text-decoration: underline; }

.testimonial-item {
	min-width: 0;
}

.testimonial-item__row {
	display: flex;
	align-items: flex-start;
	gap: 14px;
}

.testimonial-item__avatar-col {
	flex-shrink: 0;
	padding-top: 4px; /* nudges avatar to align with the card's top padding */
}
.testimonial-item__avatar {
	width: 40px;
	height: 40px;
	border-radius: 50%;
	object-fit: cover;
	display: block;
}
.testimonial-item__avatar--placeholder {
	display: flex;
	align-items: center;
	justify-content: center;
	background: var(--media-bg);
	color: var(--color-text-muted);
	font-family: var(--font-display);
	font-weight: 600;
	font-size: 0.95rem;
}

/* The bordered "card" now holds only the quote. */
.testimonial-card {
	flex: 1;
	min-width: 0;
	padding: 22px 24px;
	border-radius: 18px;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.testimonial-card__quote {
	color: var(--color-text);
	line-height: 1.6;
}
.testimonial-card__quote p + p { margin-top: 1em; }

/* Name/role sits below the row, outside the card, indented to align
   with the quote text inside the card (avatar width + row gap + the
   card's own left padding), not just the card's outer edge. */
.testimonial-item__author {
	margin-top: 12px;
	margin-left: calc(40px + 14px + 24px); /* avatar + row gap + card padding */
	font-size: 0.88rem;
	color: var(--color-text-muted);
}
.testimonial-item__name { font-weight: 500; }

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

/* ---------- Contact ---------- */
.contact__row {
	display: grid;
	grid-template-columns: 1fr;
	gap: 32px;
}
@media (min-width: 641px) {
	/* Tablet and desktop share this layout — heading on the left, form
	   on the right, top-aligned so the form starts level with the
	   heading rather than centering against its full height. */
	.contact__row {
		grid-template-columns: 1fr minmax(320px, 480px);
		gap: 48px;
		align-items: start;
	}
}
.contact__heading-group { min-width: 0; }
.contact__email {
	display: inline-block;
	margin-top: 8px;
	font-family: var(--font-display);
	font-size: clamp(1.4rem, 3vw, 2rem);
	font-weight: 600;
	letter-spacing: -0.01em;
	border-bottom: 1.5px solid var(--color-accent);
	padding-bottom: 4px;
}
.contact__email:hover { color: var(--color-accent); }
.contact__form-col { min-width: 0; }

@media (max-width: 640px) {
	/* Two things were contributing to an oversized gap above the form
	   on mobile: the row's own gap, and — the bigger factor —
	   .section__subheading's normal 40px bottom margin, which is a
	   shared rule used by every section to create space before a
	   following grid/card layout. That's overridden here specifically
	   rather than changed globally, since Contact's heading sits
	   directly above the form rather than a grid. */
	.contact__heading-group .section__subheading { margin-bottom: 8px; }
	.contact__heading-group .section__title { margin-bottom: 4px; }
}

/* Contact form (CF7). CF7 ships with no styling of its own, so
   everything below is needed to match the rest of the site:
   inputs/textarea echo the card/chip border-radius and border color
   already used elsewhere, and the submit button reuses the same pill
   shape as .cardgrid-more. */
.wpcf7-form p {
	margin-bottom: 18px;
}
.wpcf7-form p:last-child {
	margin-bottom: 0;
}
.wpcf7-form label {
	display: block;
	font-family: var(--font-body);
	font-size: 0.85rem;
	font-weight: 600;
	color: var(--color-text-muted);
	margin-bottom: 6px;
}
/* CF7's form template has the label and field tag on separate lines
   (for readability when editing), which WordPress's wpautop() then
   turns into a literal <br> between them — on top of the margin above,
   that's what was showing as an extra gap under each field title. */
.wpcf7-form label + br {
	display: none;
}
.wpcf7-form input[type="text"],
.wpcf7-form input[type="email"],
.wpcf7-form textarea {
	width: 100%;
	font-family: var(--font-body);
	font-size: 1rem;
	color: var(--color-text);
	background: var(--color-bg-raised);
	border: 1px solid var(--color-border);
	border-radius: 10px;
	padding: 12px 14px;
	transition: border-color 0.25s var(--ease-standard);
}
.wpcf7-form textarea {
	/* CF7's textarea tag defaults to rows="10" — overriding with an
	   explicit height here bypasses that regardless of what's set in
	   the form editor. 5 lines at the site's 1.6 line-height (16px ×
	   1.6 = 25.6px/line) plus this field's own 24px padding + 2px
	   border ≈ 154px. resize:vertical still lets the field be dragged
	   taller by hand if needed. */
	height: 154px;
	resize: vertical;
}
.wpcf7-form input[type="text"]:focus,
.wpcf7-form input[type="email"]:focus,
.wpcf7-form textarea:focus {
	outline: none;
	border-color: var(--color-text-muted);
}
.wpcf7-form input.wpcf7-not-valid,
.wpcf7-form textarea.wpcf7-not-valid {
	border-color: #E25555;
}
.wpcf7-form .wpcf7-not-valid-tip {
	font-size: 0.8rem;
	color: #E25555;
	margin-top: 4px;
}
.wpcf7-form input[type="submit"] {
	display: inline-block;
	margin-top: 4px;
	padding: 12px 28px;
	border-radius: 999px;
	background: var(--color-text);
	color: var(--color-bg);
	border: none;
	font-family: var(--font-body);
	font-size: 0.9rem;
	font-weight: 600;
	cursor: pointer;
	transition: opacity 0.25s var(--ease-standard);
}
.wpcf7-form input[type="submit"]:hover { opacity: 0.85; }
.wpcf7-form input[type="submit"]:disabled { opacity: 0.5; cursor: not-allowed; }
.wpcf7-spinner { margin-left: 10px; }
.wpcf7-response-output {
	margin-top: 16px;
	padding: 12px 16px;
	border-radius: 10px;
	border: 1px solid var(--color-border);
	font-size: 0.9rem;
}
.wpcf7-form.sent .wpcf7-response-output { border-color: #2E9E5B; color: #2E9E5B; }
.wpcf7-form.failed .wpcf7-response-output,
.wpcf7-form.aborted .wpcf7-response-output { border-color: #E25555; color: #E25555; }

/* ===================================================================
   Single templates (blog post + case study)
   =================================================================== */
.single-wrap { padding-top: calc(var(--nav-height) + 40px); }

.post-article, .case-study {
	max-width: var(--content-width);
	margin: 0 auto;
	padding: 0 24px var(--space-section);
}
/* Work case studies specifically: on mobile, --space-section clamps
   down to its 40px floor, which is enough breathing room on a Trip or
   Blog post (both typically long enough that there's plenty of scroll
   distance before reaching the end) but not on a short case study —
   .post-article__back only lifts to clear the *footer* as you approach
   it (see updateTabletToggleOffset() in main.js), not the article's
   own last line, so on a page barely taller than one screen the fixed
   button can still end up sitting over that last line at its default
   24px-from-bottom position, well before the footer is close enough to
   trigger that lift. Scoped to Work only — Trips/Blog already have
   enough natural length that this hasn't been an issue there. */
@media (max-width: 640px) {
	.single-portfolio .case-study {
		padding-bottom: 65px;
	}
}
/* Persistent "back" control: fixed bottom-left so it stays reachable
   the whole time the visitor scrolls the page, rather than living
   inline at the top of the article where it scrolls out of view after
   the first screenful. Same glass-pill chrome as the theme toggle /
   social rail fixed elements (see .side-rail above) for visual
   consistency with the rest of the site's persistent UI.
   --toggle-bottom is the same footer-clearance variable the tablet
   theme toggle uses (kept live by main.js), so this lifts in step with
   it above the footer rather than overlapping it — see the
   ".theme-toggle--tablet" comment near .side-rail for how that's
   computed. */
.post-article__back {
	position: fixed;
	/* Default: hug the viewport edge, same inset as the rest of the
	   site's mobile/tablet-portrait fixed chrome. Only realigned to the
	   theme toggle's column above 920px, where .side-rail is actually
	   visible to align with (it's display:none below that) — otherwise
	   the toggle's offset formula, tuned for wide viewports, pulls the
	   button in from the edge and it reads as pushed into the page. */
	left: 24px;
	bottom: var(--toggle-bottom, 24px);
	z-index: 90;
	display: inline-flex;
	align-items: center;
	gap: 6px;
	padding: 8px 16px 8px 12px;
	border-radius: 999px;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
	font-size: 0.875rem;
	font-weight: 500;
	/* Muted grey at rest, same as .site-nav__link and the theme toggle's
	   inactive option — near-black (--color-text) is reserved for
	   hover/active states across the site's fixed chrome, so using it
	   here at rest made this pill read heavier than its neighbours. The
	   icon (stroke="currentColor") follows automatically. */
	color: var(--color-text-muted);
	animation: gathercole-intro 0.5s var(--ease-standard) 0.15s both;
	transition: background 0.25s var(--ease-standard), color 0.25s var(--ease-standard), top 0.2s var(--ease-standard), bottom 0.2s var(--ease-standard), left 0.2s var(--ease-standard), opacity 0.15s var(--ease-standard);
}
/* Tablet and desktop: pinned near the top, roughly level with the post
   heading, instead of the bottom-left corner — same left inset as
   before (unchanged here; see the 921px block below for desktop's own
   override), just anchored to the top of the viewport rather than the
   bottom. bottom: auto is required alongside top — a fixed element
   with height:auto and BOTH top and bottom set stretches to fill the
   space between them rather than keeping its natural pill height.
   Mobile keeps the original bottom-anchored position (not touched
   here), where --toggle-bottom's footer-clearance logic still applies. */
@media (min-width: 641px) {
	.post-article__back {
		top: calc(var(--nav-height) + 40px + 28px);
		bottom: auto;
	}
}
/* Realign to the theme toggle's own left offset only once .side-rail
   is actually visible (it's hidden below 920px, see that rule above),
   so the two form one deliberate column instead of the button
   floating at an unrelated inset.

   The toggle's own formula (--side-rail--left, above) positions ITS
   outer edge, but the toggle's padding (4px) is much thinner than
   this pill's own left padding + border (12px + 1px = 13px) — applying
   the identical value here left this pill's icon (inset 13px from its
   own edge) sitting where the toggle's edge is, not this pill's own
   edge. Adding that 13px shifts this pill's own outer edge to line up
   with the toggle's outer edge instead, per what was actually wanted. */
@media (min-width: 921px) {
	.post-article__back {
		left: calc(max(64px, calc(50% - (var(--wide-width) / 2) - 38px - 25px)) + 13px);
	}
}
.post-article__back-icon {
	width: 14px;
	height: 14px;
	flex-shrink: 0;
	transition: transform 0.25s var(--ease-standard);
}
.post-article__back:hover { background: var(--glass-border); color: var(--color-text); }
.post-article__back:hover .post-article__back-icon { transform: translateX(-2px); }
/* Tactile press feedback via opacity rather than transform: the
   on-load entrance animation above already owns this element's
   transform (translateY, held via fill-mode: both once it finishes),
   and an animated property's held value takes precedence over any
   other transform declaration on the same element — so a :active
   scale() here would be silently ignored, the same bug just fixed on
   .article-cover's centering transform. Opacity has no such conflict. */
.post-article__back:active { opacity: 0.75; }

/* Gap before whatever follows (the cover image on a case study, or
   straight into the content on trip posts, which have no cover). Lives
   on the header as a whole rather than on whichever child happens to
   render last (tags on a case study, location on a trip, or the
   dateline alone when neither is present), so the gap stays consistent
   regardless of which optional element is showing. Blog posts
   (.post-article__header) aren't included here — they already got
   their spacing to the cover/content from .article-cover's own
   margin-top, unchanged from before this header rework. */
.case-study__header { margin-bottom: 24px; }

/* Inner-page load choreography for the header block: the dateline,
   title, and tags/location line drift up into place on load one beat
   apart rather than appearing as one flat block. */
.case-study__header > *,
.post-article__header > * {
	animation: gathercole-intro 0.5s var(--ease-standard) both;
}
.case-study__header > *:nth-child(1), .post-article__header > *:nth-child(1) { animation-delay: 0.12s; }
.case-study__header > *:nth-child(2), .post-article__header > *:nth-child(2) { animation-delay: 0.24s; }
.case-study__header > *:nth-child(3), .post-article__header > *:nth-child(3) { animation-delay: 0.36s; }

/* Case study / trip header: a quiet dateline, then the title, then
   whatever identifies the post (project-type tags, or the trip
   location) — three simple stacked lines rather than trying to fit a
   plain-text date on the same row as a cluster of coloured tag pills.
   Mixing those two different visual "weights" (bordered badges vs.
   plain muted text) on one flex row was what made the previous layout
   read as unbalanced; stacking gives each its own clear moment and
   needs no responsive row-wrapping logic to stay tidy at any width.
   Blog posts (.post-article__header) keep the older title-then-meta
   order — they don't have a tag/location line to separate the date
   from, so there's nothing to rebalance there. */
.case-study__header > .case-study__meta {
	margin-bottom: 10px;
}
.post-article__title, .case-study__title {
	font-family: var(--font-display);
	font-size: clamp(1.8rem, 4vw, 2.6rem);
	font-weight: 600;
	letter-spacing: -0.02em;
	line-height: 1.15;
	margin-bottom: 14px;
}
/* Case study / trip only: tighter than the 14px default above, so the
   title reads as grouped with the tags/location directly below it.
   The 10px dateline-to-title gap above is deliberately left larger by
   comparison — the date is the less-related piece of the three, so it
   gets the bit more breathing room. Blog post titles keep the 14px
   default via the shared rule above; this doesn't touch them. */
.case-study__header .case-study__title {
	margin-bottom: 6px;
}
.post-article__meta, .case-study__meta {
	font-size: 0.9rem;
	color: var(--color-text-muted);
}
.post-article__meta span + span { margin-left: 8px; }

/* Trips only: the dateline/title/location block at the top of the
   page is centered by default — a deliberate difference from Work's
   single project pages, which share this exact .case-study__header
   markup (see single-portfolio.php), so this is scoped via the
   single-trip body class WordPress adds automatically rather than
   touching the shared rules above and affecting Work too. A per-trip
   "Keep title & date left-aligned" checkbox (Trip Details, in the
   editor) opts back into the original layout via
   .case-study--align-left — see single-trip.php and
   gathercole_trip_meta_render() in inc/custom-post-types.php. */
.single-trip .case-study__header {
	text-align: center;
}
.single-trip .case-study__header .case-study__tags {
	justify-content: center;
}
.single-trip .case-study--align-left .case-study__header {
	text-align: left;
}
.single-trip .case-study--align-left .case-study__header .case-study__tags {
	justify-content: flex-start;
}

/* Trip Details → "Fit page to one screen" (see body.trip-fit-screen and
   gathercole_trip_fit_screen_body_class() in inc/custom-post-types.php)
   — designed for exactly one content shape: title, date, location, and
   a single Trip Gallery block, nothing else. Rather than trying to
   shrink arbitrary content to fit (which would make real body text
   unreadably small), this turns the nav-to-footer stretch of the page
   into a flex column exactly one screen tall, gives the header its
   natural size, and lets the Trip Gallery deck grow/shrink to fill
   whatever's left — sizeCard() in main.js already fits each photo to
   the deck's actual rendered box, whatever size that turns out to be,
   so no JS changes were needed for the deck to cooperate with this.
   Desktop/tablet only: mobile's nav + header already use a meaningful
   share of a phone screen's height, leaving too little room for a
   gallery to be worth squeezing into what remains. */
@media (min-width: 641px) {
	body.trip-fit-screen .single-wrap {
		height: 100vh;
		height: 100dvh;
		overflow: hidden;
		display: flex;
		flex-direction: column;
	}
	body.trip-fit-screen .single-wrap .case-study {
		flex: 1 1 auto;
		min-height: 0;
		display: flex;
		flex-direction: column;
		/* Reclaims the space --space-section would otherwise reserve
		   below the content — with no more sections to separate from,
		   that gap only ever ate into the gallery's own share here. */
		padding-bottom: 24px;
	}
	body.trip-fit-screen .case-study__header {
		flex: 0 0 auto;
		/* Cut from the normal 24px — every pixel reclaimed here is a
		   pixel the gallery below gets to grow into instead. */
		margin-bottom: 8px;
	}
	body.trip-fit-screen .case-study__content {
		flex: 1 1 auto;
		min-height: 0;
		display: flex;
		flex-direction: column;
	}
	/* Targets the Trip Gallery block specifically (rather than every
	   direct child of .case-study__content) so a stray empty <p> the
	   block editor sometimes leaves around block markup never competes
	   with it for flex space. */
	body.trip-fit-screen .case-study__content .trip-swipe {
		flex: 1 1 auto;
		min-height: 0;
		max-width: none;
		width: 100%;
		margin: 0 auto;
		display: flex;
		flex-direction: column;
	}
	body.trip-fit-screen .trip-swipe__stage {
		flex: 1 1 auto;
		min-height: 0;
		display: flex;
	}
	/* aspect-ratio and max-height are both meant for the normal
	   (scrollable) layout, where the deck's own width decides its
	   height — here it's the other way around: flex has already
	   decided the deck's height (whatever's left of the screen), so
	   the aspect ratio needs to step aside and let the deck fill that
	   box in both dimensions instead. */
	body.trip-fit-screen .trip-swipe__deck {
		flex: 1 1 auto;
		min-height: 0;
		width: 100%;
		aspect-ratio: unset;
		max-height: none;
	}
	body.trip-fit-screen .trip-swipe__footer {
		flex: 0 0 auto;
		/* Cut from the normal 14px, same reasoning as the header's
		   margin-bottom above — .trip-swipe__deck is what actually grows
		   to fill the screen, so shrinking every other flex sibling's
		   own footprint here directly hands it more room. */
		margin-top: 6px;
	}
}

.post-article__content, .case-study__content {
	color: var(--color-text);
}
.post-article__content > * + *, .case-study__content > * + * { margin-top: 1.3em; }
.post-article__content h2, .case-study__content h2 {
	font-family: var(--font-display);
	font-size: 1.4rem;
	font-weight: 600;
	line-height: var(--leading-snug);
	margin-top: 2em;
}
.post-article__content img, .case-study__content img { border-radius: 12px; }
.post-article__content a, .case-study__content a {
	color: var(--color-accent);
	text-decoration: underline;
	text-underline-offset: 2px;
}
.post-article__content blockquote {
	border-left: 2px solid var(--color-accent);
	padding-left: 20px;
	color: var(--color-text-muted);
}

/* Mac terminal-style code blocks in post content.
   ::before on <pre> renders the traffic-light dot row.
   The body bg shifts slightly between modes so the terminal
   reads as a distinct surface without being jarring. */
.post-article__content pre,
.case-study__content pre {
	position: relative;
	background: #1d2021;
	border-radius: 10px;
	overflow: hidden;
	margin-top: 1.5em;
	box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
}
html[data-theme="dark"] .post-article__content pre,
html[data-theme="dark"] .case-study__content pre {
	background: #111213;
}
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) .post-article__content pre,
	html:not([data-theme]) .case-study__content pre {
		background: #111213;
	}
}
/* Traffic light dots */
.post-article__content pre::before,
.case-study__content pre::before {
	content: '';
	display: block;
	height: 36px;
	background: #2a2d2e;
	background-image:
		radial-gradient(circle at 16px 50%, #ff5f57 5.5px, transparent 5.5px),
		radial-gradient(circle at 36px 50%, #ffbd2e 5.5px, transparent 5.5px),
		radial-gradient(circle at 56px 50%, #28ca41 5.5px, transparent 5.5px);
}
html[data-theme="dark"] .post-article__content pre::before,
html[data-theme="dark"] .case-study__content pre::before {
	background: #1a1c1d;
	background-image:
		radial-gradient(circle at 16px 50%, #ff5f57 5.5px, transparent 5.5px),
		radial-gradient(circle at 36px 50%, #ffbd2e 5.5px, transparent 5.5px),
		radial-gradient(circle at 56px 50%, #28ca41 5.5px, transparent 5.5px);
}
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) .post-article__content pre::before,
	html:not([data-theme]) .case-study__content pre::before {
		background: #1a1c1d;
		background-image:
			radial-gradient(circle at 16px 50%, #ff5f57 5.5px, transparent 5.5px),
			radial-gradient(circle at 36px 50%, #ffbd2e 5.5px, transparent 5.5px),
			radial-gradient(circle at 56px 50%, #28ca41 5.5px, transparent 5.5px);
	}
}
.post-article__content pre code,
.case-study__content pre code {
	display: block;
	padding: 16px 20px 20px;
	overflow-x: auto;
	font-family: 'SF Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
	font-size: 0.85rem;
	line-height: 1.65;
	color: #abb2bf;
	background: transparent;
	border-radius: 0;
}
/* Highlight.js adds .hljs to the code element and applies its own
   background and padding from the theme CSS. Override both so the
   terminal shell background and our padding win. The syntax colours
   from atom-one-dark are kept — only the container styles are reset. */
.post-article__content pre code.hljs,
.case-study__content pre code.hljs {
	background: transparent;
	padding: 16px 20px 20px;
}
/* Inline code (not inside pre) — subtle pill with theme-aware bg */
.post-article__content code,
.case-study__content code {
	font-family: 'SF Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
	font-size: 0.875em;
	background: var(--color-text-subtle-bg);
	border-radius: 4px;
	padding: 2px 6px;
}

/* ---------- Article cover (featured image banner) ----------
   Sits between the header and the content, nested inside the same
   720px-wide .post-article/.case-study column but deliberately broken
   out wider — same margin-left:50%/translateX(-50%) centering trick
   used by .alignwide below, just with its own fixed max-width instead
   of a CSS variable, since this isn't a block-editor alignment class. */
.article-cover {
	position: relative;
	width: min(calc(100vw - 48px), var(--wide-width));
	margin-left: 50%;
	transform: translateX(-50%);
	margin-top: 28px;
	margin-bottom: 40px;
	border-radius: 20px;
	overflow: hidden;
	box-shadow: 0 8px 32px var(--shadow-color);
	background: linear-gradient(135deg, var(--media-bg), var(--media-bg-2));
	/* Fade-only entrance (gathercole-intro-fade), NOT the translateY
	   variant: this element already relies on a static transform
	   (translateX(-50%) above) to center itself. A translateY-animating
	   keyframe replaces the whole computed transform value rather than
	   combining with it, which silently deletes the centering transform
	   and sends the cover sliding off to the right — same reason
	   .side-rail below uses the fade-only variant for its own
	   translateY(-50%) centering. For the same reason this element
	   deliberately does NOT carry [data-reveal]: that system's default
	   variant also transitions transform on scroll-reveal. */
	animation: gathercole-intro-fade 0.6s var(--ease-standard) 0.58s both;
}
/* Skeleton shimmer sweep, shown while the real photo is still loading.
   Sits over the --media-bg/--media-bg-2 placeholder gradient set on
   .article-cover above, so a slow fetch reads as an intentional loading
   state rather than a blank flash before the image pops in. Faded out
   by .is-loaded (toggled in main.js once the <img> has decoded). A light
   diagonal sheen reads fine against that gradient in both themes (dark
   mode's pair is already a mid-dark grey, not near-black), so this
   needs no separate dark-mode override. */
.article-cover::before {
	content: '';
	position: absolute;
	inset: 0;
	background: linear-gradient(100deg, transparent 32%, rgba(255, 255, 255, 0.32) 50%, transparent 68%);
	background-size: 200% 100%;
	animation: gathercole-shimmer 1.6s ease-in-out infinite;
	opacity: 1;
	transition: opacity 0.3s var(--ease-standard);
	pointer-events: none;
}
.article-cover.is-loaded::before { opacity: 0; }
@keyframes gathercole-shimmer {
	from { background-position: 150% 0; }
	to   { background-position: -50% 0; }
}
.article-cover__image {
	display: block;
	width: 100%;
	aspect-ratio: 16 / 9;
	object-fit: cover;
	opacity: 0;
	transition: opacity 0.5s var(--ease-standard);
}
.article-cover.is-loaded .article-cover__image { opacity: 1; }

/* ---------- Rich content: headings, lists, tables, quotes, dividers,
   captions — covers the default block editor's standard block set, so
   a case study or post built entirely from core WordPress blocks (no
   custom fields/blocks needed) still looks intentional rather than
   falling back to unstyled browser defaults. ---------- */
.post-article__content h3, .case-study__content h3 {
	font-family: var(--font-display);
	font-size: 1.15rem;
	font-weight: 600;
	line-height: var(--leading-snug);
	margin-top: 1.75em;
}
.post-article__content h4, .case-study__content h4 {
	font-family: var(--font-display);
	font-size: 1rem;
	font-weight: 600;
	line-height: var(--leading-snug);
	margin-top: 1.5em;
}
/* The very first paragraph (when content genuinely starts with one)
   reads as a lede — a small, free bit of editorial polish that needs
   nothing from the author beyond typing normally. No-ops harmlessly
   if the post opens with an image/heading/etc. instead. */
.post-article__content > p:first-child,
.case-study__content > p:first-child {
	font-size: 1.15rem;
	line-height: var(--leading-snug);
	color: var(--color-text);
}
.post-article__content ul, .case-study__content ul {
	list-style: disc;
	padding-left: 1.3em;
}
.post-article__content ol, .case-study__content ol {
	list-style: decimal;
	padding-left: 1.3em;
}
.post-article__content li, .case-study__content li {
	line-height: var(--leading-normal);
}
.post-article__content li + li, .case-study__content li + li {
	margin-top: 0.4em;
}
.post-article__content hr, .case-study__content hr {
	border: none;
	border-top: 1px solid var(--color-border);
	margin: 2.5em 0;
}
.post-article__content figcaption, .case-study__content figcaption {
	margin-top: 8px;
	font-size: 0.85rem;
	line-height: var(--leading-normal);
	color: var(--color-text-muted);
	text-align: center;
}
.post-article__content blockquote cite, .case-study__content blockquote cite {
	display: block;
	margin-top: 8px;
	font-style: normal;
	font-size: 0.85rem;
	color: var(--color-text-muted);
}
/* Pullquote block — deliberately more dramatic than a plain Quote:
   centered, rule-bounded, display-sized text, so choosing "Pullquote"
   vs. "Quote" in the editor actually reads as two different things. */
.post-article__content .wp-block-pullquote, .case-study__content .wp-block-pullquote {
	margin: 2.5em 0;
	padding: 32px 0;
	border-top: 2px solid var(--color-text);
	border-bottom: 2px solid var(--color-text);
	text-align: center;
}
.post-article__content .wp-block-pullquote blockquote, .case-study__content .wp-block-pullquote blockquote {
	border-left: none;
	padding-left: 0;
	color: var(--color-text);
	font-family: var(--font-display);
	font-size: 1.5rem;
	font-weight: 500;
	line-height: var(--leading-snug);
	letter-spacing: -0.01em;
}
.post-article__content .wp-block-pullquote cite, .case-study__content .wp-block-pullquote cite {
	color: var(--color-text-muted);
}
/* Table block — same dense/data treatment as the legal page's tables. */
.post-article__content .wp-block-table, .case-study__content .wp-block-table {
	overflow-x: auto;
}
.post-article__content table, .case-study__content table {
	width: 100%;
	border-collapse: collapse;
	font-size: 0.95rem;
}
.post-article__content th, .post-article__content td,
.case-study__content th, .case-study__content td {
	text-align: left;
	padding: 10px 14px;
	line-height: 1.5;
	border-bottom: 1px solid var(--color-border);
	vertical-align: top;
}
.post-article__content thead th, .case-study__content thead th {
	font-weight: 600;
	color: var(--color-text-muted);
	background: var(--color-bg-raised);
	white-space: nowrap;
}
/* Classic left/right image alignment (core Image block's "Left"/
   "Right" options) — floats with text wrap, same as any WP theme. */
.post-article__content .alignleft, .case-study__content .alignleft {
	float: left;
	margin: 0.4em 24px 1em 0;
	max-width: 45%;
}
.post-article__content .alignright, .case-study__content .alignright {
	float: right;
	margin: 0.4em 0 1em 24px;
	max-width: 45%;
}
@media (max-width: 640px) {
	.post-article__content .alignleft, .post-article__content .alignright,
	.case-study__content .alignleft, .case-study__content .alignright {
		float: none;
		margin: 1em 0;
		max-width: 100%;
	}
}
/* "Wide width" — breaks out past the 720px reading column up to
   --wide-width (or the viewport, whichever's smaller), same centering
   technique as .article-cover above. Only meaningful on a direct child
   of the content column; nested instances (e.g. inside a Group block)
   have nothing wider to break out of, so this intentionally doesn't
   apply to those. */
.post-article__content > .alignwide, .case-study__content > .alignwide {
	width: min(calc(100vw - 48px), var(--wide-width));
	max-width: none;
	margin-left: 50%;
	transform: translateX(-50%);
	margin-top: 2.5em;
	margin-bottom: 2.5em;
}
/* "Full width" — edge-to-edge, ignoring the site's own side padding
   too. Standard WordPress core breakout technique (the same one
   Twenty Twenty-One/Twenty Twenty-Two use for .alignfull). */
.post-article__content > .alignfull, .case-study__content > .alignfull {
	width: auto;
	max-width: 100vw;
	margin-left: calc(50% - 50vw);
	margin-right: calc(50% - 50vw);
	margin-top: 2.5em;
	margin-bottom: 2.5em;
}
/* Wide still has breathing room on either side, so it keeps rounded
   corners (bumped up slightly to suit the larger size); full is
   genuinely edge-to-edge, where a rounded corner would look broken
   rather than intentional. */
.post-article__content > .alignwide img, .case-study__content > .alignwide img {
	border-radius: 16px;
}
.post-article__content > .alignfull img, .case-study__content > .alignfull img {
	border-radius: 0;
}

.post-article__tags { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 36px; }
.post-article__tag {
	font-size: 0.8rem;
	color: var(--color-text-muted);
	border: 1px solid var(--color-border);
	border-radius: 999px;
	padding: 5px 12px;
}

.related-posts {
	max-width: var(--content-width);
	margin: 0 auto;
	padding: 0 24px var(--space-section);
}
.related-posts__eyebrow {
	font-size: 0.8rem;
	color: var(--color-text-muted);
	letter-spacing: 0.04em;
	margin-bottom: 16px;
}
.related-posts__list { border-top: 1px solid var(--color-border); }

/* ===================================================================
   Footer
   =================================================================== */
.site-footer {
	padding: 0 24px 32px;
}
.site-footer__inner {
	max-width: var(--wide-width);
	margin: 0 auto;
	border-top: 1px solid var(--color-border);
	padding-top: 56px;
}

.site-footer__top {
	display: grid;
	grid-template-columns: 1.2fr 1.6fr 2.3fr;
	gap: 32px;
	padding-bottom: 40px;
}

.site-footer__brand { display: flex; align-items: flex-start; }
.site-footer__logo { width: 64px; height: 64px; }

/* Groups the two Navigation columns together with a much tighter gap
   than the grid's outer gap, so they read as one attached unit rather
   than two separate sections. */
.site-footer__nav-group {
	display: flex;
	gap: 16px;
}

/* Get in Touch + Connect: used to be two separate top-level columns
   in the outer grid (4 columns total). Now grouped into one wrapper
   instead — same as .site-footer__nav-group above — so the pair can
   be treated as a single column on mobile (see the 760px override
   below) without needing grid-template-areas or explicit row/column
   placement. On desktop this just recreates the original side-by-side
   look with the same 32px gap the outer grid used to provide between
   them. */
.site-footer__contact-group {
	display: flex;
	gap: 32px;
}
.site-footer__contact-group .site-footer__col { flex: 1; }

.site-footer__col { display: flex; flex-direction: column; gap: 10px; flex: 1; }
.site-footer__heading {
	font-family: var(--font-display);
	font-size: 1rem;
	font-weight: 700;
	margin-bottom: 2px;
}
.site-footer__col a {
	color: var(--color-text);
	font-size: 0.9rem;
}
.site-footer__col a:hover { color: var(--color-accent); }

/* Second half of the Navigation links — visually a continuation, but
   structurally matches every other column (an invisible heading-height
   spacer keeps its links flush with the row above, no special alignment
   override needed). */
.site-footer__col--nav-continued .site-footer__heading {
	visibility: hidden;
}

.site-footer__bottom {
	display: flex;
	justify-content: space-between;
	align-items: center;
	flex-wrap: wrap;
	gap: 12px;
	padding-top: 24px;
	border-top: 1px solid var(--color-border);
	color: var(--color-text-muted);
	font-size: 0.85rem;
}
.site-footer__legal:hover { color: var(--color-text); }

@media (max-width: 760px) {
	.site-footer__top {
		grid-template-columns: 1fr 1fr;
		row-gap: 32px;
	}
	/* Brand/logo spans the full width above the two columns below it,
	   rather than taking up one of the two slots itself. */
	.site-footer__brand { grid-column: 1 / -1; }

	/* The two Navigation sub-columns no longer sit side-by-side on
	   mobile, so there's no reason to treat them as two separate
	   grouped blocks with their own gap between them — that extra gap
	   was exactly what showed up as a visible seam between "Blog" (end
	   of the first list) and "Testimonials" (start of the second).
	   Zeroing the group's own gap and matching it to each column's
	   internal link spacing makes the whole thing read as one
	   continuous, evenly-spaced list. */
	.site-footer__nav-group {
		flex-direction: column;
		gap: 10px;
	}
	.site-footer__col--nav-continued .site-footer__heading { display: none; }

	/* Get in Touch + Connect: same treatment as Navigation's two
	   sub-columns above — merge into one continuous list under a
	   single heading, rather than two separate heading blocks stacked
	   on top of each other. With Navigation on the left being one
	   heading + one list, two headings on the right was what made the
	   columns feel mismatched/unbalanced; this makes both columns the
	   same shape. */
	.site-footer__contact-group {
		flex-direction: column;
		gap: 10px;
		align-self: start; /* don't stretch to the nav-group's row height —
		                      flex:1 sub-cols were distributing that surplus
		                      as a large gap between email and social links */
	}
	.site-footer__col--contact-continued .site-footer__heading { display: none; }
}

/* ===================================================================
   Side rail clearance fix — placed last so it reliably wins the
   cascade over earlier shorthand `padding` declarations.

   Between 901px and 1300px the fixed side rails sit close to the
   content edge (the rail position's 64px floor is active in that
   range). Extra horizontal padding here keeps text clear of the
   rails without affecting wider screens (rails have moved further
   out by then) or narrower screens (rails are hidden below 900px).
   =================================================================== */
@media (min-width: 921px) and (max-width: 1300px) {
	.section,
	.post-article,
	.case-study,
	.related-posts {
		padding-left: 130px;
		padding-right: 130px;
	}
}

/* ===================================================================
   Lenis (smooth/momentum scrolling)
   =================================================================== */
html.lenis, html.lenis body {
	height: auto;
}
html.lenis.lenis-smooth {
	scroll-behavior: auto !important; /* Lenis owns scroll position once active */
}
html.lenis.lenis-smooth [data-lenis-prevent] {
	overscroll-behavior: contain;
}
html.lenis.lenis-stopped {
	overflow: hidden;
}

/* ===================================================================
   Scroll-reveal animations
   Applied to any element carrying [data-reveal]. JS adds .is-revealed
   the first time the element enters the viewport. Respects
   prefers-reduced-motion by skipping the transition entirely (JS adds
   .is-revealed immediately in that case, so nothing is ever stuck
   invisible).

   Two variants:
   - [data-reveal] on the element itself: that element fades/drifts in.
   - [data-reveal="stagger"] on a container: its direct children fade in
     individually with a staggered delay (used for grids/lists where each
     item should animate in its own moment rather than as one block).
   =================================================================== */
[data-reveal]:not([data-reveal="stagger"]) {
	opacity: 0;
	transform: translateY(16px);
	transition: opacity 1.4s var(--ease-standard), transform 1.4s var(--ease-standard);
}
[data-reveal]:not([data-reveal="stagger"]).is-revealed {
	opacity: 1;
	transform: translateY(0);
}

[data-reveal="stagger"] > *:not(.cardgrid-card--incoming) {
	opacity: 0;
	transform: translateY(16px);
	transition: opacity 1.4s var(--ease-standard), transform 1.4s var(--ease-standard);
}
[data-reveal="stagger"].is-revealed > *:not(.cardgrid-card--incoming) {
	opacity: 1;
	transform: translateY(0);
}
[data-reveal="stagger"].is-revealed > *:nth-child(1):not(.cardgrid-card--incoming) { transition-delay: 0s; }
[data-reveal="stagger"].is-revealed > *:nth-child(2):not(.cardgrid-card--incoming) { transition-delay: 0.16s; }
[data-reveal="stagger"].is-revealed > *:nth-child(3):not(.cardgrid-card--incoming) { transition-delay: 0.32s; }
[data-reveal="stagger"].is-revealed > *:nth-child(4):not(.cardgrid-card--incoming) { transition-delay: 0.48s; }
[data-reveal="stagger"].is-revealed > *:nth-child(5):not(.cardgrid-card--incoming) { transition-delay: 0.64s; }
[data-reveal="stagger"].is-revealed > *:nth-child(6):not(.cardgrid-card--incoming) { transition-delay: 0.8s; }
/* Work/Trips now render every post into the page upfront, so these
   grids can hold well over 6 children. Anything past the 6 explicit
   delays above falls back to a flat 0.8s rather than snapping straight
   to 0s (the unset default), so later cards don't all fade in at once.
   :not(.cardgrid-card--incoming) on all of the above: without it, these
   position-based delays (meant only for the initial page-load stagger)
   also win against .cardgrid-card--incoming's own 0.6s transition —
   they're more specific, so they silently override its delay. That
   made AJAX/"View more" batches fade in one-by-one in DOM-position
   order instead of together as a single batch, which on a viewport
   wide enough to see the whole new batch at once reads as posts
   arriving in the wrong order. */
[data-reveal="stagger"].is-revealed > *:nth-child(n+7):not(.cardgrid-card--incoming) { transition-delay: 0.8s; }

@media (prefers-reduced-motion: reduce) {
	[data-reveal]:not([data-reveal="stagger"]),
	[data-reveal="stagger"] > * {
		opacity: 1;
		transform: none;
		transition: none;
	}
}

/* .blog-row's hover effect lives on its ::before pseudo-element, so
   there's no transition conflict with the [data-reveal="stagger"] > *
   rule above. This rule just ensures the reveal's entrance timing
   (opacity + transform at 1.4s) isn't disrupted by any future changes.
   :not(.cardgrid-card--incoming) excludes AJAX-appended rows so they
   keep their own 0.6s incoming transition.

   Scoped to :not(.reveal-settled) on the container — main.js adds
   .reveal-settled once the one-time entrance fade has fully played out
   (see the IntersectionObserver callback). Without that split, this
   same 1.4s kept governing the row's opacity/transform forever,
   including the "fade back to full opacity" moment after the
   spotlight-dim hover effect below ends — so hovering felt snappy but
   un-hovering felt sluggish, and more so the more rows were on the
   page (every dimmed sibling doing that slow fade-back at once). The
   .reveal-settled rule right after this one gives that resting/hover
   state its own fast, constant-speed transition instead, independent
   of how many posts have been loaded via "View more". */
[data-reveal="stagger"]:not(.reveal-settled) > .blog-row:not(.cardgrid-card--incoming) {
	transition: opacity 1.4s var(--ease-standard), transform 1.4s var(--ease-standard);
}
[data-reveal="stagger"].reveal-settled > .blog-row:not(.cardgrid-card--incoming) {
	transition: opacity 0.3s var(--ease-standard), transform 0.3s var(--ease-standard);
}

/* Same conflict as .blog-row above: .work-card's hover lift (transform
   0.4s) would be overwritten by the stagger reveal rule's transform
   1.4s. Restate both transitions together so hover keeps its snappier
   timing while the initial reveal keeps its slower entrance timing.

   :not(.cardgrid-card--incoming) again excludes cards revealed via
   "View more" / the mobile reveal-pending flow, so they keep the
   shorter 0.6s fade defined by .cardgrid-card--incoming rather than
   inheriting this rule's 1.4s.

   Same :not(.reveal-settled)/.reveal-settled split as .blog-row above,
   and for the same reason: once the entrance has played, the
   spotlight-dim's fade-back should be fast and constant, not tied to
   the one-time 1.4s entrance duration. */
[data-reveal="stagger"]:not(.reveal-settled) > .work-card:not(.cardgrid-card--incoming) {
	transition: transform 0.4s var(--ease-standard), opacity 1.4s var(--ease-standard);
}
[data-reveal="stagger"].reveal-settled > .work-card:not(.cardgrid-card--incoming) {
	transition: transform 0.4s var(--ease-standard), opacity 0.3s var(--ease-standard);
}

/* "Spotlight" hover on the work/trips grids and the blog/related-posts
   lists: hovering one card or row keeps it at full opacity while its
   siblings dim, so whichever project you're looking at pops against
   the rest — inspired by the hover treatment on flohoeller.com's work
   list. Pure CSS, no JS.

   :has(> .work-card:hover) rather than a plain :hover on the container
   itself: .cardgrid (.work__grid) carries generous padding around the
   actual cards (14px top / 40px bottom, deliberately reserved so hover
   shadows and the lifted card don't get clipped — see .work__grid
   above), and that padding is still part of the container's own
   hoverable box. A plain :hover there meant mousing into that dead
   space — including where the .cardgrid-progress bar sits, just below
   — dimmed every card even though the cursor wasn't over any of them.
   :has() makes the trigger genuinely "a card is hovered", not "the
   container's box (padding included) is hovered".

   Scoped to .is-revealed (added once the entrance animation finishes)
   and given more selector weight than the two rules above, which claim
   `opacity` at a slow 1.4s for the scroll-reveal entrance — without
   that extra specificity this hover would inherit that same 1.4s and
   feel laggy instead of like snappy hover feedback. The one asymmetry:
   when the cursor leaves the last hovered card, this rule's own
   :has(...) condition stops matching a moment before :not(:hover)
   would've anyway, so the final fade back to full opacity falls back
   to that slower 1.4s — a minor, harmless difference from the snappy
   dim-in. */
.cardgrid.is-revealed:has(> .work-card:hover) > .work-card:not(:hover) {
	opacity: 0.5;
	transition: opacity 0.3s var(--ease-standard), transform 0.4s var(--ease-standard), box-shadow 0.4s var(--ease-standard);
}
.blog__list.is-revealed:has(> .blog-row:hover) > .blog-row:not(:hover),
.related-posts__list.is-revealed:has(> .blog-row:hover) > .blog-row:not(:hover) {
	opacity: 0.5;
	transition: opacity 0.3s var(--ease-standard), transform 1.4s var(--ease-standard);
}

/* Card hover effects (lift, image zoom, spotlight dim) are desktop/
   tablet-only. Mobile has no cursor to "hover" with — what actually
   happens on a touchscreen is the :hover state gets triggered by the
   tap and then stays stuck until the visitor taps elsewhere, which
   reads as a bug (a card stuck lifted, or every other card stuck
   dimmed) rather than a nice effect. Restores each property to its
   resting-state value rather than just unsetting them, since :hover
   rules still technically apply on mobile browsers and would otherwise
   win over the base rule by source order. */
@media (max-width: 640px) {
	.work-card:hover {
		transform: none;
		box-shadow: 0 2px 12px var(--shadow-color);
	}
	.work-card:hover .work-card__image {
		transform: none;
	}
	.cardgrid.is-revealed:has(> .work-card:hover) > .work-card:not(:hover) {
		opacity: 1;
	}
}

/* Reusable filled-pill button — the 404 page's "Back to homepage"
   link, kept as its own small utility class rather than tied to any
   one section's name. */
.button--primary {
	display: inline-block;
	padding: 14px 32px;
	border-radius: 999px;
	background: var(--color-text);
	color: var(--color-bg);
	border: none;
	font-family: var(--font-body);
	font-size: 1rem;
	font-weight: 600;
	cursor: pointer;
	transition: opacity 0.25s var(--ease-standard);
}
.button--primary:hover { opacity: 0.85; }

/* Glass pill buttons — the About section's CTAs. Same translucent
   blur/border/shadow recipe as .social-rail and the nav, so these read
   as part of the same UI language rather than a separate button
   system. Text-only (no icons): size and weight match the surrounding
   body copy, with color as the only thing distinguishing the two —
   LinkedIn in the accent blue, Email in the normal text color. */
.button--glass,
.button--accent {
	display: inline-flex;
	align-items: center;
	gap: 0;
	padding: 8px 18px;
	border-radius: 999px;
	font-family: var(--font-body);
	font-size: inherit;
	font-weight: 400;
	cursor: pointer;
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
	transition: background 0.25s var(--ease-standard), transform 0.25s var(--ease-standard);
	overflow: hidden;
}
/* External link icon — hidden and takes no space at rest, slides in
   smoothly to the right of the label on hover. max-width transitions
   from 0 to a value larger than the icon (so it never clips mid-icon),
   with a small margin for breathing room. opacity adds a fade-in so
   the leading edge doesn't feel like a hard cut-in. */
.button__ext-icon {
	width: 14px;
	height: 14px;
	flex-shrink: 0;
	max-width: 0;
	margin-left: 0;
	opacity: 0;
	transform: translate(-3px, 3px);
	transition:
		max-width   0.3s var(--ease-standard),
		margin-left 0.3s var(--ease-standard),
		opacity     0.25s var(--ease-standard),
		transform   0.3s var(--ease-standard);
}
.button--glass:hover .button__ext-icon,
.button--accent:hover .button__ext-icon {
	max-width: 20px;
	margin-left: 7px;
	opacity: 1;
	transform: translate(0, 0);
}
.button--glass {
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	color: var(--color-text);
}
.button--accent {
	background: var(--color-accent);
	border: none;
	color: #fff;
}
.button--glass:hover {
	background: var(--glass-border);
}
.button--accent:hover {
	opacity: 0.9;
}
.button--glass:hover,
.button--accent:hover {
	transform: scale(0.96);
}

/* ===================================================================
   404 template
   =================================================================== */
.error-404-wrap {
	min-height: 100vh;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: calc(var(--nav-height) + 40px) 24px var(--space-section);
}
.error-404 {
	max-width: 480px;
	text-align: center;
}
.error-404__code {
	font-family: var(--font-display);
	font-size: clamp(7rem, 22vw, 13rem);
	font-weight: 700;
	line-height: 1;
	color: var(--color-accent);
	margin-bottom: 20px;
	/* flex so the three digit spans can each be independently
	   positioned and rotated without disrupting the layout. */
	display: flex;
	justify-content: center;
	align-items: flex-end;
	gap: 0.02em;
}
/* Gravity fall animations for each 404 digit. Keyframe percentages
   are clustered at the start (0–55%) to simulate acceleration under
   gravity, then spread out for the bounce/swing settle phase.
   Each digit has its own keyframe so the rotation can arc naturally
   from 0° toward the digit's individual resting angle. */
@keyframes digit-fall-1 {
	0%   { transform: translateY(-150vh) rotate(0deg);    opacity: 0; }
	4%   { transform: translateY(-130vh) rotate(-2deg);   opacity: 1; }
	20%  { transform: translateY(-80vh)  rotate(-6deg);  }
	40%  { transform: translateY(-22vh)  rotate(-10deg); }
	52%  { transform: translateY(18px)   rotate(-14deg); } /* overshoot */
	62%  { transform: translateY(-9px)   rotate(-7deg);  } /* bounce + swing */
	72%  { transform: translateY(13px)   rotate(-14deg); } /* swing back */
	81%  { transform: translateY(-3px)   rotate(-10deg); } /* smaller swing */
	89%  { transform: translateY(8px)    rotate(-12deg); } /* settling */
	95%  { transform: translateY(4px)    rotate(-12deg); }
	100% { transform: rotate(-12deg) translateY(6px);    }
}
@keyframes digit-fall-2 {
	0%   { transform: translateY(-150vh) rotate(0deg);    opacity: 0; }
	4%   { transform: translateY(-130vh) rotate(1deg);    opacity: 1; }
	20%  { transform: translateY(-80vh)  rotate(4deg);   }
	40%  { transform: translateY(-22vh)  rotate(7deg);   }
	55%  { transform: translateY(-4px)   rotate(9deg);   } /* overshoot */
	66%  { transform: translateY(-16px)  rotate(4deg);   } /* bounce up */
	75%  { transform: translateY(-6px)   rotate(9deg);   } /* swing back */
	84%  { transform: translateY(-13px)  rotate(6deg);   } /* smaller swing */
	91%  { transform: translateY(-9px)   rotate(7deg);   } /* settling */
	96%  { transform: translateY(-11px)  rotate(7deg);   }
	100% { transform: rotate(7deg) translateY(-10px);    }
}
@keyframes digit-fall-3 {
	0%   { transform: translateY(-150vh) rotate(0deg);    opacity: 0; }
	4%   { transform: translateY(-130vh) rotate(-3deg);   opacity: 1; }
	20%  { transform: translateY(-80vh)  rotate(-8deg);  }
	40%  { transform: translateY(-22vh)  rotate(-14deg); }
	57%  { transform: translateY(22px)   rotate(-22deg); } /* overshoot */
	68%  { transform: translateY(5px)    rotate(-11deg); } /* bounce + swing */
	77%  { transform: translateY(20px)   rotate(-22deg); } /* swing back */
	86%  { transform: translateY(8px)    rotate(-15deg); } /* smaller swing */
	92%  { transform: translateY(17px)   rotate(-18deg); } /* settling */
	97%  { transform: translateY(12px)   rotate(-18deg); }
	100% { transform: rotate(-18deg) translateY(14px);   }
}

/* Each digit gets a distinct rotation and vertical offset to give
   the impression of broken, fallen type — no two are the same. */
.error-404__digit {
	display: inline-block;
	line-height: 1;
}
.error-404__digit:nth-child(1) {
	transform: rotate(-12deg) translateY(6px);
	animation: digit-fall-1 1.5s linear 0.05s both;
}
.error-404__digit:nth-child(2) {
	transform: rotate(7deg) translateY(-10px);
	animation: digit-fall-2 1.5s linear 0.25s both;
}
.error-404__digit:nth-child(3) {
	transform: rotate(-18deg) translateY(14px);
	animation: digit-fall-3 1.5s linear 0.45s both;
}
@media (prefers-reduced-motion: reduce) {
	.error-404__digit { animation: none; }
}
.error-404__title {
	font-family: var(--font-display);
	font-size: clamp(1.6rem, 3.2vw, 2.2rem);
	font-weight: 600;
	line-height: var(--leading-tight);
	letter-spacing: -0.02em;
	margin-bottom: 14px;
}
.error-404__text {
	color: var(--color-text-muted);
	line-height: 1.6;
	margin-bottom: 32px;
}

/* ===================================================================
   Legal pages (Privacy & Cookies Policy)
   =================================================================== */
.legal-page { padding-top: calc(var(--nav-height) + 40px); }
.legal-page__inner {
	max-width: var(--content-width);
	margin: 0 auto;
	padding: 0 24px var(--space-section);
}

.legal-page__title {
	font-family: var(--font-display);
	font-size: clamp(1.8rem, 4vw, 2.6rem);
	font-weight: 600;
	letter-spacing: -0.02em;
	line-height: var(--leading-tight);
	margin: 0 0 6px;
}
.legal-page__updated {
	color: var(--color-text-muted);
	font-size: 0.9rem;
	margin: 0 0 40px;
}

/* Body copy hierarchy: numbered sections (h2) read clearly larger and
   bolder than sub-sections (h3, e.g. "5.1 Necessary Cookies"), which
   in turn read distinctly heavier than the paragraph text under them
   — three clear, evenly-spaced steps rather than everything competing
   at a similar weight. */
.legal-page__content h2 {
	font-family: var(--font-display);
	font-size: 1.35rem;
	font-weight: 600;
	line-height: var(--leading-snug);
	letter-spacing: -0.01em;
	margin: 48px 0 16px;
}
.legal-page__content h2:first-child { margin-top: 0; }
.legal-page__content h3 {
	font-family: var(--font-display);
	font-size: 1.05rem;
	font-weight: 600;
	line-height: var(--leading-snug);
	margin: 28px 0 10px;
}
.legal-page__content p {
	margin: 0 0 16px;
	line-height: 1.7;
	color: var(--color-text);
}
.legal-page__content strong { font-weight: 600; }
.legal-page__content a {
	color: var(--color-accent);
	text-decoration: underline;
	text-underline-offset: 2px;
}
.legal-page__content a:hover { color: var(--color-text); }

.legal-page__content ul {
	list-style: disc;
	margin: 0 0 16px;
	padding-left: 22px;
}
.legal-page__content ol {
	list-style: decimal;
	margin: 0 0 16px;
	padding-left: 22px;
}
.legal-page__content li {
	margin-bottom: 8px;
	line-height: 1.7;
	color: var(--color-text);
}
.legal-page__content li:last-child { margin-bottom: 0; }

/* Tables are dense reference data (cookie names, providers, what's
   collected) rather than prose, so a smaller size and tighter
   line-height than the surrounding paragraphs is intentional — it
   reads as "data" rather than competing with the body copy. The wrap
   div scrolls horizontally on narrow viewports instead of squeezing
   columns illegibly thin or breaking the page layout. */
.legal-page__table-wrap {
	overflow-x: auto;
	margin: 0 0 24px;
	border: 1px solid var(--color-border);
	border-radius: 12px;
}
.legal-page__table-wrap table {
	width: 100%;
	min-width: 480px;
	border-collapse: collapse;
	font-size: 0.88rem;
}
.legal-page__table-wrap th,
.legal-page__table-wrap td {
	text-align: left;
	padding: 10px 14px;
	line-height: 1.5;
	border-bottom: 1px solid var(--color-border);
	vertical-align: top;
}
.legal-page__table-wrap thead th {
	font-family: var(--font-body);
	font-weight: 600;
	color: var(--color-text-muted);
	background: var(--color-bg-raised);
	white-space: nowrap;
}
.legal-page__table-wrap tbody tr:last-child td { border-bottom: none; }

/* ===================================================================
   Maintenance mode / "coming soon" page (inc/maintenance-mode.php)
   =================================================================== */
.maintenance-page {
	min-height: 100vh;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 24px;
}
.maintenance-page__inner {
	max-width: 480px;
	text-align: center;
}

.maintenance-page__logo {
	width: 56px;
	height: 56px;
	margin: 0 auto 28px;
}
/* Same light/dark swap mechanism as .site-footer__logo — both image
   variants render, CSS shows only the one matching the active theme. */
.maintenance-page__logo--dark { display: none; }
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) .maintenance-page__logo--light { display: none; }
	html:not([data-theme]) .maintenance-page__logo--dark { display: block; }
}
html[data-theme="dark"] .maintenance-page__logo--light { display: none; }
html[data-theme="dark"] .maintenance-page__logo--dark { display: block; }
html[data-theme="light"] .maintenance-page__logo--light { display: block; }
html[data-theme="light"] .maintenance-page__logo--dark { display: none; }

.maintenance-page__heading {
	font-family: var(--font-display);
	font-size: clamp(1.6rem, 4vw, 2.2rem);
	font-weight: 600;
	letter-spacing: -0.02em;
	line-height: var(--leading-tight);
	margin: 0 0 12px;
	text-wrap: balance;
}
.maintenance-page__message {
	color: var(--color-text-muted);
	line-height: 1.6;
	margin: 0 0 28px;
}

/* The full-site social rail (.social-rail) defaults to a vertical
   column, sized for the fixed side rail it normally lives in — this
   context calls for a horizontal row of icons sitting under the
   message instead, so just the layout-affecting properties are
   overridden here, not the glass chrome/icon styling itself. */
.social-rail--maintenance {
	flex-direction: row;
	display: inline-flex;
	padding: 8px 12px;
}

/* Always visible regardless of viewport — unlike .side-rail (used on
   the rest of the site), which intentionally hides below 920px in
   favor of a toggle built into the mobile nav pill. This page has no
   nav pill at all, so the toggle needs its own simple fixed position
   that works at every size instead. */
.maintenance-page__toggle {
	position: fixed;
	top: 24px;
	right: 24px;
	z-index: 10;
}

/* ===================================================================
   Skills & Tools section
   =================================================================== */

/* Responsive grid: data-columns drives desktop layout; tablet always
   drops to 2 cols; mobile stacks to 1 col. */
.skills__grid {
	display: grid;
	gap: 32px;
	grid-template-columns: 1fr;
}
@media (min-width: 641px) {
	.skills__grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 921px) {
	.skills__grid[data-columns="2"] { grid-template-columns: repeat(2, 1fr); }
	.skills__grid[data-columns="3"] { grid-template-columns: repeat(3, 1fr); }
	.skills__grid[data-columns="4"] { grid-template-columns: repeat(4, 1fr); }
}

/* Column heading — same weight and size as the footer column headings,
   establishing a clear hierarchy above the skill items below it. */
.skills__column-heading {
	font-family: var(--font-display);
	font-size: 0.9rem;
	font-weight: 600;
	line-height: var(--leading-tight);
	letter-spacing: 0.04em;
	text-transform: uppercase;
	color: var(--color-text-muted);
	margin-bottom: 14px;
}

/* Compound selector wins over .skill-item { display: flex } regardless
   of order — single-class selectors tie on specificity, so the later
   rule (.skill-item with display:flex) was overriding this. Two classes
   together give 0,2,0 vs the base rule's 0,1,0. */
.skill-item.skill-item--hidden { display: none; }

/* Stagger fade-up animation played on each item when the View more
   button reveals them. animation-fill-mode: both means the item starts
   at the `from` state (opacity 0) even before the delay expires, so
   there's no flash of visible content before the animation begins. */
@keyframes skill-item-reveal {
	from { opacity: 0; transform: translateY(10px); }
	to   { opacity: 1; transform: translateY(0); }
}
.skill-item--revealing {
	animation: skill-item-reveal 0.4s var(--ease-standard) both;
}
/* Reverse of the reveal — same keyframe played backwards so items
   fade down and out. Used when the work section scrolls into view. */
.skill-item--hiding {
	animation: skill-item-reveal 0.3s var(--ease-standard) both reverse;
}

/* "View more" button — plain text link style (no pill, no glass),
   sits below the list and shrinks on hover like other interactive
   elements on the page. */
.skills-more-btn {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	margin-top: 10px;
	padding: 4px 0;
	background: none;
	border: none;
	cursor: pointer;
	font-family: var(--font-body);
	font-size: 0.85rem;
	font-weight: 500;
	color: var(--color-text-muted);
	transition: color 0.2s var(--ease-standard), transform 0.25s var(--ease-standard);
}
.skills-more-btn:hover {
	color: var(--color-text);
	transform: scale(0.96);
}
.skills-more-btn[hidden] { display: none; }
.skills__list {
	display: flex;
	flex-direction: column;
	gap: 8px;
	list-style: none;
	margin: 0;
	padding: 0;
}

/* Each skill item: glass pill with icon on the left and name on the
   right. Shares the same glass treatment as the nav pill and social
   rail so it sits consistently within the design language. The icon
   inherits the button's currentColor so it responds to light/dark mode
   when the SVG uses currentColor for fill or stroke. */
.skill-item {
	display: flex;
	align-items: center;
	gap: 10px;
	padding: 10px 14px;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	border-radius: 10px;
	box-shadow: 0 2px 8px var(--shadow-color);
	transition: background-image 0.2s var(--ease-standard), transform 0.25s var(--ease-standard);
}
.skill-item:hover {
	/* Layering glass-border (rgba dark tint in light, rgba light tint in
	   dark) over the existing glass-bg via linear-gradient — same
	   approach as .site-nav__link:hover. Darkens in light mode,
	   lightens in dark mode, without touching backdrop-filter. */
	background-image: linear-gradient( var(--glass-border), var(--glass-border) );
	transform: scale(0.96);
}
.skill-item__icon {
	width: 22px;
	height: 22px;
	flex-shrink: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--color-text);
}
.skill-item__icon svg,
.skill-item__icon img {
	display: block;
	width: 100%;
	height: 100%;
}
/* Themed icons follow light/dark mode. Inline SVGs inherit currentColor
   from the parent; image-based SVGs use a CSS filter to go monochrome. */
.skill-item__icon--themed { color: var(--color-text); }
.skill-item__icon--themed img { filter: brightness(0); }
html[data-theme="dark"] .skill-item__icon--themed img { filter: brightness(0) invert(1); }
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) .skill-item__icon--themed img { filter: brightness(0) invert(1); }
}
.skill-item__body {
	display: flex;
	flex-direction: column;
	gap: 1px;
	min-width: 0;
}
.skill-item__name {
	font-size: 0.9rem;
	font-weight: 500;
	color: var(--color-text);
	line-height: 1.3;
}
.skill-item__note {
	font-size: 0.75rem;
	font-weight: 400;
	color: var(--color-text-muted);
	line-height: 1.3;
}

/* ===================================================================
   Skills section — "Stack" layout (Customizer → Skills & Tools →
   Layout = Stack; see template-parts/section-skills-stack.php). A
   stripped-back single row of full-color tool icons, pulled from the
   skill_category term "Stack" only — no names, no columns.
   Icons render once in the markup; main.js measures whether that one
   row already fits within the viewport and, only if it doesn't,
   duplicates them into a seamless scrolling marquee (see the
   .stack--marquee modifier below, added by JS, and the "Skills Stack"
   block in main.js for the duplication logic itself). On a wide
   screen where they already fit, nothing further happens — just the
   plain static row here.
   =================================================================== */
/* .section__eyebrow (shared base) already matches this site's other
   section labels — those rely on however the site owner typed them
   (capitals or not), so this doesn't force any casing of its own,
   same as every other section. Scoped to .stack only so nothing here
   leaks onto the Grid layout's own eyebrow, since this now reuses
   gathercole_section_heading() directly (so the Heading/Subheading
   fields work here too, if set — see
   template-parts/section-skills-stack.php). */
.stack__viewport {
	margin-top: 16px;
	overflow: hidden;
	/* Vertical only — room for .stack__item's own hover lift/scale so
	   it isn't clipped by this wrapper's overflow: hidden (needed to
	   clip the track itself, not the currently-hovered item's own
	   reveal). Deliberately not horizontal: the first/last icon need
	   to sit flush against this element's own edges (see
	   justify-content: space-between on .stack__track below) so they
	   visually align with the section's other content edges, which
	   any side padding here would otherwise hold them back from. The
	   name tooltip doesn't need any allowance here either way — it's a
	   single shared element main.js positions with position: fixed
	   outside this element entirely, specifically so it's never
	   subject to this clipping no matter which icon (including ones
	   right at the row's edge) it's currently showing. */
	padding: 12px 0;
	margin: -12px 0;
}
/* Mobile: bleeds past .section's own 24px side padding out to the
   true screen edge, so the row has nothing but the icons themselves
   right up to both edges. */
@media (max-width: 640px) {
	.stack__viewport {
		margin-inline: -24px;
	}
}
/* .stack--marquee lands on this same element (see the "Skills Stack"
   block in main.js), not a wrapper around it. A genuine overflow-x:
   auto element at every viewport width (not just mobile) — the
   auto-scroll itself is also driven from main.js by nudging this
   element's own scrollLeft every frame rather than a CSS transform
   animation, specifically so a person can grab and drag/swipe through
   it manually too, on desktop and tablet as well as mobile — native
   scrollLeft is the one thing both the auto-scroll and a manual drag
   actually share, so the two combine rather than fight each other.
   The scrollbar itself is hidden since the auto-scroll is already a
   constant visual cue that there's more to see. */
.stack__viewport.stack--marquee {
	overflow-x: auto;
	overflow-y: hidden;
	scroll-behavior: auto; /* not smooth — main.js's own frame-by-frame nudges and wrap-around correction need to land instantly */
	-webkit-overflow-scrolling: touch;
	scrollbar-width: none;
	-ms-overflow-style: none;
}
.stack__viewport.stack--marquee::-webkit-scrollbar {
	display: none;
}
.stack__track {
	display: flex;
	align-items: flex-start;
	/* Static mode (below): the row spans the full available width,
	   first icon flush with the left edge and last flush with the
	   right — matching where the section's own heading text starts
	   and ends — with space-between handling the gaps between them
	   automatically, so however many icons there are, they're always
	   spread evenly edge to edge rather than clumped at a fixed gap.
	   Marquee mode overrides both properties below, since an
	   infinitely-scrolling duplicated row needs a content-sized (not
	   100%) track with its own fixed gap instead — see
	   .stack--marquee .stack__track further down. */
	width: 100%;
	justify-content: space-between;
}
/* Each icon tile — just the glyph itself now (no background/border/
   shadow "frame" behind it). Its name is a tooltip that reveals on
   hover/focus/tap instead of a permanently-visible label (see
   .stack__tooltip, the single shared element main.js creates and
   positions for whichever item is currently active — not this one,
   which only carries the sizing/hover-scale). An item with a Link URL
   set (Skills → edit a skill → Link URL) renders as a real <a> (opens
   in a new tab); without one it's a plain <div> — see the $tag logic
   in section-skills-stack.php — but either way gets the same hover
   treatment, since that's just visual feedback, not a promise of a
   destination. */
.stack__item {
	position: relative;
	z-index: 1;
	display: flex;
	align-items: center;
	justify-content: center;
	flex: 0 0 auto;
	text-decoration: none;
	color: inherit;
}
.stack__item:hover,
.stack__item:focus-visible,
.stack__item.is-open {
	/* Keeps a scaled-up tile from rendering behind an immediate
	   neighbour, now that the row sits closer together. */
	z-index: 2;
}
/* Fluidly sized (clamp, not a fixed px) rather than a single size
   swapped at a breakpoint — without this, a fixed 56px tile stayed
   exactly that size right up until the 768px marquee breakpoint, so a
   row that fit at 769px could easily be wider than the viewport by
   768px and get clipped for that last stretch before the marquee
   takes over. Scaling smoothly from 56px down to a 40px floor well
   before that breakpoint (the clamp's preferred value reaches its
   floor at ~666px viewport width) means there's no longer a sudden
   cliff for that to happen at. */
.stack__tile {
	width: clamp(40px, 6vw, 56px);
	height: clamp(40px, 6vw, 56px);
	box-sizing: border-box;
	display: flex;
	align-items: center;
	justify-content: center;
	transition: transform 0.25s var(--ease-standard);
}
.stack__item:hover .stack__tile,
.stack__item:focus-visible .stack__tile,
.stack__item.is-open .stack__tile {
	transform: scale(1.1) translateY(-3px);
}
.stack__item:focus-visible {
	outline: 2px solid var(--color-accent);
	outline-offset: 4px;
	border-radius: 8px;
}
.stack__icon {
	width: clamp(24px, 3.5vw, 32px);
	height: clamp(24px, 3.5vw, 32px);
	flex: 0 0 clamp(24px, 3.5vw, 32px);
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--color-text);
}
.stack__icon svg,
.stack__icon img {
	display: block;
	width: 100%;
	height: 100%;
}
/* Customizer → Skills & Tools → Stack icon size = Large: both the
   tile and the glyph inside it scale up together (1.75x — same
   viewport widths as the standard clamp above, just 1.75x the pixel
   values at each end), keeping the same proportions rather than just
   the glyph growing inside an unchanged footprint. */
.stack--large-icons .stack__tile {
	width: clamp(70px, 10.5vw, 98px);
	height: clamp(70px, 10.5vw, 98px);
}
.stack--large-icons .stack__icon {
	width: clamp(42px, 6.125vw, 56px);
	height: clamp(42px, 6.125vw, 56px);
	flex-basis: clamp(42px, 6.125vw, 56px);
}
/* Same mechanism as .skill-item__icon--themed in the Grid layout — an
   individual skill item can still opt into following light/dark mode
   here too, even though "full-color brand icons" is this layout's own
   default. Inline SVGs inherit currentColor from the parent; image-
   based SVGs use a CSS filter to go monochrome instead. */
.stack__icon--themed { color: var(--color-text); }
.stack__icon--themed img { filter: brightness(0); }
html[data-theme="dark"] .stack__icon--themed img { filter: brightness(0) invert(1); }
@media (prefers-color-scheme: dark) {
	html:not([data-theme]) .stack__icon--themed img { filter: brightness(0) invert(1); }
}
/* Name tooltip — a single shared element (see the "Skills Stack"
   block in main.js), appended to <body> and moved/repositioned via
   position: fixed to sit under whichever icon is currently hovered,
   focused, or tapped open. Being outside .stack__viewport entirely
   (rather than a child of the item it's labelling) is what keeps it
   from ever being clipped by that wrapper's overflow: hidden — no
   matter how close to the row's own edge the active icon is, or how
   much wider the tooltip is than the tile itself. Solid
   --color-bg-raised (not glass) plus a border so it reads clearly
   against whatever's behind it, and reacts to light/dark mode the
   same way the rest of the theme's tokens do. white-space: nowrap
   lets it be exactly as wide as the name needs. */
.stack__tooltip {
	position: fixed;
	left: 0;
	top: 0;
	transform: translate(-50%, 4px);
	padding: 5px 10px;
	border-radius: 6px;
	background: var(--color-bg-raised);
	border: 1px solid var(--color-border);
	font-size: 0.75rem;
	font-weight: 500;
	line-height: 1.25;
	color: var(--color-text);
	white-space: nowrap;
	opacity: 0;
	z-index: 1000;
	pointer-events: none;
	transition: opacity 0.2s var(--ease-standard), transform 0.2s var(--ease-standard);
}
.stack__tooltip.is-visible {
	opacity: 1;
	transform: translate(-50%, 0);
}
/* Marquee mode: JS adds this to .stack__viewport once it's measured
   that the plain row above no longer fits on one line (or always, if
   "Always scroll on desktop/tablet" is ticked), and has duplicated the
   icons into the track 3x back-to-back so the loop is seamless. The
   actual scrolling is driven by nudging .stack__viewport's own
   scrollLeft every frame — see the "Skills Stack" block in main.js —
   rather than a CSS animation, so a manual drag/swipe and the
   auto-scroll can share the same underlying position instead of one
   overriding the other. */
.stack--marquee .stack__track {
	/* Overrides the static row's width: 100%/space-between above — an
	   infinitely-scrolling duplicated row needs to be exactly as wide
	   as its own (tripled) content instead, with a fixed gap between
	   icons rather than space distributed across a 100%-wide track. */
	width: max-content;
	justify-content: flex-start;
	gap: clamp(12px, 3vw, 16px);
}
@media (prefers-reduced-motion: reduce) {
	.stack__tile,
	.stack__tooltip { transition: none; }
}

/* ===================================================================
   Trip Gallery block (gathercole/trip-gallery) — front-end styles
   A Tinder-style swipeable stack of photo cards, styled after the
   homepage Trips section's own polaroid-print photo frame
   (.trips-photos__frame) — same fixed off-white "paper" background,
   hairline border and soft shadow, deliberately not a theme-adaptive
   token, since a printed photo's white border doesn't switch to dark
   mode in real life either. Editor styles are in
   assets/css/trip-gallery-editor.css; all interaction (drag/fling,
   autoplay, keyboard, dots) lives in main.js, driven off the
   data-trip-swipe-* markup rendered by inc/trip-gallery-block.php.
   =================================================================== */
.trip-swipe {
	max-width: 400px;
	margin: 1.6em auto;
}
/* Tablet/desktop only, per the deck's own bounding envelope below: at
   400px wide, a landscape photo — width-bound within that box's fixed
   4:5 shape — tops out barely any wider than a portrait one does
   height-bound in the very same box, so landscape images read as
   scaled down to match rather than using their own wider shape. A
   bigger box gives them real room to do that, while mobile stays at
   400px since there's little room to grow into there anyway. No JS
   changes needed for this to take effect responsively — sizeCard() in
   main.js already reads the deck's live clientWidth/clientHeight on
   load and on resize, so it re-fits every card to whichever of these
   is currently active. */
@media (min-width: 641px) {
	.trip-swipe {
		max-width: 480px;
	}
}
@media (min-width: 921px) {
	.trip-swipe {
		max-width: 600px;
	}
}
.trip-swipe__stage {
	position: relative;
}
/* Bounding envelope, not a shape every card is forced into — each
   card's own frame sizes itself to its image's real aspect ratio (see
   main.js) and is centered inside this box, so a portrait photo gets a
   tall narrow card and a landscape photo gets a short wide one, each
   with its own even bezel, rather than every photo being letterboxed
   or cropped into one fixed rectangle. */
.trip-swipe__deck {
	position: relative;
	aspect-ratio: 4 / 5;
}
/* Tablet/desktop only: at the wider max-widths above, a tall viewport
   isn't an issue, but a shorter one (most laptop screens) can end up
   with the deck's 4:5-derived height taller than the browser window
   itself. width: 100% + max-height together — not max-height alone —
   is what makes aspect-ratio actually shrink the box's width to match
   whenever this clamps the height, rather than only capping height
   and leaving the photo cut off/overflowing at the previously
   width-determined size. No JS changes needed here either — same
   reasoning as the comment on .trip-swipe above; sizeCard() re-fits
   every card to the deck's actual rendered size regardless of how
   that size was arrived at. */
@media (min-width: 641px) {
	.trip-swipe__deck {
		width: 100%;
		max-height: 80vh;
	}
}
.trip-swipe__card {
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	/* Per-card random tilt/drift (set once, in main.js) — every photo
	   keeps its own fixed scatter regardless of which stack position it
	   currently occupies, like a real loosely-stacked pile of prints
	   rather than a mechanically uniform fan. Only non-front positions
	   below actually apply these; the front card is always squared up. */
	--tilt: 0deg;
	--drift: 0px;
	transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s var(--ease-standard);
	touch-action: pan-y;
}
/* Stack depth: nine positions deep (front + eight peeking behind),
   each progressively smaller/further back/more rotated. Every real
   photo in the gallery gets one of these — none are ever hidden, so a
   mix of portrait and landscape photos both stay visible somewhere in
   the pile rather than one orientation only showing up once you've
   swiped several photos deep. A gallery with more photos than there
   are named positions just piles the overflow at the deepest position
   (is-back8) rather than making them disappear — see the pos clamp in
   main.js's paint(). */
.trip-swipe__card.is-back8 {
	z-index: 1;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 64px) scale(0.76) rotate(var(--tilt));
}
.trip-swipe__card.is-back7 {
	z-index: 2;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 56px) scale(0.79) rotate(var(--tilt));
}
.trip-swipe__card.is-back6 {
	z-index: 3;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 48px) scale(0.82) rotate(var(--tilt));
}
.trip-swipe__card.is-back5 {
	z-index: 4;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 40px) scale(0.85) rotate(var(--tilt));
}
.trip-swipe__card.is-back4 {
	z-index: 5;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 32px) scale(0.88) rotate(var(--tilt));
}
.trip-swipe__card.is-back3 {
	z-index: 6;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 24px) scale(0.91) rotate(var(--tilt));
}
.trip-swipe__card.is-back2 {
	z-index: 7;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 16px) scale(0.94) rotate(var(--tilt));
}
.trip-swipe__card.is-next {
	z-index: 8;
	opacity: 1;
	pointer-events: none;
	transform: translate(var(--drift), 8px) scale(0.97) rotate(var(--tilt));
}
/* main.js elevates this card's own z-index further still (to 11) for
   as long as a pointer is actively dragging it, and elevates the
   "previous" candidate card even higher (to 12) while live-dragging it
   into view from off-screen, so that incoming photo arrives on top of
   this one rather than underneath it. */
.trip-swipe__card.is-front {
	z-index: 9;
	opacity: 1;
	cursor: grab;
	transform: translate(0, 0) scale(1) rotate(0deg);
}
/* Set only while a pointer is actively dragging the front card — JS
   drives its transform directly per-frame, so the card's own
   transition (meant for released/settled states) has to step aside or
   every drag frame would visibly lag behind the pointer. Also reused
   as a one-frame "disable transition" toggle when a card needs to snap
   instantly to a new resting position — see settleInstantly() and
   restorePrev() in main.js. */
.trip-swipe__card.is-dragging {
	transition: none;
	cursor: grabbing;
}
/* The polaroid frame — a separate element from .trip-swipe__card so
   the card's own stack-position transform (scale/rotate/translate)
   never has to be recombined by hand with a drag transform; JS only
   ever sets transform on .trip-swipe__card, never a size on this.
   Sized in px by main.js to exactly match its own image's aspect
   ratio (an even 10px bezel all round, whatever that image's shape),
   the same "paper white" polaroid look as .trips-photos__frame on the
   homepage. The width/height below are only the pre-JS/no-JS fallback. */
.trip-swipe__frame {
	position: relative;
	box-sizing: border-box;
	width: 260px;
	height: 325px;
	max-width: 100%;
	max-height: 100%;
	border-radius: 18px;
	background: #FAFAFA;
	padding: 10px;
	box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12), 0 14px 32px var(--shadow-color);
}
.trip-swipe__image {
	display: block;
	width: 100%;
	height: 100%;
	/* The frame is sized to the image's own ratio exactly (see above),
	   so cover vs. contain makes no visual difference here — cover is
	   just the safer choice against any sub-pixel rounding mismatch. */
	object-fit: cover;
	border-radius: 8px;
	-webkit-user-select: none;
	user-select: none;
}
/* Arrows: identical glass-pill chrome and slide-in-plus-fade hover
   reveal as .work-carousel__arrow, so the two blocks feel like the
   same design language. Positioned on the stage rather than the deck
   so they never sit inside a rotated/scaled card's own transform. */
.trip-swipe__arrow {
	position: absolute;
	top: 50%;
	/* Above every card's resting z-index (is-front is the highest, at
	   9) so the arrows normally sit on top of the deck — but main.js
	   temporarily elevates a card being actively dragged past this (to
	   11, and to 12 for an incoming "previous" card arriving on top of
	   it), so the buttons briefly yield to whatever's actually under
	   the pointer during a drag. */
	z-index: 10;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 40px;
	height: 40px;
	border-radius: 999px;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
	color: var(--color-text-muted);
	cursor: pointer;
	opacity: 0;
	transition: opacity 0.25s var(--ease-standard), color 0.25s var(--ease-standard), transform 0.35s var(--ease-standard);
}
.trip-swipe__arrow svg { width: 18px; height: 18px; }
.trip-swipe__arrow:hover { color: var(--color-text); }
.trip-swipe__stage:hover .trip-swipe__arrow,
.trip-swipe__arrow:focus-visible {
	opacity: 1;
}
.trip-swipe__arrow--prev {
	left: -14px;
	transform: translateY(-50%) translateX(-10px);
}
.trip-swipe__arrow--next {
	right: -14px;
	transform: translateY(-50%) translateX(10px);
}
.trip-swipe__stage:hover .trip-swipe__arrow--prev,
.trip-swipe__arrow--prev:focus-visible,
.trip-swipe__stage:hover .trip-swipe__arrow--next,
.trip-swipe__arrow--next:focus-visible {
	transform: translateY(-50%) translateX(0);
}
@media (max-width: 640px) {
	.trip-swipe__arrow {
		display: none;
	}
}
/* Footer: caption + dots, matching .work-carousel__footer's own
   layout options exactly (centered by default; left-aligned moves the
   dots onto the same row; "keep dots centered" then re-centers them on
   that row via the same 3-column grid technique). */
.trip-swipe__footer {
	margin-top: 14px;
}
/* With the caption hidden (display: none, so it takes up no space of
   its own), the dots would otherwise sit right at the footer's own
   14px margin — noticeably closer to the deck than when a caption's
   line-height plus its own margin are also in the gap. Extra room
   makes up the difference. */
.trip-swipe--no-caption .trip-swipe__footer {
	margin-top: 26px;
}
/* Caption alignment/centering options are moot with nothing to align
   against — the hidden caption takes up no space in the flex/grid
   row, so without this the dots (now its only child) would drift to
   whichever side the layout leaves them at rather than sitting
   centered. auto margins on a flex item absorb the leftover space
   evenly, centering it regardless of the row's own justify-content. */
.trip-swipe--no-caption.trip-swipe--caption-left .trip-swipe__dots {
	margin: 0 auto;
}
.trip-swipe__caption {
	margin: 0 0 8px;
	text-align: center;
	font-size: 0.9rem;
	line-height: var(--leading-snug);
	color: var(--color-text-muted);
}
.trip-swipe__caption:empty {
	display: none;
	margin: 0;
}
.trip-swipe__dots {
	display: flex;
	align-items: center;
	justify-content: center;
	gap: 8px;
}
.trip-swipe--caption-left .trip-swipe__footer {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
}
.trip-swipe--caption-left .trip-swipe__caption {
	margin: 0;
	text-align: left;
}
@media (min-width: 641px) {
	.trip-swipe--center-dots.trip-swipe--caption-left .trip-swipe__footer {
		display: grid;
		grid-template-columns: 1fr auto 1fr;
		align-items: center;
	}
	.trip-swipe--center-dots.trip-swipe--caption-left .trip-swipe__caption {
		grid-column: 1;
	}
	.trip-swipe--center-dots.trip-swipe--caption-left .trip-swipe__dots {
		grid-column: 2;
	}
}
.trip-swipe__dot {
	width: 6px;
	height: 6px;
	padding: 0;
	border: none;
	border-radius: 50%;
	background: var(--color-border);
	cursor: pointer;
	transition: background 0.2s var(--ease-standard);
}
.trip-swipe__dot.is-active {
	background: var(--color-text);
}
@media (prefers-reduced-motion: reduce) {
	.trip-swipe__card,
	.trip-swipe__arrow,
	.trip-swipe__dot {
		transition: none;
	}
}

/* ===================================================================
   Work Carousel block (gathercole/work-carousel) — front-end styles
   Editor styles are in assets/css/work-carousel-editor.css. main.js
   drives all the interactive behaviour (autoplay, drag/swipe,
   keyboard nav) off the .work-carousel markup rendered in
   inc/work-carousel-block.php — this file is purely visual, nothing
   here depends on JS having run (the first slide already shows via
   plain HTML/CSS if it hasn't).
   =================================================================== */
.work-carousel {
	position: relative;
	max-width: 1000px;
	margin: 0 auto;
	/* Referenced by both the bezel frame's own padding and the footer's
	   matching inset below, so the two can never drift out of sync —
	   see the comment on .work-carousel--bezel .work-carousel__footer
	   further down for why the footer needs this at all. */
	--work-carousel-bezel-inset: 10px;
	/* Matches .work-carousel__dots' own margin-top below — without
	   this, the space above the image (before the carousel) and below
	   it (before the dots) read as visibly uneven. */
	padding-top: 14px;
}
/* .work-carousel__frame is a plain wrapper by default (no styling) —
   the bezel below is the only thing that ever styles it. Kept
   separate from .work-carousel__viewport itself so the bezel's own
   padding never shifts the viewport's internal geometry, which the
   arrows/toggle button are positioned absolute against. */
.work-carousel__frame--bezel {
	/* Same "photo print" shape as .trips-photos__frame in the
	   homepage's Trips section (padded white frame, hairline outline,
	   soft shadow) — but unlike that one, every color here uses a
	   theme token rather than a fixed white: Trips is deliberately a
	   literal printed-photo look regardless of theme, but this block
	   can end up anywhere in arbitrary page content, where a fixed
	   white frame reads as a bright, out-of-place block against a dark
	   theme instead of a deliberate material choice. */
	background: var(--color-bg-raised);
	padding: var(--work-carousel-bezel-inset);
	box-sizing: border-box;
	/* Nested-radius rule of thumb: outer radius = inner radius + inset,
	   so the frame and the image inside it read as concentric rather
	   than the outer curve looking flatter/sharper than the inner one.
	   Inner (.work-carousel__viewport) is 16px, inset is the 10px
	   padding above, so outer is 16 + 10 = 26px. */
	border-radius: 26px;
	box-shadow: 0 0 0 1px var(--color-border), 0 14px 32px var(--shadow-color);
}
/* border-radius/aspect-ratio match the 1000×640 reference size, but
   the container itself scales fluidly with its parent — aspect-ratio
   keeps that same ~1.5625:1 shape at any width rather than the fixed
   pixel size the reference gave. */
.work-carousel__viewport {
	position: relative;
	aspect-ratio: 1000 / 640;
	border-radius: 16px;
	overflow: hidden;
	background: transparent;
}
.work-carousel__track {
	display: flex;
	width: 100%;
	height: 100%;
	/* Small visible seam between slides as one scrolls past another
	   during a drag/transition, rather than them touching edge to
	   edge. Doesn't affect layout at rest — the gap sits just past
	   each slide's own 100%-width box, clipped by the viewport's own
	   overflow: hidden until a transition/drag brings it into view. */
	gap: 8px;
	transition: transform 0.45s var(--ease-standard);
	will-change: transform;
	cursor: grab;
	/* Leaves vertical page scrolling to the browser's own native
	   gesture handling; only horizontal movement is treated as a
	   carousel drag by main.js's pointer handlers. Without this, a
	   vertical swipe starting on the carousel could get fought over
	   by both the page scroll and the drag tracking. */
	touch-action: pan-y;
}
.work-carousel.is-dragging .work-carousel__track {
	cursor: grabbing;
	/* main.js adds/removes .is-dragging for the duration of an active
	   drag, same pattern as .work__grid.is-drag-scrolling elsewhere —
	   a transition fighting a live, continuous drag update reads as
	   laggy/rubbery. */
	transition: none;
}
.work-carousel__slide {
	flex: 0 0 100%;
	width: 100%;
	height: 100%;
}
.work-carousel__image {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: cover;
	-webkit-user-select: none;
	user-select: none;
}
/* Arrows: same glass-pill chrome as .cardgrid-arrow elsewhere on the
   site (same tokens: --glass-bg/--glass-border/--glass-blur/
   --shadow-color/--color-text-muted), just repositioned to overlay
   the image directly rather than sitting in a header row, and hidden
   until hover/focus rather than always visible. */
.work-carousel__arrow {
	position: absolute;
	top: 50%;
	transform: translateY(-50%);
	z-index: 2;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 40px;
	height: 40px;
	border-radius: 999px;
	background: var(--glass-bg);
	border: 1px solid var(--glass-border);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
	color: var(--color-text-muted);
	cursor: pointer;
	opacity: 0;
	transition: opacity 0.25s var(--ease-standard), color 0.25s var(--ease-standard), transform 0.35s var(--ease-standard);
}
.work-carousel__arrow svg { width: 18px; height: 18px; }
.work-carousel__arrow:hover { color: var(--color-text); }
/* :focus-visible as well as :hover, so a keyboard user tabbing to the
   arrow can actually see what they've focused — opacity: 0 alone
   would leave it invisible but still technically activatable. */
.work-carousel__viewport:hover .work-carousel__arrow,
.work-carousel__arrow:focus-visible {
	opacity: 1;
}
/* At rest each arrow sits shifted further toward its own edge (off
   past its final position) so the hover/focus reveal slides it inward
   as well as fading it in, instead of fading in place. */
.work-carousel__arrow--prev {
	left: 12px;
	transform: translateY(-50%) translateX(-10px);
}
.work-carousel__arrow--next {
	right: 12px;
	transform: translateY(-50%) translateX(10px);
}
.work-carousel__viewport:hover .work-carousel__arrow--prev,
.work-carousel__arrow--prev:focus-visible,
.work-carousel__viewport:hover .work-carousel__arrow--next,
.work-carousel__arrow--next:focus-visible {
	transform: translateY(-50%) translateX(0);
}
/* No arrows at all on mobile — swipe is the mobile interaction
   instead (see main.js). */
@media (max-width: 640px) {
	.work-carousel__arrow {
		display: none;
	}
}
/* Play/pause toggle: same glass-pill chrome as .work-carousel__arrow
   above, so it reads correctly against the page's own light/dark mode
   instead of a fixed white circle that always renders as "light mode"
   even on a dark page. */
.work-carousel__toggle {
	position: absolute;
	right: 12px;
	bottom: 12px;
	z-index: 2;
	display: flex;
	align-items: center;
	justify-content: center;
	width: 36px;
	height: 36px;
	padding: 0;
	border: 1px solid var(--glass-border);
	border-radius: 50%;
	background: var(--glass-bg);
	backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(140%);
	color: var(--color-text-muted);
	cursor: pointer;
	box-shadow: 0 8px 32px var(--shadow-color), inset 0 1px 0 rgba(255, 255, 255, 0.08);
	transition: color 0.25s var(--ease-standard);
}
.work-carousel__toggle:hover { color: var(--color-text); }
.work-carousel__toggle svg { width: 14px; height: 14px; display: block; }
/* Both icons always render (see the render callback) — only one
   shows at a time, matching the carousel's current .is-paused state.
   main.js also keeps the button's aria-label in sync, since that
   can't follow CSS state on its own. */
.work-carousel__icon-play { display: none; }
.work-carousel.is-paused .work-carousel__icon-pause { display: none; }
.work-carousel.is-paused .work-carousel__icon-play { display: flex; }
/* Footer: holds the caption + dots together so the "left aligned"
   layout (block sidebar → Caption → Alignment) can put them on one
   row via flex alone — see .work-carousel--caption-left below. Sits
   below the framed image (not clipped inside it). */
.work-carousel__footer {
	margin-top: 14px;
}
/* With the caption hidden (display: none, so it takes up no space of
   its own), the dots would otherwise sit right at the footer's own
   14px margin — noticeably closer to the frame than when a caption's
   line-height plus its own margin are also in the gap. Extra room
   makes up the difference. */
.work-carousel--no-caption .work-carousel__footer {
	margin-top: 26px;
}
/* Caption alignment/centering options are moot with nothing to align
   against — the hidden caption takes up no space in the flex/grid
   row, so without this the dots (now its only child) would drift to
   whichever side the layout leaves them at rather than sitting
   centered. auto margins on a flex item absorb the leftover space
   evenly, centering it regardless of the row's own justify-content. */
.work-carousel--no-caption.work-carousel--caption-left .work-carousel__dots {
	margin: 0 auto;
}
/* When the bezel is on, the frame's own padding (--work-carousel-
   bezel-inset) sits between the image and the frame's outer edge —
   but the footer is a sibling of the frame, not inside it, so without
   this it lines up with the frame's outer edge instead, leaving the
   caption's start and the dots' end visibly out of step with the
   image edge above them. Matching the same inset here re-aligns both
   with the image itself. */
.work-carousel--bezel .work-carousel__footer {
	padding-inline: var(--work-carousel-bezel-inset);
	box-sizing: border-box;
}
/* Caption: the current slide's typed description, or its file name if
   left blank (see gathercole_work_carousel_caption() in the render
   callback) — plain text rather than an overlay chip, since it's
   meant to read as a permanent label rather than a hover reveal.
   main.js updates this element's text whenever the slide changes;
   :empty hides it entirely rather than leaving a blank line's worth
   of margin when a slide has no caption and no fallback title either
   (a raw <img> URL with no attachment ID, and no typed caption). */
.work-carousel__caption {
	margin: 0 0 8px;
	text-align: center;
	font-size: 0.9rem;
	line-height: var(--leading-snug);
	color: var(--color-text-muted);
}
.work-carousel__caption:empty {
	display: none;
	margin: 0;
}
/* Dots: --color-border/--color-text stand in for the reference
   design's literal rgba(0,0,0,0.16)/rgb(17,17,17), so both states
   stay visible against the page background in dark mode too. */
.work-carousel__dots {
	display: flex;
	align-items: center;
	justify-content: center;
	gap: 8px;
}
/* Left-aligned caption: caption and dots share one row instead of
   stacking, so the dots move up out of their own separate line and
   sit inline with the (now left-aligned) text. */
.work-carousel--caption-left .work-carousel__footer {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
}
.work-carousel--caption-left .work-carousel__caption {
	margin: 0;
	text-align: left;
}
/* "Keep slide indicator centered" (block sidebar → Caption), only
   offered when the caption is left-aligned. Desktop/tablet only —
   mobile always keeps the default inline-with-caption layout above
   regardless of this, since there's rarely room to also center a
   second row on a narrow screen.
   Stays on the same row as the caption — a 3-column grid (caption /
   dots / an empty mirror column) puts the dots in the true middle
   column, flanked by two equal 1fr columns, so they land at the row's
   mathematical center regardless of the caption's own width, rather
   than dropping to a second line or merely being pushed to the far
   right by space-between. */
@media (min-width: 641px) {
	.work-carousel--center-dots.work-carousel--caption-left .work-carousel__footer {
		display: grid;
		grid-template-columns: 1fr auto 1fr;
		align-items: center;
	}
	.work-carousel--center-dots.work-carousel--caption-left .work-carousel__caption {
		grid-column: 1;
	}
	.work-carousel--center-dots.work-carousel--caption-left .work-carousel__dots {
		grid-column: 2;
	}
}
.work-carousel__dot {
	width: 6px;
	height: 6px;
	padding: 0;
	border: none;
	border-radius: 50%;
	background: var(--color-border);
	cursor: pointer;
	transition: background 0.2s var(--ease-standard);
}
.work-carousel__dot.is-active {
	background: var(--color-text);
}
@media (prefers-reduced-motion: reduce) {
	.work-carousel__track {
		transition: none;
	}
	.work-carousel__arrow,
	.work-carousel__dot {
		transition: none;
	}
}
