/* ==========================================================
   Aperture Pro homepage — Apple-flavoured edition.
   Pure grounds (#fff / #f5f5f7 / #000), one gradient family,
   precise radii, motion that carries meaning.
   ========================================================== */

.home-main {
	--apple-grey: #f5f5f7;
	--apple-black: #060607;
	--grad-a: #0b1c20;
	--grad-b: #024a48;
	--grad-c: #0d8f89;
	--grad-d: #6ba8cf;
	--rail-inset: max(var(--gutter), calc((100% - var(--shell)) / 2));
	--spring: cubic-bezier(0.16, 1, 0.3, 1);
	/* Long, gentle deceleration for the hero entrance — no overshoot. */
	--hero-ease: cubic-bezier(0.22, 0.61, 0.16, 1);
	/* Leaves fast, lands slowly, never overshoots: the curve to reach for when
	   several objects have to move at once and still look calm. */
	--apple-ease: cubic-bezier(0.32, 0.72, 0, 1);
	overflow-x: clip;
}

/* Section stacking for the hero hand-off. The latest band sits one layer above
   the hero so its soft, oversized colour field can bloom into the final part
   of the hero without moving either section out of normal document flow. */
.home-main > section {
	position: relative;
	z-index: 1;
}

.home-main > .home-latest {
	z-index: 2;
}

/* ---------- shared primitives ---------- */

.grad-text {
	background: linear-gradient(98deg, var(--grad-a) 0%, var(--grad-b) 26%, var(--grad-c) 58%, var(--grad-d) 92%);
	background-size: 220% 100%;
	background-clip: text;
	-webkit-background-clip: text;
	color: transparent;
	-webkit-text-fill-color: transparent;
	animation: grad-drift 14s var(--ease-inout) infinite alternate;
}

.grad-text-light {
	background: linear-gradient(98deg, #ffffff 0%, #b9e7e0 38%, #7cf0dd 66%, #8fbfe6 100%);
	background-size: 220% 100%;
	background-clip: text;
	-webkit-background-clip: text;
	color: transparent;
	-webkit-text-fill-color: transparent;
	animation: grad-drift 16s var(--ease-inout) infinite alternate;
}

@keyframes grad-drift {
	from { background-position: 0% 50%; }
	to { background-position: 100% 50%; }
}

.home-section-head {
	display: flex;
	align-items: flex-end;
	justify-content: space-between;
	gap: 24px;
	margin-bottom: clamp(28px, 3.6vw, 48px);
}

.home-section-head > div:first-child {
	flex: 1;
	min-width: 0;
}

/*
 * Section titles colour in as you scroll.
 *
 * Two backgrounds clipped to the same glyphs: the brand gradient at the
 * bottom, and on top an opaque ghost in the colour of the paper behind the
 * heading. `--fill` is the position of the ghost's edge — 0% means the ghost
 * covers everything and the words read as blank paper, 100% means it has slid
 * off and the gradient is fully exposed. JS moves that one number (see
 * initScrollFill), so nothing here needs to know about scroll at all.
 *
 * The ±7% skirt on the stops turns the boundary into a short wash rather than
 * a razor edge, which is what stops it looking like a progress bar. The
 * transparent end must be the ghost colour at zero alpha, not the `transparent`
 * keyword, or the wash picks up a grey cast on the way through.
 *
 * The ghost layer is gated behind `.js` on purpose. Nothing but script ever
 * moves `--fill`, so without it the ghost would sit at 0% forever and the
 * section titles would read as blank paper. The plain gradient below is the
 * honest fallback.
 */
.home-section-head h2 {
	--fill: 0%;
	--head-ghost: 226 234 234;
	--head-paint: linear-gradient(98deg, var(--grad-a), var(--grad-b) 38%, var(--grad-c) 72%, var(--grad-d));

	display: flex;
	align-items: center;
	gap: clamp(16px, 2.4vw, 34px);
	font-size: clamp(2rem, 4.4vw, 3.25rem);
	font-weight: 500;
	line-height: 1.08;
	letter-spacing: -0.018em;
}

.home-section-head h2 > span,
.newsletter-panel h2 > span {
	display: inline-block;
	background-image: var(--head-paint);
	background-clip: text;
	-webkit-background-clip: text;
	color: transparent;
	-webkit-text-fill-color: transparent;
}

/* Scoped through .home-main so this still outranks the black band's own
   repaint further down the file, which shares its two-class weight. */
.js .home-main .home-section-head h2 > span,
.js .home-main .newsletter-panel h2 > span {
	background-image:
		linear-gradient(90deg, rgb(var(--head-ghost) / 0) calc(var(--fill) - 7%), rgb(var(--head-ghost)) calc(var(--fill) + 7%)),
		var(--head-paint);
}

.home-section-head h2::after {
	flex: 1;
	height: 1px;
	background: linear-gradient(90deg, rgb(13 143 137 / 72%), rgb(107 168 207 / 10%) 82%, transparent);
	content: "";
}

.home-section-head .eyebrow {
	margin-bottom: 10px;
}

/* Anchor jumps must clear the floating capsule. */
.home-main > section[id] {
	scroll-margin-top: calc(var(--header-h, 0px) + clamp(12px, 2vw, 24px));
}

/* ---------- 1. hero ---------- */

/*
 * The gradient is full-bleed and stays that way — no rounded plate rising out
 * of its bottom edge.
 *
 * The hero's own gradient settles onto the exact mint used by the next section.
 * A very wide, feathered ellipse from .home-latest then blooms into that shared
 * colour from the centre. Because the joint and the ellipse's opaque foot use
 * the same value, the overlap reads as depth rather than a separate panel edge.
 */
.home-hero {
	display: grid;
	place-items: center;
	min-height: 100svh;
	margin-top: calc(var(--header-h, 0px) * -1);
	padding-block: calc(var(--header-h, 0px) + clamp(48px, 6vw, 84px)) clamp(72px, 9vw, 104px);
	/*
	 * Straight down, not tilted: an angled gradient leaves the bottom edge a
	 * slightly different colour on the left than on the right, and that alone
	 * is enough to draw the seam back in. The last stop pair holds the same
	 * mint the next section opens on, so the final stretch is flat colour by
	 * the time the two meet.
	 */
	background: linear-gradient(180deg, #ffffff 0%, #fdfefe 28%, #f4fbfa 58%, #eaf8f5 82%, #e6f6f3 100%);
	overflow: hidden;
	text-align: center;
	isolation: isolate;
}

/* The mesh, not the section, carries the recede — scaling the section itself
   would drag its background edge in and expose the paper behind it. */
.home-hero[data-hero-sink="on"] .hero-canvas {
	transform: scale(calc(1 - var(--hero-exit, 0) * 0.08));
	filter: brightness(calc(1 - var(--hero-exit, 0) * 0.12));
	transform-origin: 50% 42%;
}

/* Mesh-gradient canvas: overlapping multiplied blobs + fine grain. */
.hero-canvas {
	position: absolute;
	inset: -14%;
	z-index: -1;
	pointer-events: none;
	/*
	 * The mesh is faded out before it reaches the foot of the hero. Without
	 * this the last row of pixels is tinted by whichever blob happens to sit
	 * there, the section below opens on flat mint, and the mismatch draws
	 * exactly the dividing line this hand-off is meant to remove. The canvas is
	 * inset by 14% top and bottom, so the hero's own bottom edge is at about
	 * 89% of it — the fade has to be finished before that.
	 */
	-webkit-mask-image: linear-gradient(180deg, #000 0%, #000 60%, rgb(0 0 0 / 0) 87%);
	mask-image: linear-gradient(180deg, #000 0%, #000 60%, rgb(0 0 0 / 0) 87%);
}

.hero-blob {
	position: absolute;
	border-radius: 50%;
	filter: blur(84px);
	mix-blend-mode: multiply;
	will-change: transform;
}

.hero-blob[data-blob="1"] {
	bottom: 4%;
	left: -6%;
	width: 46vw;
	max-width: 720px;
	aspect-ratio: 1;
	background: radial-gradient(circle, rgb(15 179 168 / 62%), transparent 68%);
	animation: blob-a 24s var(--ease-inout) infinite alternate;
}

.hero-blob[data-blob="2"] {
	right: -8%;
	bottom: 8%;
	width: 44vw;
	max-width: 680px;
	aspect-ratio: 1;
	background: radial-gradient(circle, rgb(107 168 207 / 58%), transparent 68%);
	animation: blob-b 28s var(--ease-inout) infinite alternate;
}

.hero-blob[data-blob="3"] {
	bottom: -14%;
	left: 30%;
	width: 42vw;
	max-width: 640px;
	aspect-ratio: 1;
	background: radial-gradient(circle, rgb(124 240 221 / 66%), transparent 70%);
	animation: blob-c 32s var(--ease-inout) infinite alternate;
}

.hero-blob[data-blob="4"] {
	top: 2%;
	right: 14%;
	width: 30vw;
	max-width: 460px;
	aspect-ratio: 1;
	background: radial-gradient(circle, rgb(169 184 240 / 42%), transparent 70%);
	animation: blob-b 36s var(--ease-inout) infinite alternate-reverse;
}

.hero-blob[data-blob="5"] {
	top: 12%;
	left: 6%;
	width: 26vw;
	max-width: 400px;
	aspect-ratio: 1;
	background: radial-gradient(circle, rgb(255 214 170 / 34%), transparent 70%);
	animation: blob-c 30s var(--ease-inout) infinite alternate-reverse;
}

.hero-grain {
	position: absolute;
	inset: 0;
	background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(/wp-content/themes/ysjblog-aperture/assets/css/%23n)' opacity='0.5'/%3E%3C/svg%3E");
	mix-blend-mode: soft-light;
	opacity: 0.5;
}

@keyframes blob-a {
	from { transform: translate3d(0, 0, 0) scale(1); }
	to { transform: translate3d(10vw, -6vh, 0) scale(1.18); }
}

@keyframes blob-b {
	from { transform: translate3d(0, 0, 0) scale(1.12); }
	to { transform: translate3d(-9vw, 6vh, 0) scale(0.9); }
}

@keyframes blob-c {
	from { transform: translate3d(0, 0, 0) scale(0.94); }
	to { transform: translate3d(7vw, -7vh, 0) scale(1.22); }
}

.hero-copy {
	position: relative;
	z-index: 1;
	width: min(var(--shell), calc(100% - var(--gutter) * 2));
	will-change: transform, opacity;
}

.hero-brand {
	display: inline-flex;
	align-items: center;
	padding: 9px 20px;
	border-radius: var(--r-chip);
	background: rgb(255 255 255 / 62%);
	box-shadow: inset 0 0 0 1px rgb(2 74 72 / 10%);
	color: var(--ink-700);
	backdrop-filter: blur(10px);
	-webkit-backdrop-filter: blur(10px);
	animation: hero-fade 1.5s var(--hero-ease) 0.1s both;
}

/*
 * The wordmark is sized by height so its stroke weight stays in the same
 * optical family as the roles line below it — a touch larger than the
 * letter-spaced text it replaced.
 */
.hero-wordmark {
	display: block;
	height: clamp(15px, 1.5vw, 18px);
	width: auto;
	overflow: visible;
	color: var(--ink-900);
}

.hero-wordmark-accent {
	stroke: var(--teal-700);
}

/*
 * One draw for the whole mark: every path carries pathLength="1", so a single
 * dash pattern hides any stroke and they all complete together. The delay plus
 * duration land on 3.65s — the same instant the last hero element (the scroll
 * cue, 2.05s + 1.6s) finishes, so the hero has one ending, not two.
 */
.hero-wordmark path {
	stroke-dasharray: 1;
	stroke-dashoffset: 1;
	animation: hero-mark-draw 3.3s var(--hero-ease) 0.35s both;
}

@keyframes hero-mark-draw {
	to { stroke-dashoffset: 0; }
}

.hero-name {
	margin-top: clamp(20px, 2.6vw, 32px);
	font-family: "PingFang TC", "Noto Sans TC", var(--font-sans);
	font-size: clamp(4rem, 13vw, 10rem);
	font-weight: 300;
	line-height: 1;
	letter-spacing: 0.06em;
}

/*
 * Per-glyph mask reveal — the mark writes itself in.
 * Slow, single-direction ease (no spring overshoot): each letter lifts out of
 * its mask while a soft blur resolves, so the name settles rather than pops.
 */
.hero-glyph {
	display: inline-block;
	padding-bottom: 0.1em;
	margin-bottom: -0.1em;
	overflow: hidden;
	vertical-align: bottom;
}

.hero-glyph > span {
	display: inline-block;
	font-weight: inherit;
	animation: glyph-rise 1.85s var(--hero-ease) both;
	animation-delay: calc(var(--glyph-index) * 165ms + 320ms);
}

@keyframes glyph-rise {
	0% { opacity: 0; transform: translateY(88%) scale(1.06); filter: blur(9px); }
	55% { opacity: 1; }
	100% { opacity: 1; transform: none; filter: blur(0); }
}

@keyframes hero-fade {
	0% { opacity: 0; transform: translateY(20px); filter: blur(6px); }
	60% { opacity: 1; }
	100% { opacity: 1; transform: none; filter: blur(0); }
}

/* Keep the absolute centring transform after the entrance animation ends. */
@keyframes hero-scroll-fade {
	0% { opacity: 0; transform: translateX(-50%) translateY(20px); filter: blur(6px); }
	60% { opacity: 1; }
	100% { opacity: 1; transform: translateX(-50%); filter: blur(0); }
}

.hero-roles {
	display: flex;
	align-items: center;
	justify-content: center;
	flex-wrap: wrap;
	gap: 10px 16px;
	margin-top: clamp(20px, 2.6vw, 30px);
	animation: hero-fade 1.5s var(--hero-ease) 1.15s both;
}

.hero-roles span {
	color: var(--ink-700);
	font-family: var(--font-mono);
	font-size: clamp(0.6875rem, 1.4vw, 0.875rem);
	font-weight: 500;
	letter-spacing: 0.22em;
	text-transform: uppercase;
}

.hero-roles i {
	width: 4px;
	height: 4px;
	border-radius: 50%;
	background: var(--teal-600);
}

.hero-tagline {
	max-width: 22ch;
	margin: clamp(26px, 3.4vw, 40px) auto 0;
	font-size: clamp(1.5rem, 3.4vw, 2.5rem);
	font-weight: 600;
	line-height: 1.34;
	letter-spacing: -0.024em;
	animation: hero-fade 1.6s var(--hero-ease) 1.4s both;
}

.hero-tagline em {
	font-style: normal;
	font-weight: 700;
}

.hero-actions {
	display: flex;
	align-items: center;
	justify-content: center;
	flex-wrap: wrap;
	gap: 10px;
	margin-top: clamp(30px, 3.4vw, 44px);
	animation: hero-fade 1.6s var(--hero-ease) 1.68s both;
}

.hero-cta {
	display: inline-flex;
	align-items: center;
	gap: 14px;
	min-height: 56px;
	padding: 8px 8px 8px 26px;
	border-radius: var(--r-chip);
	background: var(--ink-900);
	box-shadow: 0 14px 36px rgb(2 74 72 / 18%);
	color: #fff;
	font-size: 0.9375rem;
	font-weight: 600;
	transition: box-shadow var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out);
}

.hero-cta i {
	display: grid;
	place-items: center;
	width: 40px;
	height: 40px;
	border-radius: 50%;
	background: linear-gradient(135deg, #0d8f89, #6ba8cf);
	color: #fff;
}

.hero-cta svg {
	width: 15px;
	height: 15px;
	transition: transform var(--dur-fast) var(--ease-out);
}

.hero-cta:hover {
	box-shadow: 0 20px 46px rgb(2 74 72 / 26%);
	transform: translateY(-2px);
}

.hero-cta:hover svg {
	transform: translateX(3px);
}

.hero-cta-quiet {
	display: inline-flex;
	align-items: center;
	min-height: 56px;
	padding: 0 26px;
	border-radius: var(--r-chip);
	background: rgb(255 255 255 / 62%);
	box-shadow: inset 0 0 0 1px rgb(11 28 32 / 12%);
	color: var(--ink-900);
	font-size: 0.9375rem;
	font-weight: 600;
	backdrop-filter: blur(12px);
	-webkit-backdrop-filter: blur(12px);
	transition: background var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
}

.hero-cta-quiet:hover {
	background: #fff;
	box-shadow: inset 0 0 0 1px rgb(11 28 32 / 26%);
}

.hero-scroll {
	position: absolute;
	bottom: clamp(28px, 4vw, 52px);
	left: 50%;
	display: inline-flex;
	align-items: center;
	flex-direction: column;
	gap: 10px;
	color: var(--ink-500);
	font-family: var(--font-mono);
	font-size: 0.6875rem;
	letter-spacing: 0.24em;
	text-transform: uppercase;
	transform: translateX(-50%);
	animation: hero-scroll-fade 1.6s var(--hero-ease) 2.05s both;
}

.hero-scroll i {
	position: relative;
	width: 1px;
	height: 42px;
	background: rgb(11 28 32 / 14%);
	overflow: hidden;
}

.hero-scroll i::after {
	position: absolute;
	inset-inline: 0;
	height: 16px;
	background: linear-gradient(180deg, transparent, var(--teal-600));
	content: "";
	animation: scroll-trace 2.2s var(--ease-inout) infinite;
}

@keyframes scroll-trace {
	0% { top: -18px; }
	100% { top: 44px; }
}

/* ---------- rail (最新文章) ---------- */

.rail-viewport {
	overflow-x: auto;
	overflow-y: hidden;
	scroll-snap-type: x mandatory;
	scroll-padding-inline-start: var(--rail-inset);
	scrollbar-width: none;
	-ms-overflow-style: none;
	cursor: grab;
}

.rail-viewport::-webkit-scrollbar {
	display: none;
}

.rail-viewport[data-dragging="true"] {
	cursor: grabbing;
	scroll-snap-type: none;
}

.rail-viewport[data-dragging="true"] a {
	pointer-events: none;
}

.rail-viewport[data-rail-align="center"] {
	scroll-padding-inline: 0;
}

.rail-viewport[data-rail-align="center"] .rail > * {
	scroll-snap-align: center;
}

.rail {
	display: flex;
	gap: clamp(16px, 1.6vw, 24px);
	padding-inline: var(--rail-inset);
	padding-block: 6px 4px;
}

.rail > * {
	flex: 0 0 auto;
	scroll-snap-align: start;
}

/* Apple-style control cluster: a tall pill of dots + a round play toggle. */
.rail-nav {
	display: flex;
	align-items: center;
	justify-content: center;
	gap: 10px;
	margin-top: clamp(28px, 3.2vw, 42px);
}

.rail-pill {
	display: flex;
	align-items: center;
	height: 46px;
	padding-inline: 20px;
	border-radius: var(--r-chip);
	background: rgb(11 28 32 / 6%);
}

.rail-dots {
	display: flex;
	align-items: center;
	gap: 9px;
}

.rail-dots button {
	width: 9px;
	height: 9px;
	padding: 0;
	border-radius: var(--r-chip);
	background: rgb(11 28 32 / 26%);
	cursor: pointer;
	transition: width var(--dur-base) var(--spring), background var(--dur-fast) var(--ease-out);
}

.rail-dots button:hover {
	background: rgb(11 28 32 / 48%);
}

.rail-dots button[aria-current="true"] {
	width: 30px;
	background: var(--ink-900);
}

.rail-play {
	display: grid;
	place-items: center;
	width: 46px;
	height: 46px;
	border-radius: 50%;
	background: rgb(11 28 32 / 6%);
	color: var(--ink-900);
	cursor: pointer;
	transition: background var(--dur-fast) var(--ease-out);
}

.rail-play:hover {
	background: rgb(11 28 32 / 12%);
}

.rail-play svg {
	width: 15px;
	height: 15px;
}

.rail-play .rail-play-go,
.rail-play[aria-pressed="false"] .rail-play-pause {
	display: none;
}

.rail-play[aria-pressed="false"] .rail-play-go {
	display: block;
}

/* ---------- 2. 最新文章 ---------- */

/*
 * Where the hero's gradient carries on.
 *
 * Square-edged, full-bleed, and — critically — it opens on the exact mint the
 * hero closes with, then takes its time reaching paper. No rounded plate, no
 * shadow: a shadow along the top would be the dividing line by another name.
 * The oversized ellipse above adds the sense of the new band swelling upward,
 * while this base keeps the actual section joint continuous.
 */
.home-latest {
	--hero-handoff: 0;
	--handoff-height: clamp(380px, 44vw, 660px);

	isolation: isolate;
	padding-block: clamp(72px, 9vw, 128px);
	background: linear-gradient(180deg, #e6f6f3 0%, #ecf9f6 12%, #f5fcfa 26%, var(--paper) 46%);
}

/*
 * The next section arrives as one enormous, feathered ellipse.
 *
 * Its opaque foot is the exact colour at the top of .home-latest, while the
 * outer 30% dissolves through several translucent mint rings. That thick wash
 * is what removes the visible cut line: there is no single row of pixels where
 * hero stops and the next sheet begins. Scroll progress only expands and
 * settles the field; the document itself keeps ordinary native scrolling.
 */
.home-latest::before {
	position: absolute;
	top: calc(var(--handoff-height) * -0.46);
	left: 50%;
	z-index: 0;
	width: max(166vw, 1760px);
	height: var(--handoff-height);
	background:
		radial-gradient(
			ellipse at 50% 100%,
			rgb(158 215 210 / calc(0.38 + var(--hero-handoff, 0) * 0.42)) 0 42%,
			rgb(190 230 226 / calc(0.28 + var(--hero-handoff, 0) * 0.32)) 60%,
			rgb(220 241 238 / 0) 82%
		),
		radial-gradient(
			ellipse at 50% 100%,
			#e6f6f3 0 50%,
			rgb(230 246 243 / 92%) 66%,
			rgb(230 246 243 / 0) 90%
		);
	filter: blur(clamp(12px, 1.4vw, 22px));
	pointer-events: none;
	transform: translate3d(-50%, calc((1 - var(--hero-handoff, 0)) * 15%), 0)
		scaleX(calc(0.76 + var(--hero-handoff, 0) * 0.28))
		scaleY(calc(0.7 + var(--hero-handoff, 0) * 0.3));
	transform-origin: 50% 100%;
	will-change: transform;
	content: "";
}

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

/*
 * Heads and rails share one inset, so the first card's left edge can never
 * drift away from its section title at any viewport width.
 */
.home-latest .home-section-head,
.home-photo .home-section-head,
.home-topics .home-section-head {
	width: 100%;
	padding-inline: var(--rail-inset);
}

/* Same fill on the black band, with a ghost mixed from the band's own ink.
   Only the two custom properties change — the layering rule above does the
   rest, so there is one place that knows how the fill is composed. */
.home-lab .home-section-head h2 {
	--head-ghost: 38 44 46;
	--head-paint: linear-gradient(98deg, #fff, #b9e7e0 38%, #7cf0dd 68%, #8fbfe6);
}

.home-lab .home-section-head h2::after {
	background: linear-gradient(90deg, rgb(124 240 221 / 72%), rgb(143 191 230 / 12%) 82%, transparent);
}

/*
 * Wider gutters between the article cards.
 *
 * At the old 16–24px the two cards nearest the snap point read as a pair — the
 * gap between them was smaller than the gap to the edge of the screen, so the
 * eye grouped them. Pushing the gutter past a tenth of a card's width breaks
 * that pairing and lets each card be its own object.
 */
.home-latest .rail {
	/* Ceiling in rem, so a large screen gets larger cards rather than more of
	   the same small ones parked in a row. */
	--latest-card-size: clamp(272px, 32vw, 26.25rem);

	gap: clamp(28px, 3.4vw, 60px);
}

.home-latest .rail-pill {
	height: 52px;
	padding-inline: 24px;
}

.home-latest .rail-dots {
	gap: 10px;
}

.home-latest .rail-dots button {
	width: 11px;
	height: 11px;
}

.home-latest .rail-dots button[aria-current="true"] {
	width: 36px;
}

/*
 * Article entrance — the Apple read: a long, single-direction deceleration,
 * a short rise, a hair of scale, and a blur that resolves. No overshoot, no
 * bounce.
 *
 * What changed: the stagger is no longer a timer. `.is-shown` is added one
 * card at a time by initLatestReveal as the strip climbs the viewport, so the
 * row is dealt out under the reader's scroll rather than played back at them
 * on a single trigger — and scrolling up takes the cards away again in the
 * order they arrived. A `transition-delay` here would fight that, so there
 * isn't one.
 */
.rail-item {
	display: block;
}

.home-latest .rail-viewport .rail-item {
	opacity: 0;
	transform: translate3d(0, 58px, 0) scale(0.968);
	filter: blur(8px);
	transition:
		opacity 760ms var(--hero-ease),
		transform 980ms var(--hero-ease),
		filter 700ms var(--hero-ease);
	will-change: transform, opacity;
}

.home-latest .rail-viewport .rail-item.is-shown {
	opacity: 1;
	transform: none;
	filter: blur(0);
}

/* The strip itself only reports visibility; the base reveal rule would
   otherwise slide the whole scroller and flatten the per-card stagger. */
.home-main .rail-viewport[data-reveal] {
	opacity: 1;
	transform: none;
	transition: none;
}

.post-card {
	position: relative;
	display: block;
	width: var(--latest-card-size, clamp(272px, 32vw, 26.25rem));
	border-radius: 28px;
	background: #fff;
	box-shadow: inset 0 0 0 1px rgb(11 28 32 / 7%), 0 2px 6px rgb(11 28 32 / 3%);
	overflow: hidden;
	transition: transform 560ms var(--spring), box-shadow 560ms var(--ease-out);
}

.post-card:hover,
.post-card:focus-visible {
	box-shadow: inset 0 0 0 1px rgb(2 74 72 / 22%), 0 30px 62px rgb(11 28 32 / 18%);
	transform: translateY(-8px);
}

.post-card-cover {
	position: relative;
	aspect-ratio: 1 / 1;
	background: #fff;
	overflow: hidden;
}

.post-card-cover img {
	width: 100%;
	height: 100%;
	object-fit: contain;
	transition: transform 900ms var(--spring);
}

.post-card:hover .post-card-cover img {
	transform: scale(1.06);
}

/* Hover veil: a decisive teal wash plus a spring-in open button. */
.post-card-veil {
	position: absolute;
	inset: 0;
	display: grid;
	place-items: center;
	background: linear-gradient(190deg, rgb(2 74 72 / 26%), rgb(2 74 72 / 62%));
	opacity: 0;
	backdrop-filter: blur(3px) saturate(120%);
	-webkit-backdrop-filter: blur(3px) saturate(120%);
	transition: opacity 420ms var(--ease-out);
}

.post-card:hover .post-card-veil,
.post-card:focus-visible .post-card-veil {
	opacity: 1;
}

.post-card-open {
	display: grid;
	place-items: center;
	width: 66px;
	height: 66px;
	border-radius: 50%;
	background: #fff;
	box-shadow: 0 16px 34px rgb(0 0 0 / 26%);
	color: var(--teal-700);
	transform: scale(0.55) translateY(16px);
	transition: transform 620ms var(--spring) 70ms;
}

.post-card-open svg {
	width: 22px;
	height: 22px;
}

.post-card:hover .post-card-open,
.post-card:focus-visible .post-card-open {
	transform: none;
}

.post-card-meta {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 12px;
	padding: 15px 20px 17px;
	border-top: 1px solid rgb(11 28 32 / 6%);
}

.post-card-cat {
	display: inline-flex;
	align-items: center;
	min-height: 26px;
	padding: 3px 12px;
	border-radius: var(--r-chip);
	background: var(--teal-050, #e6f6f4);
	color: var(--teal-700);
	font-size: 0.75rem;
	font-weight: 600;
	white-space: nowrap;
	transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}

.post-card:hover .post-card-cat {
	background: var(--teal-700);
	color: #fff;
}

.post-card-meta time {
	color: var(--ink-500);
	font-family: var(--font-mono);
	font-size: 0.75rem;
	letter-spacing: 0.02em;
}

/* ---------- 3. 主題地圖 ---------- */

/*
 * Not one flat grey.
 *
 * The band used to be a single fill, which read as a hole between two lively
 * sections. It is now a very shallow vertical gradient — lighter at the top so
 * it opens on almost the same value as the white band above it, a shade deeper
 * at the foot — with two big, soft colour fields sitting in it: mint on the
 * left, a cooler blue at the lower right. Both are weak enough that no edge of
 * either is findable; what the eye gets is depth, not decoration.
 */
.home-topics {
	position: relative;
	padding-block: clamp(72px, 9vw, 128px);
	background: linear-gradient(180deg, #f8f9fa 0%, #f4f5f7 42%, #eef0f3 100%);
	isolation: isolate;
}

/* The colour fields live on their own layer so they can drift a little against
   the type as the band is scrolled through — the parallax is a few percent of
   the screen, far too small to notice as movement and just enough to stop the
   background reading as a printed sheet. */
.home-topics::before {
	position: absolute;
	inset: -12% -8%;
	z-index: 0;
	background:
		radial-gradient(42% 38% at 16% 24%, rgb(120 202 192 / 30%), rgb(120 202 192 / 0) 72%),
		radial-gradient(38% 36% at 86% 76%, rgb(146 183 224 / 26%), rgb(146 183 224 / 0) 74%),
		radial-gradient(54% 40% at 62% 8%, rgb(255 255 255 / 72%), rgb(255 255 255 / 0) 70%);
	filter: blur(24px);
	pointer-events: none;
	transform: translate3d(0, calc((0.5 - var(--p, 0)) * 4svh), 0);
	content: "";
}

/* A hairline of the same grey family along the foot, so the band closes rather
   than simply stopping. */
.home-topics::after {
	position: absolute;
	right: 0;
	bottom: 0;
	left: 0;
	z-index: 0;
	height: clamp(80px, 12vh, 190px);
	background: linear-gradient(180deg, rgb(233 236 240 / 0), rgb(231 235 240 / 76%));
	pointer-events: none;
	content: "";
}

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

.home-topic-stage {
	position: relative;
}

.home-topic-grid {
	display: grid;
	grid-template-columns: repeat(5, minmax(0, 1fr));
	gap: 12px;
}

/* ---- the drawn frame that becomes the plates ----
 *
 * One rectangle the size of a single plate, sitting in the middle of the row.
 * A hairline runs out sideways, the box closes around it, and then that one box
 * is what divides: the plates leave it in both directions and the outline fades
 * out underneath them, so nothing ever traces the whole row. Its box is
 * measured in script (--frame-*) because "the size of one plate" depends on how
 * many columns the grid actually chose at this width.
 */
.topic-frame {
	position: absolute;
	top: var(--frame-y, 0px);
	left: var(--frame-x, 0px);
	z-index: 2;
	width: var(--frame-w, 190px);
	height: var(--frame-h, 198px);
	/* Drawn on a pale grey ground, so the line needs more weight than it would
	   on paper — at a third opacity it read as a smudge rather than a stroke. */
	border: 1px solid rgb(2 74 72 / 52%);
	border-radius: 28px;
	pointer-events: none;
	opacity: 0;
}

/*
 * All three beats are now scrubbed by the band's own scroll progress.
 *
 * They used to be keyframes on a 2.8s timer, which meant the sequence had no
 * relationship to the scroll that was holding the section on screen: it either
 * finished before the band had arrived or left the reader staring at an empty
 * outline, and the rest of the pinned stretch had nothing happening in it. Read
 * off --p, the split is exactly as far through as you have scrolled — stop
 * halfway and it stands there half apart, scroll back and it packs itself up.
 *
 * The smoothstep is written out longhand (t·t·(3−2t)) because there is no
 * easing function to hang on a value that is not a transition.
 */
.home-topic-stage[data-topic-intro="scrub"] .topic-frame {
	--fx: clamp(0, calc(var(--p, 0) / 0.10), 1);
	--fy: clamp(0, calc((var(--p, 0) - 0.10) / 0.09), 1);

	/* Drawn almost immediately, gone once the plates have left it behind. */
	opacity: calc(
		clamp(0, calc(var(--p, 0) / 0.015), 1) *
			(1 - clamp(0, calc((var(--p, 0) - 0.24) / 0.10), 1))
	);
	transform: scale3d(
		calc(0.08 + var(--fx) * var(--fx) * (3 - 2 * var(--fx)) * 0.92),
		calc(0.006 + var(--fy) * var(--fy) * (3 - 2 * var(--fy)) * 0.994),
		1
	);
}

/*
 * Every plate starts stacked exactly on that one box and travels out to its own
 * column. --from-x / --from-y are the gap between the two boxes, measured once
 * in script, so the plates come apart from the drawn outline rather than from
 * an assumed centre — which matters the moment the grid reflows to two columns
 * on a narrow screen.
 */
.home-main .home-topic-stage[data-topic-intro="scrub"] .topic-cell[data-reveal],
.home-main .home-topic-stage[data-topic-intro="scrub"] .topic-cell[data-reveal].is-in {
	--cs: clamp(0, calc((var(--p, 0) - 0.19 - var(--cell-index, 0) * 0.045) / 0.26), 1);
	--ec: calc(var(--cs) * var(--cs) * (3 - 2 * var(--cs)));

	opacity: clamp(0, calc(var(--cs) / 0.3), 1);
	transform: translate3d(
		calc((1 - var(--ec)) * var(--from-x, 0px)),
		calc((1 - var(--ec)) * var(--from-y, 0px)),
		0
	);
	transition: none;
}

/* The type is held down inside its clipped line until its own plate has
   landed, then pushed up like a counter rolling over — the count a beat behind
   the name, so the two do not read as one block sliding. */
.home-main .home-topic-stage[data-topic-intro="scrub"] .topic-name > span,
.home-main .home-topic-stage[data-topic-intro="scrub"] .topic-count > span {
	--ts: clamp(0, calc((var(--p, 0) - 0.38 - var(--cell-index, 0) * 0.042) / 0.20), 1);
	--et: calc(var(--ts) * var(--ts) * (3 - 2 * var(--ts)));

	transform: translate3d(0, calc((1 - var(--et)) * 118%), 0);
	transition: none;
}

.home-main .home-topic-stage[data-topic-intro="scrub"] .topic-count > span {
	--ts: clamp(0, calc((var(--p, 0) - 0.44 - var(--cell-index, 0) * 0.042) / 0.20), 1);
}

/* Once the sequence has played the plates are simply there — no animation left
   to fight the hover lift, and no dependence on the shared reveal observer
   having fired. */
.home-main .home-topic-stage[data-topic-intro="done"] .topic-cell[data-reveal],
.home-main .home-topic-stage[data-topic-intro="done"] .topic-cell[data-reveal].is-in {
	opacity: 1;
	transform: none;
	transition: none;
}

.home-main .home-topic-stage[data-topic-intro="done"] .topic-name > span,
.home-main .home-topic-stage[data-topic-intro="done"] .topic-count > span {
	transform: none;
	transition: none;
}

.topic-card {
	position: relative;
	display: grid;
	align-content: space-between;
	min-height: 12.375rem;
	padding: clamp(22px, 1.7rem, 34px);
	border: 1px solid rgb(11 28 32 / 8%);
	border-radius: 28px;
	background: #fff;
	overflow: hidden;
	transition: transform var(--dur-base) var(--ease-out), box-shadow var(--dur-base) var(--ease-out);
}

.topic-card::after {
	position: absolute;
	inset: auto -30% -60% auto;
	width: 180px;
	height: 180px;
	border-radius: 50%;
	background: radial-gradient(circle, rgb(15 179 168 / 22%), transparent 70%);
	content: "";
	opacity: 0;
	transition: opacity var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out);
}

.topic-card:hover {
	box-shadow: 0 16px 40px rgb(11 28 32 / 9%);
	transform: translateY(-4px);
}

.topic-card:hover::after {
	opacity: 1;
	transform: translate(-14px, -14px);
}

/*
 * Two-stage entrance, the way a set of stat plates reads best: the plate
 * arrives first as an empty plane, then the type climbs up out of it.
 *
 * The cell owns the arrival — a longer, further rise than the shared reveal,
 * so the row lands as a deliberate sequence — while the card underneath keeps
 * its own short transition free for the hover lift. The per-card offset comes
 * from --reveal-delay, which theme.js stamps on each child of a
 * [data-reveal-group].
 */
.topic-cell {
	display: block;
}

.home-main .home-topic-grid .topic-cell[data-reveal] {
	transform: translate3d(0, 56px, 0);
	transition:
		opacity 720ms var(--hero-ease),
		transform 1020ms var(--hero-ease);
	transition-delay: var(--reveal-delay, 0ms);
}

.home-main .home-topic-grid .topic-cell[data-reveal].is-in {
	transform: none;
}

.topic-cell .topic-card {
	height: 100%;
}

.topic-count {
	position: relative;
	z-index: 1;
	font-family: var(--font-mono);
	font-size: clamp(2.2rem, 4vw, 3.25rem);
	font-weight: 400;
	line-height: 0.9;
	letter-spacing: -0.025em;
	color: var(--teal-700);
}

.topic-name {
	position: relative;
	z-index: 1;
	font-size: 1.0625rem;
	font-weight: 550;
	letter-spacing: -0.012em;
}

/*
 * The clipped line. A little breathing room below the box keeps the digits'
 * baseline from being shaved, and the matching negative margin gives that
 * space back to the layout so nothing shifts.
 */
.topic-name,
.topic-count {
	display: block;
	padding-bottom: 0.12em;
	margin-bottom: -0.12em;
	overflow: hidden;
}

.topic-name > span,
.topic-count > span {
	display: block;
	transform: translate3d(0, 118%, 0);
	transition: transform 880ms var(--hero-ease);
	transition-delay: calc(var(--reveal-delay, 0ms) + 150ms);
}

/* The count lands last, so the eye finishes on the number. */
.topic-count > span {
	transition-delay: calc(var(--reveal-delay, 0ms) + 260ms);
}

.topic-cell.is-in .topic-name > span,
.topic-cell.is-in .topic-count > span {
	transform: none;
}

.topic-card-foot {
	position: relative;
	z-index: 1;
	display: flex;
	align-items: flex-end;
	justify-content: space-between;
	gap: 16px;
}

.topic-card-foot i {
	display: grid;
	place-items: center;
	width: 34px;
	height: 34px;
	border-radius: 50%;
	color: var(--teal-700);
	transition: background var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}

.topic-card-foot svg {
	width: 17px;
	height: 17px;
}

.topic-card:hover .topic-card-foot i {
	background: var(--teal-050);
	transform: translateX(4px);
}

.home-tag-row {
	display: flex;
	flex-wrap: wrap;
	justify-content: center;
	gap: 9px;
	margin-top: clamp(24px, 3vw, 34px);
}

.home-tag-row .tag-pill {
	display: inline-flex;
	align-items: center;
	min-height: 38px;
	padding: 8px 18px;
	border: 1px solid transparent;
	border-radius: var(--r-chip);
	background: #e9eeee;
	font-size: 0.875rem;
	transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);
}

.home-tag-row .tag-pill:hover {
	border-color: transparent;
	background: var(--ink-900);
	color: #fff;
}

/* ---------- 4. Vibe coding ---------- */

.band-black {
	background: var(--apple-black);
	color: #fff;
}

.home-lab {
	padding-block: clamp(72px, 9vw, 128px);
}

.home-lab-shell {
	width: min(var(--shell), calc(100% - var(--gutter) * 2));
	margin-inline: auto;
}

.home-lab .home-section-head {
	width: 100%;
}

.home-lab .eyebrow {
	color: var(--teal-300);
}

.home-lab-panel {
	display: grid;
	grid-template-columns: minmax(200px, 246px) minmax(0, 1fr);
	align-items: start;
	gap: clamp(20px, 2.4vw, 36px);
	width: 100%;
}

/* The light rail: a permanent line the pills hang off, with a glow that
   glides to the active pill and a beam that lights the rail on first view. */
.lab-rail {
	position: relative;
	padding-left: 26px;
}

.lab-rail-line {
	position: absolute;
	top: 6px;
	bottom: 6px;
	left: 8px;
	width: 1px;
	background: linear-gradient(180deg, rgb(255 255 255 / 4%), rgb(255 255 255 / 16%) 18%, rgb(255 255 255 / 16%) 82%, rgb(255 255 255 / 4%));
	transform-origin: top center;
}

.lab-rail-glow {
	position: absolute;
	left: 7px;
	top: 0;
	width: 3px;
	height: var(--glow-h, 48px);
	border-radius: 3px;
	background: linear-gradient(180deg, transparent, var(--glow-accent, #7cf0dd) 22%, var(--glow-accent, #7cf0dd) 78%, transparent);
	box-shadow: 0 0 18px 2px var(--glow-accent, #7cf0dd);
	opacity: 0;
	transform: translateY(var(--glow-y, 0px));
	transition: transform 720ms var(--spring), height 720ms var(--spring), opacity 420ms var(--ease-out), background 420ms var(--ease-out);
}

.lab-rail-beam {
	position: absolute;
	top: 6px;
	bottom: 6px;
	left: 0;
	width: 17px;
	opacity: 0;
	pointer-events: none;
}

.lab-rail-beam::after {
	position: absolute;
	top: 0;
	left: 6px;
	width: 5px;
	height: 5px;
	border-radius: 50%;
	background: #d8fff8;
	box-shadow: 0 0 20px 6px rgb(124 240 221 / 65%);
	content: "";
}

.lab-tablist {
	position: relative;
	display: grid;
	gap: 10px;
}

.lab-tab {
	position: relative;
	display: flex;
	align-items: center;
	width: 100%;
	min-height: 48px;
	padding: 13px 20px;
	border-radius: var(--r-chip);
	background: rgb(255 255 255 / 7%);
	color: rgb(255 255 255 / 70%);
	cursor: pointer;
	font-size: 0.9375rem;
	font-weight: 500;
	text-align: left;
	transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out), transform var(--dur-base) var(--spring);
}

/* the short connector that ties a pill back to the rail */
.lab-tab-link {
	position: absolute;
	top: 50%;
	left: -18px;
	width: 18px;
	height: 1px;
	background: linear-gradient(90deg, var(--tab-accent, #7cf0dd), rgb(255 255 255 / 0%));
	transform: scaleX(0);
	transform-origin: left center;
	transition: transform 520ms var(--spring);
}

.lab-tab:hover {
	background: rgb(255 255 255 / 13%);
	color: #fff;
}

.lab-tab[aria-selected="true"] {
	background: rgb(255 255 255 / 14%);
	color: #fff;
	font-weight: 600;
	transform: translateX(4px);
}

.lab-tab[aria-selected="true"] .lab-tab-link {
	transform: scaleX(1);
}

/* --- first-view intro: the beam runs down, pills arrive behind it --- */

.home-lab[data-lab-animated="pending"] .lab-tab,
.home-lab[data-lab-animated="running"] .lab-tab {
	opacity: 0;
}

.home-lab[data-lab-animated="pending"] .lab-rail-line,
.home-lab[data-lab-animated="running"] .lab-rail-line {
	transform: scaleY(0);
}

.home-lab[data-lab-animated="running"] .lab-rail-line {
	animation: lab-line-draw 1.05s var(--ease-out) both;
}

.home-lab[data-lab-animated="running"] .lab-rail-beam {
	animation: lab-beam-fade 1.35s linear both;
}

.home-lab[data-lab-animated="running"] .lab-rail-beam::after {
	animation: lab-beam-travel 1.35s cubic-bezier(0.5, 0.02, 0.3, 1) both;
}

.home-lab[data-lab-animated="running"] .lab-tab {
	animation: lab-tab-arrive 0.72s var(--spring) both;
	animation-delay: calc(var(--tab-index) * 150ms + 260ms);
}

@keyframes lab-line-draw {
	from { transform: scaleY(0); }
	to { transform: scaleY(1); }
}

@keyframes lab-beam-fade {
	0% { opacity: 0; }
	8% { opacity: 1; }
	86% { opacity: 1; }
	100% { opacity: 0; }
}

@keyframes lab-beam-travel {
	from { top: 0; }
	to { top: 100%; }
}

@keyframes lab-tab-arrive {
	from { opacity: 0; transform: translateX(-16px) scale(0.97); }
	to { opacity: 1; transform: none; }
}

.home-lab[data-lab-animated="done"] .lab-rail-glow {
	opacity: 1;
}

.lab-stage {
	position: relative;
	display: grid;
	min-height: 460px;
	padding: clamp(22px, 3vw, 44px);
	border: 1px solid rgb(255 255 255 / 9%);
	border-radius: 30px;
	background: radial-gradient(120% 90% at 50% 0%, rgb(255 255 255 / 7%), rgb(255 255 255 / 2%) 60%, transparent);
	overflow: hidden;
}

/* leaving state — quick and out of the way */
.lab-panel {
	display: grid;
	grid-area: 1 / 1;
	grid-template-columns: minmax(0, 0.78fr) minmax(0, 1fr);
	align-items: center;
	gap: clamp(20px, 3vw, 40px);
	opacity: 0;
	filter: blur(8px);
	pointer-events: none;
	transform: translateY(36px);
	transition: opacity 320ms var(--ease-out), transform 320ms var(--ease-out), filter 320ms var(--ease-out);
}

/* arriving state — a full second, rising from below */
.lab-panel[data-active="true"] {
	opacity: 1;
	filter: blur(0);
	pointer-events: auto;
	transform: none;
	transition:
		opacity 1000ms var(--ease-out) 140ms,
		transform 1080ms var(--spring) 140ms,
		filter 900ms var(--ease-out) 140ms;
}

/* Scroll-driven swap — short enough that a fast wheel never lands on a
   half-faded panel. */
[data-tab-scrub="true"] .lab-panel {
	transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out), filter 200ms var(--ease-out);
}

[data-tab-scrub="true"] .lab-panel[data-active="true"] {
	transition:
		opacity 300ms var(--ease-out),
		transform 380ms var(--ease-out),
		filter 300ms var(--ease-out);
}

.lab-shot {
	position: relative;
	border-radius: 18px;
	background: rgb(255 255 255 / 4%);
	overflow: hidden;
}

.lab-shot::after {
	position: absolute;
	inset: 0;
	border: 1px solid rgb(255 255 255 / 12%);
	border-radius: inherit;
	content: "";
	pointer-events: none;
}

.lab-shot-mac {
	box-shadow: 0 30px 70px rgb(0 0 0 / 55%);
}

.lab-shot-mac .lab-shot-bar {
	display: flex;
	align-items: center;
	gap: 6px;
	padding: 11px 14px;
	background: rgb(255 255 255 / 7%);
}

.lab-shot-bar i {
	width: 9px;
	height: 9px;
	border-radius: 50%;
	background: rgb(255 255 255 / 22%);
}

.lab-shot video {
	display: block;
	width: 100%;
	height: auto;
	background: #07090a;
}

.lab-shot-phone {
	width: min(232px, 100%);
	margin-inline: auto;
	border: 7px solid #1c1c1e;
	border-radius: 36px;
	box-shadow: 0 30px 70px rgb(0 0 0 / 60%);
}

.lab-shot-phone::after {
	border-radius: 29px;
}

.lab-copy h3 {
	font-size: clamp(1.625rem, 3vw, 2.25rem);
	font-weight: 700;
	letter-spacing: -0.028em;
}

.lab-kicker {
	display: inline-flex;
	align-items: center;
	gap: 7px;
	margin-bottom: 12px;
	color: rgb(255 255 255 / 60%);
	font-family: var(--font-mono);
	font-size: 0.75rem;
	letter-spacing: 0.14em;
	text-transform: uppercase;
}

.lab-kicker i {
	width: 6px;
	height: 6px;
	border-radius: 50%;
	background: var(--tab-accent, var(--teal-500));
}

.lab-copy p {
	max-width: 42ch;
	margin-top: 14px;
	color: rgb(255 255 255 / 70%);
	font-size: 0.9375rem;
	line-height: 1.75;
}

.lab-stack {
	display: flex;
	flex-wrap: wrap;
	gap: 8px;
	margin-top: 22px;
}

.lab-stack span {
	padding: 6px 13px;
	border: 1px solid rgb(255 255 255 / 16%);
	border-radius: var(--r-chip);
	color: rgb(255 255 255 / 78%);
	font-size: 0.75rem;
}

/* ---------- 5. 攝影 ---------- */

.home-photo {
	padding-block: clamp(72px, 9vw, 128px);
	background: var(--paper);
}

/*
 * The ribbon.
 *
 * The band's energy comes from the type, not from the pictures. The phrase
 * loops sideways on its own clock, at one steady rate, for as long as the band
 * is on screen: the reader's wheel neither drives it nor stalls it.
 *
 * Two structural details make it work:
 *
 * - The track holds four identical phrases and slides exactly -25%, so the
 *   loop has no seam while the three remaining copies keep wide screens
 *   covered. That only holds if the quarters measure identically, which is why
 *   spacing is a trailing margin on each item rather than `gap`: a gap sits
 *   between children, so one quarter would carry a different edge spacing.
 * - Drift and loop live on different elements. They are both transforms, and
 *   an animated transform cannot share an element with one set from script.
 *
 * Purely decorative — aria-hidden in the markup, unselectable, click-through,
 * and it only repeats what the heading above already says.
 */
.photo-marquee {
	position: relative;
	width: 100%;
	margin-block: clamp(18px, 2.4vw, 32px) clamp(30px, 4vw, 58px);
	overflow: hidden;
	-webkit-mask-image: linear-gradient(90deg, rgb(0 0 0 / 0), #000 9%, #000 91%, rgb(0 0 0 / 0));
	mask-image: linear-gradient(90deg, rgb(0 0 0 / 0), #000 9%, #000 91%, rgb(0 0 0 / 0));
	pointer-events: none;
	user-select: none;
	-webkit-user-select: none;
}

.photo-marquee-drift {
	will-change: transform;
}

.photo-marquee-track {
	display: flex;
	align-items: center;
	width: max-content;
	animation: photo-ribbon 34s linear infinite;
	will-change: transform;
}

.photo-marquee-track > * {
	flex: 0 0 auto;
	margin-right: clamp(18px, 2.4vw, 42px);
}

@keyframes photo-ribbon {
	from { transform: translate3d(0, 0, 0); }
	to { transform: translate3d(-25%, 0, 0); }
}

.photo-marquee-word {
	font-size: clamp(2.25rem, 6.4vw, 5rem);
	font-weight: 600;
	line-height: 1.05;
	letter-spacing: -0.032em;
	white-space: nowrap;
	/*
	 * A quiet ink tint, not the house gradient.
	 *
	 * The teal→blue gradient is the property of a real heading — 凝光成影 is
	 * wearing it two lines above — and repeating it here gave the band two
	 * headlines shouting the same colour at each other. Dropping the ribbon to
	 * a soft neutral drawn from the ink scale keeps it as texture, pairs it in
	 * weight with the outlined words beside it, and leaves the saturated
	 * colour in this band to the one place it belongs: the photographs.
	 */
	background-image: none;
	background-clip: text;
	-webkit-background-clip: text;
	color: color-mix(in srgb, var(--ink-900) 28%, var(--paper));
	-webkit-text-fill-color: color-mix(in srgb, var(--ink-900) 28%, var(--paper));
}

/* Every other word is outlined instead of filled, so the ribbon reads as
   texture rather than as a second headline competing with the real one. */
.photo-marquee-word.is-ghost {
	background-image: none;
	color: transparent;
	-webkit-text-fill-color: transparent;
	-webkit-text-stroke: 1px rgb(2 74 72 / 32%);
}

.photo-marquee-dot {
	width: clamp(7px, 0.85vw, 11px);
	height: clamp(7px, 0.85vw, 11px);
	border-radius: 50%;
	background: var(--teal-600, #0d8f89);
}

/* Staggered editorial grid — cards drift in from the lower-left as you scroll,
   left column first, so the eye travels left-to-right. */
.photo-grid {
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	align-items: start;
	gap: clamp(16px, 2vw, 34px);
	width: min(var(--shell), calc(100% - var(--gutter) * 2));
	margin-inline: auto;
}

/*
 * The curved-strip entrance.
 *
 * Four states, and script only moves between them (see initPhotoIntro):
 *
 *   pending — nothing shown yet.
 *   arc     — every frame is parked on one rising curve through the viewport,
 *             with a small individual angle and depth scale. Positions are
 *             measured in script, so transitions are off for this beat.
 *   align   — the stage removes its shared lean while the arc holds together.
 *   open    — the inline transforms are dropped and each frame travels from
 *             the ribbon back to its own cell, which sends them out in every
 *             direction at once because that is where their cells are.
 *   done    — plain layout again, nothing animating, hover free to work.
 */
/*
 * One easing for the whole sequence: --apple-ease leaves quickly and settles
 * for a long time without ever overshooting. The bounce of --spring is what
 * makes an entrance read as a web effect rather than as something with weight,
 * so it is deliberately not used here — the only reason the movement looks
 * expensive is that it decelerates for longer than feels necessary.
 */
.photo-stage {
	transform-origin: 50% 50%;
	transition: transform 1040ms var(--apple-ease);
}

/* The measured cards already describe the arc; the stage contributes only a
   restrained shared lean so the group reads as one object. */
.home-photo[data-photo-intro="arc"] .photo-stage {
	transform: rotate(-2.5deg) scale(0.955);
	transition: none;
}

.home-photo[data-photo-intro="align"] .photo-stage,
.home-photo[data-photo-intro="open"] .photo-stage {
	transform: none;
}

/* On the arc the cards only fade — their position is script's business, and
   the per-lane delay that staggers the normal grid would stagger the ribbon
   into existence one card at a time, so it is cancelled here. */
.home-photo[data-photo-intro="arc"] .photo-card,
.home-photo[data-photo-intro="align"] .photo-card {
	transition: opacity 520ms var(--apple-ease);
	transition-delay: 0s;
}

/* The arc must read as photographs, not as empty cards waiting for the grid's
   vertical wipe. The standard wipe resumes only for non-homepage reveals. */
.home-photo[data-photo-intro="arc"] .photo-card-frame,
.home-photo[data-photo-intro="align"] .photo-card-frame {
	clip-path: inset(0 round 20px);
	transition: none;
}

.home-photo[data-photo-intro="arc"] .photo-card-frame img,
.home-photo[data-photo-intro="align"] .photo-card-frame img {
	opacity: 1;
	transform: none;
	transition: none;
}

/*
 * The scatter is staggered by how far each frame has to travel, not by its
 * position in the markup (--photo-delay, measured in script). Ordering by index
 * sweeps left to right like a wipe; ordering by distance lets the frames that
 * barely move settle first and the far corners arrive last, which is what makes
 * a group of objects look like it is obeying the same physics.
 */
.home-photo[data-photo-intro="open"] .photo-card {
	transition:
		opacity 800ms var(--apple-ease),
		transform 1250ms var(--apple-ease);
	transition-delay: var(--photo-delay, 0ms);
}

.photo-card {
	position: relative;
	display: block;
	opacity: 0;
	transform: translate3d(-26px, 76px, 0) scale(0.965);
	transition:
		opacity 900ms var(--ease-out),
		transform 1150ms var(--spring);
	transition-delay: calc(var(--photo-lane, 0) * 130ms);
	will-change: transform, opacity;
}

.photo-card.is-in {
	opacity: 1;
	transform: none;
}

.photo-card:nth-child(3n + 2) {
	margin-top: clamp(20px, 4vw, 64px);
}

.photo-card:nth-child(3n) {
	margin-top: clamp(40px, 8vw, 124px);
}

/*
 * The frame wipes open from the top while the card rises, and afterwards keeps
 * drifting at its lane's own rate (--photo-drift),
 * so the three columns never read as one flat block. Only the frames move —
 * the captions below them stay put, because text that slides while you are
 * trying to read it is just noise.
 */
.photo-card-frame {
	position: relative;
	display: block;
	aspect-ratio: 4 / 5;
	border-radius: 20px;
	background: var(--apple-grey);
	overflow: hidden;
	clip-path: inset(0 0 100% 0 round 20px);
	transform: translate3d(0, var(--photo-drift, 0px), 0);
	transition: clip-path 1150ms var(--hero-ease);
	transition-delay: calc(var(--photo-lane, 0) * 130ms);
}

.photo-card.is-in .photo-card-frame {
	clip-path: inset(0 round 20px);
}

.photo-card:nth-child(3n + 2) .photo-card-frame {
	aspect-ratio: 1 / 1;
}

.photo-card:nth-child(3n) .photo-card-frame {
	aspect-ratio: 3 / 4;
}

/* The picture settles out of a slight push-in behind the wipe. The hover rule
   has to stay below the entrance rule — they carry equal specificity, so
   source order is the only thing deciding which scale wins. */
.photo-card-frame img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	transform: scale(1.11);
	transition: transform 1.3s var(--spring);
}

.photo-card.is-in .photo-card-frame img {
	transform: scale(1);
}

.photo-card:hover .photo-card-frame img {
	transform: scale(1.055);
}

.photo-card-copy {
	display: block;
	padding: 15px 4px 0;
}

.photo-card-place {
	display: block;
	font-size: 1.0625rem;
	font-weight: 600;
	letter-spacing: -0.014em;
	transition: color var(--dur-fast) var(--ease-out);
}

.photo-card:hover .photo-card-place {
	color: var(--teal-700);
}

.photo-card-meta {
	display: flex;
	align-items: center;
	gap: 8px;
	margin-top: 5px;
	color: var(--ink-500);
	font-size: 0.8125rem;
}

.photo-card-meta i {
	flex: 0 0 auto;
	width: 3px;
	height: 3px;
	border-radius: 50%;
	background: var(--ink-400, rgb(11 28 32 / 32%));
}

.photo-more {
	display: flex;
	justify-content: center;
	margin-top: clamp(48px, 6vw, 88px);
}

/* ---------- 6. 電子報 ---------- */

.home-newsletter {
	padding-block: clamp(72px, 9vw, 140px);
	background: var(--paper);
}

/* Light ground, no frame: the wash simply fades out at its edges. */
.newsletter-panel {
	position: relative;
	width: min(820px, calc(100% - var(--gutter) * 2));
	margin-inline: auto;
	padding: clamp(48px, 6vw, 88px) clamp(20px, 4vw, 56px);
	border-radius: 48px;
	background: radial-gradient(72% 66% at 50% 48%, #e8f9f5 0%, #f1fbf9 46%, rgb(255 255 255 / 0%) 78%);
	color: var(--ink-900);
	text-align: center;
}

.newsletter-glow {
	position: absolute;
	inset: 12% 8% -6%;
	border-radius: 50%;
	background: radial-gradient(circle, rgb(61 199 183 / 20%), transparent 66%);
	filter: blur(40px);
	pointer-events: none;
	animation: blob-a 26s var(--ease-inout) infinite alternate;
}

.newsletter-inner {
	position: relative;
	z-index: 1;
}

.newsletter-panel .eyebrow {
	color: var(--ink-500);
}

/* The closing line colours in the same way the section titles do; its ghost is
   mixed from the panel's mint wash rather than the plain paper. */
.newsletter-panel h2 {
	--fill: 0%;
	--head-ghost: 214 233 228;
	--head-paint: linear-gradient(98deg, var(--grad-a), var(--grad-b) 34%, var(--grad-c) 68%, var(--grad-d));

	margin-top: 12px;
	font-size: clamp(1.875rem, 4.2vw, 3rem);
	font-weight: 700;
	line-height: 1.14;
	letter-spacing: -0.03em;
}

.newsletter-form {
	display: flex;
	gap: 6px;
	width: min(660px, 100%);
	margin: clamp(28px, 3.4vw, 40px) auto 0;
	padding: 6px;
	border-radius: var(--r-chip);
	background: #fff;
	box-shadow:
		0 1px 2px rgb(11 28 32 / 5%),
		0 16px 44px rgb(7 71 72 / 10%);
	transition: box-shadow var(--dur-base) var(--ease-out), transform var(--dur-base) var(--ease-out);
}

.newsletter-form:focus-within {
	box-shadow:
		0 0 0 3px rgb(13 143 137 / 16%),
		0 20px 54px rgb(7 71 72 / 14%);
	transform: translateY(-1px);
}

.newsletter-form input[type="text"],
.newsletter-form input[type="email"] {
	min-width: 0;
	height: 50px;
	padding: 0 20px;
	border: 0;
	background: none;
	color: var(--ink-900);
	font-size: 0.9375rem;
}

.newsletter-form input[type="text"] {
	flex: 0 1 34%;
}

.newsletter-form input[type="email"] {
	flex: 1 1 auto;
	box-shadow: inset 1px 0 0 rgb(11 28 32 / 7%);
}

.newsletter-form input::placeholder {
	color: var(--ink-400);
}

.newsletter-form input:focus {
	outline: none;
}

.newsletter-form button {
	flex-shrink: 0;
	height: 50px;
	padding: 0 28px;
	border-radius: var(--r-chip);
	background: var(--ink-900);
	color: #fff;
	cursor: pointer;
	font-size: 0.9375rem;
	font-weight: 600;
	transition: background var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out);
}

.newsletter-form button:hover {
	background: var(--teal-700);
}

.newsletter-form button[disabled] {
	cursor: not-allowed;
	opacity: 0.55;
}

.newsletter-hp {
	position: absolute !important;
	width: 1px;
	height: 1px;
	overflow: hidden;
	clip-path: inset(50%);
}

.newsletter-status {
	display: none;
	margin: 20px auto 0;
	max-width: 46ch;
	font-size: 0.9375rem;
	line-height: 1.7;
}

.newsletter-status[data-state] {
	display: block;
	animation: hero-fade 0.6s var(--spring) both;
}

.newsletter-status[data-state="ok"] {
	color: var(--teal-700);
	font-weight: 600;
}

.newsletter-status[data-state="error"] {
	color: #a3372f;
}

.newsletter-panel[data-state="ok"] .newsletter-form,
.newsletter-panel[data-state="ok"] .newsletter-note {
	display: none;
}

.newsletter-note {
	margin-top: 16px;
	color: var(--ink-500);
	font-size: 0.8125rem;
}

.newsletter-setup {
	width: min(480px, 100%);
	margin: 18px auto 0;
	padding: 12px 18px;
	border: 1px dashed rgb(11 28 32 / 20%);
	border-radius: 16px;
	color: var(--ink-500);
	font-size: 0.8125rem;
	line-height: 1.6;
}

/* ---------- section-to-section motion ---------- */

.home-main [data-reveal] {
	opacity: 0;
	transform: translateY(38px);
	transition:
		opacity 760ms var(--ease-out),
		transform 900ms var(--spring);
	transition-delay: var(--reveal-delay, 0ms);
}

.home-main [data-reveal].is-in {
	opacity: 1;
	transform: none;
}

/*
 * No-script safety net.
 *
 * Every scroll entrance on this page starts from a hidden state that only
 * script ever clears, so if theme.js never runs the articles and photographs
 * would simply not be there. `.js` is stamped on <html> as the first thing
 * theme.js does, which makes its absence a reliable signal to show everything
 * outright.
 */
html:not(.js) .photo-card,
html:not(.js) .rail-item,
html:not(.js) .topic-name > span,
html:not(.js) .topic-count > span {
	opacity: 1;
	filter: none;
	transform: none;
}

html:not(.js) .photo-card-frame {
	clip-path: none;
}

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

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

	.photo-card:nth-child(3n),
	.photo-card:nth-child(3n + 2) {
		margin-top: 0;
	}

	.photo-card:nth-child(2n) {
		margin-top: clamp(24px, 5vw, 70px);
	}
}

/* Five cards have only two intentional desktop arrangements. If five equal
   columns no longer fit comfortably, a six-track grid gives each card two
   tracks: three cards on the first row and the final two centred underneath.
   This prevents CSS auto-fit from briefly producing an accidental 4 + 1. */
@media (min-width: 521px) and (max-width: 1100px) {
	.home-topic-grid {
		grid-template-columns: repeat(6, minmax(0, 1fr));
	}

	.home-topic-grid > .topic-cell {
		grid-column: span 2;
	}

	.home-topic-grid > .topic-cell:nth-child(4) {
		grid-column: 2 / span 2;
	}
}

@media (max-width: 900px) {
	.home-lab-panel {
		grid-template-columns: minmax(0, 1fr);
	}

	.lab-rail {
		padding-left: 0;
	}

	.lab-rail-line,
	.lab-rail-glow,
	.lab-rail-beam,
	.lab-tab-link {
		display: none;
	}

	.lab-tablist {
		display: flex;
		gap: 8px;
		margin-inline: calc(var(--gutter) * -1);
		padding-inline: var(--gutter);
		overflow-x: auto;
		scrollbar-width: none;
	}

	.lab-tablist::-webkit-scrollbar {
		display: none;
	}

	.lab-tab {
		width: auto;
		flex: 0 0 auto;
		white-space: nowrap;
	}

	.lab-tab[aria-selected="true"] {
		transform: none;
	}

	.lab-stage {
		min-height: 0;
	}

	.lab-panel {
		grid-template-columns: minmax(0, 1fr);
	}

	.lab-panel .lab-shot {
		order: -1;
	}

	.lab-shot-phone {
		width: min(200px, 62%);
	}
}

@media (max-width: 760px) {
	.home-section-head {
		align-items: flex-start;
		flex-direction: column;
		gap: 14px;
	}

	.home-section-head h2::after {
		max-width: 34vw;
	}

	.hero-tagline {
		max-width: 16ch;
	}

	/*
	 * Stacked on phones: the one-line capsule becomes three separate cards.
	 * Same materials as the desktop form — white ground, hairline ring, teal
	 * focus, dark action — but each field owns its own row, so the labels are
	 * readable and the tap targets clear 44px.
	 */
	.newsletter-form {
		display: grid;
		gap: 10px;
		width: min(420px, 100%);
		padding: 0;
		border-radius: 0;
		background: none;
		box-shadow: none;
	}

	.newsletter-form:focus-within {
		box-shadow: none;
		transform: none;
	}

	.newsletter-form input[type="text"],
	.newsletter-form input[type="email"] {
		flex: 0 0 auto;
		height: 54px;
		padding: 0 20px;
		border-radius: 18px;
		background: #fff;
		box-shadow:
			inset 0 0 0 1px rgb(11 28 32 / 8%),
			0 1px 2px rgb(11 28 32 / 4%),
			0 10px 26px rgb(7 71 72 / 7%);
		/* 16px is the floor that stops iOS Safari zooming the page on focus. */
		font-size: 1rem;
		text-align: left;
		transition: box-shadow var(--dur-base) var(--ease-out);
	}

	.newsletter-form input[type="text"]:focus,
	.newsletter-form input[type="email"]:focus {
		box-shadow:
			inset 0 0 0 1px rgb(13 143 137 / 45%),
			0 0 0 3px rgb(13 143 137 / 14%),
			0 10px 26px rgb(7 71 72 / 9%);
	}

	.newsletter-form button {
		width: 100%;
		height: 54px;
		margin-top: 2px;
		border-radius: 18px;
		box-shadow: 0 10px 26px rgb(7 71 72 / 14%);
		font-size: 1rem;
	}
}

@media (max-width: 640px) {
	/* A clipped outer capsule makes the tab row feel intentional while the
	   inner pills remain freely swipeable on touch screens. */
	.lab-rail {
		padding: 6px;
		border: 1px solid rgb(255 255 255 / 10%);
		border-radius: var(--r-chip);
		background: rgb(255 255 255 / 6%);
		box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%);
		overflow: hidden;
	}

	.lab-tablist {
		width: 100%;
		margin: 0;
		padding: 0;
		overflow-x: auto;
		overflow-y: hidden;
		overscroll-behavior-inline: contain;
		scroll-snap-type: x proximity;
		touch-action: pan-x;
		-webkit-overflow-scrolling: touch;
	}

	.lab-tab {
		min-height: 46px;
		padding: 11px 18px;
		background: transparent;
		scroll-snap-align: start;
	}

	.lab-tab[aria-selected="true"] {
		background: rgb(255 255 255 / 13%);
	}
}

@media (max-width: 560px) {
	.home-latest {
		--handoff-height: min(42svh, 360px);
	}

	.home-latest::before {
		width: 210vw;
		filter: blur(12px);
	}

	.photo-grid {
		grid-template-columns: minmax(0, 1fr);
		gap: 26px;
	}

	.photo-card:nth-child(n) {
		margin-top: 0;
	}

	.photo-card:nth-child(n) .photo-card-frame {
		aspect-ratio: 4 / 5;
	}
}

@media (max-width: 520px) {
	.home-topic-grid {
		grid-template-columns: repeat(2, minmax(0, 1fr));
	}

	.home-topic-grid > :last-child:nth-child(odd) {
		grid-column: 1 / -1;
		justify-self: center;
		width: calc((100% - 12px) / 2);
	}

	.topic-card {
		min-height: 166px;
		padding: 18px;
	}

	.hero-name {
		letter-spacing: 0.03em;
	}

	.hero-actions .hero-cta,
	.hero-actions .hero-cta-quiet {
		min-height: 50px;
	}
}

@media (prefers-reduced-motion: reduce) {
	.photo-marquee-track,
	.grad-text,
	.grad-text-light,
	.hero-blob,
	.hero-brand,
	.hero-roles,
	.hero-tagline,
	.hero-actions,
	.hero-scroll,
	.hero-scroll i::after,
	.hero-glyph > span,
	.hero-wordmark path,
	.newsletter-glow,
	.home-lab[data-lab-animated] .lab-tab,
	.home-lab[data-lab-animated] .lab-rail-line,
	.home-lab[data-lab-animated] .lab-rail-beam,
	.home-lab[data-lab-animated] .lab-rail-beam::after {
		animation: none !important;
	}

	.home-lab[data-lab-animated] .lab-tab,
	.home-lab[data-lab-animated] .lab-rail-line {
		opacity: 1;
		transform: none;
	}

	.home-main [data-reveal],
	.photo-card,
	.photo-card-frame,
	.photo-card-frame img,
	.photo-marquee-drift,
	.topic-name > span,
	.topic-count > span,
	.rail-item,
	.lab-panel {
		opacity: 1 !important;
		filter: none !important;
		transform: none !important;
		transition: none !important;
	}

	/* The wipe and the sink are the two effects that hide content until
	   something moves, so both have to be fully open, not merely still. */
	.photo-card-frame {
		clip-path: none !important;
	}

	.home-hero .hero-copy {
		-webkit-mask-image: none !important;
		mask-image: none !important;
	}

	.home-latest::before {
		transform: translate3d(-50%, 0, 0);
		will-change: auto;
	}

	/* Drawn with `both`, so without the animation the mark would stay blank. */
	.hero-wordmark path {
		stroke-dashoffset: 0;
	}

	.rail-viewport {
		scroll-behavior: auto;
	}
}

/* =====================================================================
   SCENES — one screen per section, pinned until its animation is done
   =====================================================================

   Every band is wrapped in .home-scene, a container taller than the window.
   Inside it the section itself (.home-stage) is `position: sticky; top: 0`
   and exactly one screen tall, so the wrapper's surplus height is nothing but
   scroll runway: while you travel through it the section stays parked on the
   screen and that travel plays its entrance, its hold and its exit. This is
   the whole answer to "fill the screen, finish the animation, then move on" —
   and it does it without hijacking the wheel, so trackpads, keyboards,
   find-in-page and screen readers all keep working normally.

   Script writes four numbers onto each scene every frame and the CSS below is
   written entirely as functions of them:

     --p      0 → 1  raw progress through the pinned stretch
     --enter  0 → 1  eased, front third of --p        (things arrive)
     --exit   0 → 1  eased, back quarter of --p       (things withdraw)
     --out    0 → 1  the tail after the pin releases  (the band hangs back
                     while the next one, one z-layer up, slides over it)

   All four fall back to a resting value, so with script off or blocked the
   page is simply a plain, fully visible stack.
   ===================================================================== */

.js .home-main[data-scenes] {
	--scene-run: 1.8;
}

.js .home-main[data-scenes] .home-scene {
	--scene-pin: 1;
	--enter: 1;
	--exit: 0;
	--out: 0;
	--p: 0;

	/*
	 * How hard this band withdraws when the next one arrives. These live on the
	 * scene rather than on the stage on purpose: a band that wants to leave
	 * differently — the hero, the last one — overrides them on its own wrapper,
	 * and a custom property declared on the stage itself would beat that
	 * inherited value every time. Which is exactly what used to happen: the
	 * newsletter asked not to shrink, was overruled, and ended the page as a
	 * scaled-down panel hanging over the footer.
	 */
	--exit-fade: 1;
	--exit-scale: 1;
	--out-lag: 1;

	/*
	 * The soft joint between two bands.
	 *
	 * Every band arrives as an opaque sheet sliding up over the one before it,
	 * and an opaque sheet has an edge — that single row of pixels is the hard
	 * line, and when the band underneath is showing photographs the edge also
	 * chops them in half. So the leading edge is feathered instead: --edge is
	 * how far down the screen the top of this band still is, and --feather
	 * turns that into the length of a fade that runs ahead of it. The fade is
	 * long while the band is climbing and closes to nothing exactly as it
	 * pins, which is when its top edge leaves the screen and there is nothing
	 * left to hide.
	 *
	 * Two surfaces need it: this wrapper, which paints the ground, and the
	 * sticky stage on top of it, which is masked to match.
	 */
	--edge: 0;
	/*
	 * Bent, not straight: e * (2 - e) is the same 0-to-1 ramp with its slope
	 * front-loaded, so the fade is still generous in the last moments before
	 * the band pins — which is exactly when a linear feather has already
	 * collapsed to a few dozen pixels and the joint snaps back into a line.
	 */
	--feather: calc(var(--edge, 0) * (2 - var(--edge, 0)) * 46svh);
	--seam-25: calc(var(--feather, 0px) * 0.25);
	--seam-50: calc(var(--feather, 0px) * 0.5);
	--seam-75: calc(var(--feather, 0px) * 0.75);

	position: relative;
	z-index: var(--scene-z, 1);
	height: calc(var(--scene-run) * 100svh);
	background: linear-gradient(
		180deg,
		var(--scene-fade, rgb(255 255 255 / 0)) 0,
		color-mix(in srgb, var(--scene-bg, var(--paper)) 10%, transparent) var(--seam-25),
		color-mix(in srgb, var(--scene-bg, var(--paper)) 50%, transparent) var(--seam-50),
		color-mix(in srgb, var(--scene-bg, var(--paper)) 90%, transparent) var(--seam-75),
		var(--scene-bg, var(--paper)) var(--feather)
	);
}

/* The hero still starts under the floating capsule, but the pull-up now lives
   on the wrapper — moving the sticky element itself would offset the very
   coordinate the pin is measured from. */
.js .home-main[data-scenes] .home-scene[data-scene="hero"] {
	margin-top: calc(var(--header-h, 0px) * -1);

	/* The hero's own dimming is the plateau's job, not the shared withdrawal's. */
	--exit-fade: 0;
	--exit-scale: 0;
	/* The sticky hero finishes on this exact plateau. Keeping the wrapper and
	   its outgoing tail on the same value prevents the stage's lower edge from
	   exposing a white strip before 最新文章 reaches it. */
	--scene-bg: #8fd8ce;
	--scene-fade: rgb(143 216 206 / 0);
}

/* Each band's ground, plus the same colour at zero alpha for the feather to
   dissolve into. Interpolating to plain `transparent` would run the ramp
   through transparent *black* and leave a grey bruise along a white joint. */
/*
 * The trailing edge.
 *
 * A band's ground stops dead at the bottom of its own wrapper, and that row of
 * pixels is the hard line: the arriving band fades *in* from there, so for the
 * length of the fade the page shows white paper between the two of them, with
 * the outgoing colour ending on a ruler-straight edge.
 *
 * So every band that has a successor keeps painting below itself: a tail of its
 * own ground, in its own colour, dying out over the same distance the arriving
 * band needs to become solid. The two ramps are complements — where one is thin
 * the other is thick — so the joint stays opaque the whole way through and
 * neither edge is ever visible. It sits behind the sticky stage, so a band that
 * is still standing there (photographs and all) still reads on top of it.
 *
 * The last band has no successor, and a tail there would hang a veil over the
 * footer, so it is left out.
 */
.js .home-main[data-scenes] .home-scene[data-scene="hero"]::after,
.js .home-main[data-scenes] .home-scene[data-scene="latest"]::after,
.js .home-main[data-scenes] .home-scene[data-scene="topics"]::after,
.js .home-main[data-scenes] .home-scene[data-scene="lab"]::after,
.js .home-main[data-scenes] .home-scene[data-scene="photo"]::after {
	position: absolute;
	top: 100%;
	right: 0;
	left: 0;
	z-index: -1;
	height: 46svh;
	background: linear-gradient(
		180deg,
		var(--scene-bg, var(--paper)) 0,
		color-mix(in srgb, var(--scene-bg, var(--paper)) 90%, transparent) 25%,
		color-mix(in srgb, var(--scene-bg, var(--paper)) 50%, transparent) 50%,
		color-mix(in srgb, var(--scene-bg, var(--paper)) 10%, transparent) 75%,
		var(--scene-fade, rgb(255 255 255 / 0)) 100%
	);
	pointer-events: none;
	content: "";
}

.js .home-main[data-scenes] .home-scene[data-scene="latest"] {
	--scene-bg: var(--paper);
	--scene-fade: rgb(255 255 255 / 0);
}

.js .home-main[data-scenes] .home-scene[data-scene="topics"] {
	/* Match the stage's final gradient stop exactly. A lighter wrapper left a
	   one-row colour step at the stage boundary during the lab hand-off. */
	--scene-bg: #eef0f3;
	--scene-fade: rgb(238 240 243 / 0);
}

.js .home-main[data-scenes] .home-scene[data-scene="lab"] {
	--scene-bg: var(--apple-black, #060607);
	--scene-fade: rgb(6 6 7 / 0);
}

.js .home-main[data-scenes] .home-scene[data-scene="photo"] {
	--scene-bg: var(--paper);
	--scene-fade: rgb(255 255 255 / 0);
}

/* The last band has no successor to slide over it, only the footer — so it
   keeps its runway short and leaves the way an ordinary section does. */
.js .home-main[data-scenes] .home-scene[data-scene="newsletter"] {
	--scene-run: 1.2;
	--out-lag: 0;
	--exit-fade: 0;
	--exit-scale: 0;
	--scene-bg: var(--paper);
	--scene-fade: rgb(255 255 255 / 0);
}

.js .home-main[data-scenes] .home-stage {
	position: sticky;
	top: 0;
	height: 100svh;
	min-height: 100svh;
	margin-top: 0;
	overflow: hidden;
	/*
	 * Withdrawal, then lag.
	 *
	 * While the band is still pinned --exit eases it back a few percent and
	 * dims it: it reads as stepping away from the reader rather than being
	 * shoved off the top. Once the pin releases, --out holds it back against
	 * the scroll at roughly half speed, so the next band — one z-layer above,
	 * with its own opaque ground — appears to travel up *over* a section that
	 * is still standing there. That overlap is the transition; there is no
	 * moment where the page is showing a gap between two bands.
	 */
	transform:
		translate3d(0, calc(var(--out, 0) * var(--out-lag) * 52svh), 0)
		scale(
			calc(
				1 - var(--exit, 0) * var(--exit-scale) * 0.03 -
					var(--out, 0) * var(--exit-scale) * 0.025
			)
		);
	transform-origin: 50% 38%;
	/* The fade is split in two: a gentle dip while the band is still pinned
	   and alone on screen (so it never washes out while the reader is still
	   reading it), then the rest of the way out once the next band has
	   actually started covering it. */
	opacity: calc(
		1 - var(--exit, 0) * var(--exit-fade) * 0.32 -
			var(--out, 0) * var(--exit-fade) * 0.45
	);
	/*
	 * The stage's own ground and its content share the wrapper's soft edge, so
	 * the band does not cut across whatever it is arriving over. At rest
	 * --feather is 0 and the mask is a solid black sheet — no clipping.
	 *
	 * The bottom of the sheet needs the same courtesy once the band starts to
	 * leave: the lag drags the stage down past the end of its own wrapper, and
	 * its lower edge then sits in the middle of the screen as a straight line
	 * across the band arriving underneath it. The fade only opens as far as
	 * the band actually lags, so a band that leaves without lagging — the last
	 * one — never gets it.
	 */
	--tail-mask: calc(var(--out, 0) * var(--out-lag) * 22svh);

	-webkit-mask-image: linear-gradient(
		180deg,
		rgb(0 0 0 / 0) 0,
		rgb(0 0 0 / 10%) var(--seam-25),
		rgb(0 0 0 / 50%) var(--seam-50),
		rgb(0 0 0 / 90%) var(--seam-75),
		#000 var(--feather, 0px),
		#000 calc(100% - var(--tail-mask)),
		rgb(0 0 0 / 90%) calc(100% - var(--tail-mask) * 0.75),
		rgb(0 0 0 / 50%) calc(100% - var(--tail-mask) * 0.5),
		rgb(0 0 0 / 10%) calc(100% - var(--tail-mask) * 0.25),
		rgb(0 0 0 / 0) 100%
	);
	mask-image: linear-gradient(
		180deg,
		rgb(0 0 0 / 0) 0,
		rgb(0 0 0 / 10%) var(--seam-25),
		rgb(0 0 0 / 50%) var(--seam-50),
		rgb(0 0 0 / 90%) var(--seam-75),
		#000 var(--feather, 0px),
		#000 calc(100% - var(--tail-mask)),
		rgb(0 0 0 / 90%) calc(100% - var(--tail-mask) * 0.75),
		rgb(0 0 0 / 50%) calc(100% - var(--tail-mask) * 0.5),
		rgb(0 0 0 / 10%) calc(100% - var(--tail-mask) * 0.25),
		rgb(0 0 0 / 0) 100%
	);
	will-change: transform, opacity;
}

/* Hero keeps its own centred grid; every other band becomes a centred column
   so its content sits in the middle of the screen at any window height. */
.js .home-main[data-scenes] .home-stage:not(.home-hero) {
	display: flex;
	flex-direction: column;
	justify-content: center;
	padding-block: calc(var(--header-h, 0px) + clamp(16px, 3vh, 40px)) clamp(20px, 4vh, 52px);
}

.js .home-main[data-scenes] .home-hero {
	padding-block: calc(var(--header-h, 0px) + clamp(24px, 4vh, 64px)) clamp(40px, 6vh, 84px);
}

/* An anchor now lands on the wrapper, which is exactly where the band pins —
   so no extra offset is needed or wanted. */
.js .home-main[data-scenes] .home-scene[id] {
	scroll-margin-top: 0;
}

/* The old flat stacking is replaced by the scene z-index above. */
.js .home-main[data-scenes] > section,
.js .home-main[data-scenes] > .home-latest {
	z-index: auto;
}

/* ---------- scrubbed reveals ----------

   The reveal is no longer a class the observer flips; it is read straight off
   the scene's own --enter, so the content is exactly as far in as you have
   scrolled and it rewinds if you scroll back. --rs is a stagger step: each
   step delays that element by a slice of the entrance.                    */

.js .home-main[data-scenes] [data-reveal]:not(.topic-cell) {
	--rs: 0;
	--e: clamp(0, calc((var(--enter, 1) - var(--rs) * 0.1) / 0.6), 1);

	opacity: var(--e);
	transform: translate3d(0, calc((1 - var(--e)) * 40px), 0);
	transition: none;
}

.js .home-main[data-scenes] .rail-viewport[data-reveal] { --rs: 1; }
.js .home-main[data-scenes] .home-lab-panel[data-reveal] { --rs: 1; }
.js .home-main[data-scenes] .photo-more[data-reveal] { --rs: 5; }

/* The topic labels are a fourth beat, after the frame, plates and their type.
   The fifth card's count finishes at roughly --p .81; only then may the pills
   begin. Each pill takes a slightly later slice of the same scrubbed progress,
   so reversing the scroll also reverses the reveal cleanly. */
.js .home-main[data-scenes] .home-topics .home-tag-row {
	--tag-e: clamp(0, calc((var(--p, 0) - 0.81) / 0.15), 1);
}

.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill[data-reveal] {
	--tag-i: 0;
	--tag-p: clamp(0, calc((var(--tag-e) - var(--tag-i) * 0.04) / 0.72), 1);

	opacity: var(--tag-p);
	transform: translate3d(0, calc((1 - var(--tag-p)) * 18px), 0)
		scale(calc(0.96 + var(--tag-p) * 0.04));
	transition: none;
}

.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill:nth-child(2) { --tag-i: 1; }
.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill:nth-child(3) { --tag-i: 2; }
.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill:nth-child(4) { --tag-i: 3; }
.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill:nth-child(5) { --tag-i: 4; }
.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill:nth-child(6) { --tag-i: 5; }
.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill:nth-child(7) { --tag-i: 6; }
.js .home-main[data-scenes] .home-topics .home-tag-row .tag-pill:nth-child(8) { --tag-i: 7; }

/* ---------- 2 · 最新文章 dealt out across the pin ----------

   The cards are not a stagger on the entrance any more; they are the section's
   whole timeline. --rs2 is each card's place in the queue as a 0 → 1 fraction,
   written by script because how many cards a screen shows depends on how wide
   it is, and whatever that number is they all have to be dealt by the time the
   band hands over. Everything past the last visible card shares the final slot,
   so dragging the rail afterwards never uncovers a blank.                    */

.js .home-main[data-scenes] .home-latest .rail-viewport .rail-item {
	--c: clamp(0, calc((var(--p, 0) - 0.04 - var(--rs2, 0) * 0.52) / 0.26), 1);
	--ec: calc(var(--c) * var(--c) * (3 - 2 * var(--c)));

	opacity: var(--ec);
	transform:
		translate3d(0, calc((1 - var(--ec)) * 58px), 0)
		scale(calc(0.968 + var(--ec) * 0.032));
	filter: blur(calc((1 - var(--ec)) * 8px));
	transition: none;
}

/* ---------- 1 · the hero → 最新文章 bloom ----------

   One ellipse, in the colour the next band opens with, anchored below the
   hero's bottom edge and grown by the hero's own scroll progress until it has
   very nearly filled the screen. Because it is a single feathered layer rather
   than a border between two backgrounds, there is no row of pixels where one
   section stops and the next starts — only colour that runs out before it
   reaches anything. The next band then arrives already wearing that same mint
   and dissolves it to paper as it settles, so the swell and the section are
   literally the same colour event.                                        */

.hero-bloom {
	position: absolute;
	/* Centred on the hero's bottom edge, so only the upper dome is ever on
	   screen and the swell is read as rising rather than as a shape sliding
	   up from below. */
	bottom: -95svh;
	left: 50%;
	z-index: 0;
	width: 190vw;
	height: 190svh;
	background: radial-gradient(
		closest-side,
		rgb(104 200 189 / 96%) 0%,
		rgb(128 211 201 / 94%) 26%,
		rgb(163 224 216 / 86%) 47%,
		rgb(196 236 231 / 60%) 66%,
		rgb(216 244 240 / 26%) 82%,
		rgb(228 247 244 / 0%) 100%
	);
	opacity: clamp(0, calc(var(--p, 0) * 4), 1);
	pointer-events: none;
	transform:
		translate3d(-50%, 0, 0)
		scaleX(calc(0.5 + var(--p, 0) * 0.56))
		scaleY(calc(0.12 + var(--p, 0) * 0.96));
	transform-origin: 50% 50%;
	will-change: transform, opacity;
}

/*
 * The plateau: the hero's last frame, flushed to one flat colour.
 *
 * The bloom on its own could not remove the seam. 最新文章's top edge only ever
 * appears at the exact moment the hero reaches the end of its runway, and at
 * that moment the hero was showing the *ellipse* — a radial with its own
 * centre colour and its own falloff — while the band arriving underneath it
 * opens on flat #8fd8ce. Two different colours meeting along a straight edge is
 * a line, however soft each of them is on its own.
 *
 * So the hero finishes by flushing to that one value. It sits above the bloom
 * and below the copy, reaching full strength a little before the handover, so
 * by the time there is a joint to see, both sides of it are literally the same
 * colour and the edge has nothing to be.
 */
.hero-plateau {
	position: absolute;
	inset: -2px;
	z-index: 0;
	background: #8fd8ce;
	opacity: clamp(0, calc((var(--p, 0) - 0.42) / 0.46), 1);
	pointer-events: none;
}

/* The old under-section blob is what the bloom replaces. */
.js .home-main[data-scenes] .home-latest::before {
	display: none;
}

.js .home-main[data-scenes] .home-latest {
	isolation: isolate;
	background: var(--paper);
}

/*
 * 最新文章 arrives mint and settles to paper.
 *
 * It slides up wearing the exact colour the bloom left on the screen, so the
 * joint between the two is invisible by construction; the wash then fades out
 * across the band's own entrance, which is what makes the colour read as
 * something that swelled up and resolved rather than as a coloured stripe
 * parked at the top of a section.
 */
.js .home-main[data-scenes] .home-latest::after {
	position: absolute;
	inset: 0;
	z-index: 0;
	background: linear-gradient(
		180deg,
		#8fd8ce 0%,
		#a9e2da 16%,
		#c9ece8 36%,
		#e8f7f5 58%,
		rgb(255 255 255 / 0%) 78%
	);
	/*
	 * Held at full strength for the whole approach and dissolved only once the
	 * band is pinned — i.e. once it covers the hero completely. Fading it on
	 * --enter instead used to lighten the band's top edge while the hero's own
	 * mint was still visible directly above it, which put the seam straight
	 * back: the wash has to stay the same colour as the plateau for exactly as
	 * long as the two are on screen together.
	 */
	opacity: calc(1 - clamp(0, calc(var(--p, 0) / 0.34), 1) * 0.94);
	pointer-events: none;
	content: "";
}

/* ---------- 5 · 凝光成影: a streamer that becomes six pictures ----------

   Two beats, both scrubbed by the band's own scroll progress.

   First the 彩帶: a dozen small frames spiral in from off-screen — each one
   swung round on a decaying radius, so the path is a curve rather than a
   straight slide — and settle onto one diagonal band across the middle of the
   section. Then the hand-over: the streamer dissolves and the six real
   pictures fly in from six different directions at once and land in their own
   cells, two rows of three.

   The directions are the cells' own: the top row arrives from above and from
   the two upper corners, the bottom row from below and from the two lower
   ones, so nothing crosses anything else on the way in and the group resolves
   into the grid instead of assembling on top of it.

   Two rows is the layout the section was asked for; the frames are therefore
   sized by height rather than by aspect ratio, which is the only way two rows
   plus a heading, a ribbon and a button are guaranteed to fit one screen at
   any window size.                                                          */

.js .home-main[data-scenes] .home-photo .photo-marquee {
	flex: 0 0 auto;
	margin-block: clamp(4px, 0.8vh, 10px) clamp(8px, 1.4vh, 18px);
}

.js .home-main[data-scenes] .home-photo .photo-marquee-word {
	font-size: clamp(1.5rem, 3.4vw, 2.75rem);
}

.js .home-main[data-scenes] .home-photo .photo-stage {
	position: relative;
	transform: none;
	transition: none;
}

/* ---- the streamer ---- */

.js .home-main[data-scenes] .home-photo .photo-ribbon {
	position: absolute;
	inset: 0;
	z-index: 3;
	pointer-events: none;
}

.js .home-main[data-scenes] .home-photo .photo-ribbon-tile {
	--r: clamp(0, calc((var(--p, 0) - var(--i, 0) * 0.014) / 0.30), 1);
	--er: calc(var(--r) * var(--r) * (3 - 2 * var(--r)));
	/* Swing angle and radius: the tile is rotated out to --rr, rotated back so
	   it stays upright, and the radius is what collapses — which traces a
	   spiral in rather than a straight run. */
	--th: calc(var(--i, 0) * 27deg + (1 - var(--er)) * 420deg);
	--rr: calc((1 - var(--er)) * 52vmin);
	--gone: clamp(0, calc((var(--p, 0) - 0.30) / 0.12), 1);

	position: absolute;
	top: 50%;
	left: 50%;
	width: clamp(70px, 8.4vw, 148px);
	height: clamp(94px, 11.2vw, 196px);
	border-radius: 12px;
	box-shadow: 0 14px 38px rgb(11 28 32 / 20%);
	opacity: calc(clamp(0, calc(var(--p, 0) / 0.04), 1) * (1 - var(--gone)));
	overflow: hidden;
	transform:
		translate(-50%, -50%)
		translate(
			calc((var(--i, 0) - 5.5) * 7.2vmin),
			calc((var(--i, 0) - 5.5) * 1.5vmin)
		)
		rotate(var(--th))
		translateX(var(--rr))
		rotate(calc(var(--th) * -1))
		rotate(calc(-10deg + (1 - var(--er)) * 200deg))
		scale(calc(0.62 + var(--er) * 0.38 - var(--gone) * 0.24));
	will-change: transform, opacity;
}

.js .home-main[data-scenes] .home-photo .photo-ribbon-tile img {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: cover;
}

/* ---- the six that stay ---- */

/* Six photographs land as two portrait rows of three. Their incoming vectors
   remain untouched; each row steps down from left to right after landing.
   Height still comes from the viewport so both rows, their captions and the
   section controls fit inside the pinned stage. */
.js .home-main[data-scenes] .home-photo .photo-grid {
	/* Grow with the room the screen actually gives us. Width makes the gallery
	   feel at home on a 27-inch display; height remains the safety rail that
	   keeps both rows, their captions and the button inside the pinned stage. */
	/*
	 * Height comes from what is left, not from a wish.
	 *
	 * The band is pinned to exactly one screen, so its heading, ribbon, two
	 * rows of captions and the closing button all have to be paid for before
	 * the photographs get anything. Everything above measures out at roughly a
	 * third of the screen plus half the floating header plus a fixed remainder,
	 * so the frames take half of whatever survives that. Asking for a fixed
	 * 25vh instead is what used to push the title up behind the header and the
	 * button off the bottom edge: the overflow was split between both ends of
	 * the centred column.
	 */
	--photo-trim: 72px;
	--photo-h: clamp(
		120px,
		min(calc(33svh - var(--header-h, 0px) / 2 - var(--photo-trim)), 22vw),
		360px
	);
	--photo-stair: clamp(8px, 1vh, 14px);

	position: relative;
	z-index: 1;
	display: grid;
	grid-template-columns: repeat(3, minmax(0, calc(var(--photo-h) * 0.78)));
	justify-content: center;
	align-items: flex-start;
	gap: clamp(8px, 1vh, 14px) clamp(14px, 1.8vw, 30px);
	width: min(var(--shell, 1180px), calc(100% - var(--gutter) * 2));
	padding-bottom: calc(var(--photo-stair) * 2);
}

.js .home-main[data-scenes] .home-photo .photo-card {
	--c: clamp(0, calc((var(--p, 0) - 0.33 - var(--photo-index, 0) * 0.03) / 0.26), 1);
	--ec: calc(var(--c) * var(--c) * (3 - 2 * var(--c)));
	--in-d: 34vmin;

	width: 100%;
	min-width: 0;
	max-width: calc(var(--photo-h) * 0.78);
	margin-top: 0;
	opacity: clamp(0, calc(var(--c) / 0.28), 1);
	transform:
		translate3d(
			calc(var(--in-x, 0) * var(--in-d) * (1 - var(--ec))),
			calc(var(--in-y, 0) * var(--in-d) * (1 - var(--ec))),
			0
		)
		rotate(calc(var(--in-r, 0deg) * (1 - var(--ec))))
		scale(calc(0.84 + var(--ec) * 0.16));
	transition: none;
	will-change: transform, opacity;
}

.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n + 2) {
	margin-top: var(--photo-stair);
}

.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n) {
	margin-top: calc(var(--photo-stair) * 2);
}

/* Each cell's own corner of the screen. */
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(1) { --in-x: -1.2; --in-y: 0.85; --in-r: -12deg; }
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(2) { --in-x: -0.75; --in-y: -1.15; --in-r: 10deg; }
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3) { --in-x: 0.1; --in-y: -1.35; --in-r: -8deg; }
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(4) { --in-x: -0.1; --in-y: 1.35; --in-r: 9deg; }
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(5) { --in-x: 0.75; --in-y: -1.15; --in-r: -10deg; }
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(6) { --in-x: 1.2; --in-y: 0.85; --in-r: 12deg; }

/*
 * Height, not aspect ratio. The pictures arrive whole — they flew in as
 * photographs, so wiping them open on landing would be a second entrance for
 * something that has already entered.
 */
.js .home-main[data-scenes] .home-photo .photo-card-frame,
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n + 2) .photo-card-frame,
.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n) .photo-card-frame {
	height: var(--photo-h);
	aspect-ratio: auto;
	border-radius: 16px;
	clip-path: inset(0 round 16px);
	transform: translate3d(0, 0, 0);
	transition: transform 620ms var(--apple-ease);
}

.js .home-main[data-scenes] .home-photo .photo-card:hover .photo-card-frame {
	transform: translate3d(0, -7px, 0);
}

.js .home-main[data-scenes] .home-photo .photo-card-frame img {
	transform: scale(calc(1.08 - var(--ec, 1) * 0.08));
	transition: none;
}

.js .home-main[data-scenes] .home-photo .photo-card:hover .photo-card-frame img {
	transform: scale(1.05);
	transition: transform 1.1s var(--spring);
}

.js .home-main[data-scenes] .home-photo .photo-card-copy {
	padding-top: 9px;
}

/*
 * Two lines, then stop.
 *
 * A cell is as wide as its frame, so a long place name used to wrap to three
 * or four lines, push its own row taller and — because every row in the band
 * is paid for out of the same one screen — steal the height from the
 * photographs, which narrowed the cells, which wrapped the name further still.
 * Capping the name breaks that loop and squares the two rows off against each
 * other; the full title is one click away on the collection page.
 */
.js .home-main[data-scenes] .home-photo .photo-card-place {
	display: -webkit-box;
	-webkit-box-orient: vertical;
	-webkit-line-clamp: 2;
	font-size: clamp(0.8125rem, 0.95vw, 0.9375rem);
	overflow: hidden;
}

.js .home-main[data-scenes] .home-photo .photo-card-meta {
	font-size: 0.75rem;

	/* Date and count are one fact, so they break the row rather than the pair. */
	white-space: nowrap;
}

/* The button is the section's last beat, so it waits for the pictures. */
.js .home-main[data-scenes] .home-photo .photo-more[data-reveal] {
	--e: clamp(0, calc((var(--p, 0) - 0.66) / 0.2), 1);

	margin-top: clamp(8px, 1.2vh, 18px);
	opacity: var(--e);
	transform: translate3d(0, calc((1 - var(--e)) * 34px), 0);
}

/* ---------- fitting a band into one screen ---------- */

.js .home-main[data-scenes] .home-latest,
.js .home-main[data-scenes] .home-topics,
.js .home-main[data-scenes] .home-photo,
.js .home-main[data-scenes] .home-newsletter {
	padding-block: calc(var(--header-h, 0px) + clamp(16px, 3vh, 40px)) clamp(20px, 4vh, 52px);
}

.js .home-main[data-scenes] .home-section-head {
	margin-bottom: clamp(14px, 2.4vh, 34px);
}

/* The photography band carries more stacked parts than any other scene, so its
   heading keeps a tighter gap than the shared rhythm. */
.js .home-main[data-scenes] .home-photo .home-section-head {
	margin-bottom: clamp(8px, 1.2vh, 16px);
}

/* Belt and braces for the pinned band: should a window still be shorter than
   the column needs, `safe` centring gives up the centring rather than letting
   the heading ride up behind the floating header. */
.js .home-main[data-scenes] .home-photo {
	justify-content: safe center;
}

.js .home-main[data-scenes] .home-newsletter .newsletter-panel {
	padding: clamp(28px, 4vh, 64px) clamp(20px, 4vw, 56px);
}

/* A short window — a 768-tall laptop, a landscape tablet — has full desktop
   width but far less vertical room, and the band still has to seat a heading,
   a ribbon, two rows of captioned photographs and a button between the
   floating header and the bottom edge. Keep every beat of the choreography and
   squeeze only the spacing between them, then let the frames take what is
   left. */
@media (min-width: 561px) and (max-height: 940px) {
	.js .home-main[data-scenes] .home-photo {
		padding-block: calc(var(--header-h, 0px) + 8px) 16px;
	}

	.js .home-main[data-scenes] .home-photo .home-section-head {
		margin-bottom: 6px;
	}

	.js .home-main[data-scenes] .home-photo .photo-marquee {
		margin-block: 3px 6px;
	}

	/* The ribbon is texture, so it is the first thing asked to give up room —
	   the section's real heading keeps its size. */
	.js .home-main[data-scenes] .home-photo .photo-marquee-word {
		font-size: clamp(1.25rem, 2.6vw, 1.875rem);
	}

	.js .home-main[data-scenes] .home-photo .photo-grid {
		/* The spacing this block just gave back is handed straight to the
		   frames, so a shorter window shows smaller photographs rather than a
		   smaller layout with air around it. */
		--photo-trim: 58px;
		--photo-stair: 6px;

		gap: 6px clamp(14px, 1.8vw, 30px);
	}

	.js .home-main[data-scenes] .home-photo .photo-more[data-reveal] {
		/* The generic button reveal starts 34px below its resting place. At this
		   height that would park it outside the clipped stage, so short windows
		   use the same fade without the extra travel. */
		margin-top: -2px;
		transform: none;
	}
}

/* Below this the spacing has nothing left to give: the two rows themselves have
   to come down, or the heading rides up behind the header again. */
@media (min-width: 561px) and (max-height: 810px) {
	.js .home-main[data-scenes] .home-photo .photo-grid {
		--photo-trim: 74px;
	}
}

/* ---------- when pinning is the wrong answer ----------

   Short windows and phones cannot hold a whole band on one screen, and a
   sticky element taller than the viewport would simply crop itself. Below
   these thresholds the scenes collapse back into an ordinary stack; script
   reads --scene-pin, sees the 0, and switches to measuring the entrance
   against the viewport instead of against a pin, so the animation survives
   even though the parking does not.                                        */

@media (max-width: 560px), (max-height: 760px) {
	.js .home-main[data-scenes] .home-scene {
		--scene-pin: 0;

		height: auto;
	}

	.js .home-main[data-scenes] .home-stage {
		position: relative;
		height: auto;
		min-height: 0;
		overflow: visible;
		transform: none;
		opacity: 1;
	}

	.js .home-main[data-scenes] .home-stage:not(.home-hero) {
		display: block;
		padding-block: clamp(56px, 9vw, 104px);
	}

	.js .home-main[data-scenes] .home-hero {
		min-height: 100svh;
		padding-block: calc(var(--header-h, 0px) + clamp(48px, 6vw, 84px)) clamp(72px, 9vw, 104px);
	}

	.js .home-main[data-scenes] .home-scene[id] {
		scroll-margin-top: calc(var(--header-h, 0px) + clamp(12px, 2vw, 24px));
	}

	/*
	 * The mint handover belongs to the pin: without a runway the hero's --p is
	 * already 1 the moment the page loads, so both layers would open the site
	 * on a flat green screen. The bands simply follow one another here.
	 */
	.js .home-main[data-scenes] .hero-bloom,
	.js .home-main[data-scenes] .hero-plateau,
	.js .home-main[data-scenes] .home-latest::after {
		opacity: 0;
	}
}

/* Mobile-only Hero readability. Keep this separate from the combined compact
   fallback above: a short desktop window may disable pinning, but it must keep
   every desktop Hero animation exactly as designed. */
@media (max-width: 560px) {
	.js .home-main[data-scenes] .home-hero .hero-brand,
	.js .home-main[data-scenes] .home-hero .hero-roles,
	.js .home-main[data-scenes] .home-hero .hero-tagline,
	.js .home-main[data-scenes] .home-hero .hero-actions,
	.js .home-main[data-scenes] .home-hero .hero-glyph > span {
		animation: none;
		opacity: 1;
		filter: none;
		transform: none;
	}

	/* initHeroSink reads the mobile scene's zero-runway progress as 1 and writes
	   the desktop exit state inline. Reset only that mobile parent state. */
	.js .home-main[data-scenes] .home-hero .hero-copy {
		opacity: 1 !important;
		transform: none !important;
	}

	.js .home-main[data-scenes] .home-hero[data-hero-sink="on"] .hero-canvas {
		filter: none;
		transform: none;
	}

	.js .home-main[data-scenes] .home-hero .hero-scroll {
		animation: none;
		opacity: 1;
		filter: none;
		transform: translateX(-50%);
	}

	.js .home-main[data-scenes] .home-hero .hero-wordmark path {
		animation: none;
		stroke-dashoffset: 0;
	}
}

@media (max-width: 560px) {
	/* Phones use ordinary document flow: one complete card per row. Tablets
	   retain the pinned ribbon and 3×2 landing grid from the desktop version. */
	.js .home-main[data-scenes] .home-photo .photo-ribbon {
		display: none;
	}

	.js .home-main[data-scenes] .home-photo .photo-grid {
		display: grid;
		grid-template-columns: minmax(0, 1fr);
		width: 100%;
		padding-block-end: 0;
		padding-inline: var(--gutter);
		overflow: visible;
		gap: 28px;
		scroll-snap-type: none;
	}

	.js .home-main[data-scenes] .home-photo .photo-card {
		--in-x: 0;
		--in-y: 0;
		--in-r: 0deg;

		width: 100%;
		max-width: none;
		flex: none;
		margin-top: 0;
		scroll-snap-align: none;
	}

	.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n + 2),
	.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n) {
		margin-top: 0;
	}

	.js .home-main[data-scenes] .home-photo .photo-card-frame,
	.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n + 2) .photo-card-frame,
	.js .home-main[data-scenes] .home-photo .photo-card:nth-child(3n) .photo-card-frame {
		height: auto;
		aspect-ratio: 4 / 5;
	}

	.js .home-main[data-scenes] .home-photo .photo-marquee-word {
		font-size: clamp(2rem, 7vw, 3.25rem);
	}
}

@media (prefers-reduced-motion: reduce) {
	.js .home-main[data-scenes] .home-scene {
		--scene-pin: 0;
		--enter: 1;
		--exit: 0;
		--out: 0;
		--p: 1;

		height: auto;
	}

	.js .home-main[data-scenes] .home-stage {
		position: relative;
		height: auto;
		min-height: 0;
		overflow: visible;
		transform: none;
		opacity: 1;
	}

	.js .home-main[data-scenes] [data-reveal]:not(.topic-cell) {
		opacity: 1;
		transform: none;
	}

	.js .home-main[data-scenes] .home-photo .photo-ribbon {
		display: none;
	}

	.js .home-main[data-scenes] .home-photo .photo-card,
	.js .home-main[data-scenes] .home-photo .photo-card-frame img,
	.js .home-main[data-scenes] .home-latest .rail-viewport .rail-item {
		opacity: 1;
		filter: none;
		transform: none;
	}

	.js .home-main[data-scenes] .home-photo .photo-more[data-reveal] {
		opacity: 1;
		transform: none;
	}

	.js .home-main[data-scenes] .home-photo .photo-card-frame {
		clip-path: inset(0 round 16px);
	}

	.hero-bloom,
	.hero-plateau,
	.js .home-main[data-scenes] .home-latest::after {
		opacity: 0;
	}
}
