T45c Site · Home: a full-screen hero, five live features beside their miniatures, counts on the beat, the chapters as a city, a closing call; smooth scroll, reveals from scroll, a fade between routes; the OG image from the new hero

This commit is contained in:
ZacharyZhang-NY committed 2026-10-01 08:38:05 -04:00
1 parent f9c20b251c
commit 23ebaba2a1
10 files changed
+355 -49

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 244 KiB

+3 -8
View File
@@ -4,14 +4,9 @@ import { chromium } from "@playwright/test";
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 2, colorScheme: "light", reducedMotion: "reduce" });
await page.goto(process.env.SITE ?? "http://localhost:4173/");
await page.addStyleTag({
content: ".bar, .cta { display: none } .hero { min-height: 630px; padding: 56px } .words { zoom: 1.5 } .og { font-weight: 600; margin-bottom: 16px }",
});
await page.evaluate(() => {
document.querySelector(".glyphs").insertAdjacentHTML("afterend", '<div class="og">Ely</div>');
document.querySelector(".hero p").textContent = "Every component live in the browser, compiled from Rust.";
});
await page.waitForTimeout(1500);
await page.addStyleTag({ content: ".bar, .skip, .hero .button { display: none } .hero { min-height: 630px; height: 630px; padding: 56px } .hero-iso { top: 0; height: 100% }" });
await page.waitForSelector(".hero-iso");
await page.waitForTimeout(800);
await page.screenshot({ path: "public/og.png", clip: { x: 0, y: 0, width: 1200, height: 630 } });
await browser.close();
console.log("og: wrote public/og.png");
+10
View File
@@ -1,6 +1,10 @@
import { Link, Outlet } from "@tanstack/react-router";
import Lenis from "lenis";
import "lenis/dist/lenis.css";
import { useEffect } from "react";
import { REPO } from "./data";
import { Icon } from "./icons";
import { useStill } from "./iso/Iso";
import { setSound, useSound } from "./iso/marimba";
import { setMode, useMode } from "./theme";
@@ -53,6 +57,12 @@ function Footer() {
}
export function Shell() {
const still = useStill();
useEffect(() => {
if (still) return;
const lenis = new Lenis({ autoRaf: true });
return () => lenis.destroy();
}, [still]);
return (
<>
<a className="skip" href="#main">
+28 -7
View File
@@ -5,22 +5,34 @@ import type { Block } from "./engine";
const REDUCED = matchMedia("(prefers-reduced-motion: reduce)");
function useStill(): boolean {
/** Whether the system asks for reduced motion, kept current. */
export function useStill(): boolean {
return useSyncExternalStore(
(listen) => (REDUCED.addEventListener("change", listen), () => REDUCED.removeEventListener("change", listen)),
() => REDUCED.matches,
);
}
/** Grows once it shows; a press grows it again. */
export function Iso({ model, label, className }: { model: Block[]; label: string; className?: string }) {
interface Props {
model: Block[];
label: string;
className?: string;
onHover?: (index: number | null) => void;
onPick?: (index: number) => void;
lift?: number | null;
}
/** Grows once shown; a press regrows it or picks a block. */
export function Iso({ model, label, className, onHover, onPick, lift }: Props) {
const canvas = useRef<HTMLCanvasElement>(null);
const mini = useRef<ReturnType<typeof miniature> | null>(null);
const hover = useRef(onHover);
hover.current = onHover;
const theme = useMode();
const still = useStill();
useEffect(() => {
const m = miniature(canvas.current!, model, still);
const m = miniature(canvas.current!, model, still, (i) => hover.current?.(i));
mini.current = m;
const seen = new IntersectionObserver(([e]) => e.isIntersecting && m.start(), { threshold: 0.35 });
seen.observe(canvas.current!);
@@ -31,11 +43,20 @@ export function Iso({ model, label, className }: { model: Block[]; label: string
}, [model, still]);
useEffect(() => mini.current?.repaint(), [theme]);
useEffect(() => {
if (lift != null) mini.current?.lift(lift);
}, [lift]);
// Still, there is nothing to replay.
if (still) return <canvas ref={canvas} className={`iso ${className ?? ""}`} role="img" aria-label={label} />;
const press = (x: number, y: number) => {
const at = onPick && mini.current?.pick(x, y);
if (at != null) onPick!(at);
else mini.current?.replay();
};
const name = `iso ${className ?? ""}`;
// Still, nothing replays; picks stay the pointer's.
if (still) return <canvas ref={canvas} className={name} role="img" aria-label={label} onClick={(e) => press(e.clientX, e.clientY)} />;
return (
<button className={`iso ${className ?? ""}`} aria-label={`${label}. Grow it again`} onClick={() => mini.current?.replay()}>
<button className={name} aria-label={`${label}. Grow it again`} onClick={(e) => (e.detail ? press(e.clientX, e.clientY) : mini.current?.replay())}>
<canvas ref={canvas} />
</button>
);
+33 -19
View File
@@ -19,8 +19,8 @@ function ink(): Ink {
return { ground: v("--bg"), line: v("--iso-line"), accent: v("--accent"), side: v("--iso-side"), shade: v("--iso-shade") };
}
/** Grows cell by cell; a block under the pointer lifts and sounds. */
export function miniature(canvas: HTMLCanvasElement, model: Block[], still: boolean) {
/** Grows cell by cell; a pointed block lifts and sounds. */
export function miniature(canvas: HTMLCanvasElement, model: Block[], still: boolean, onHover?: (index: number | null) => void) {
const blocks = painted(model);
const ctx = canvas.getContext("2d")!;
let colors = ink();
@@ -75,10 +75,10 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
const drawn = Math.min(1, since / DRAW);
const grown = rise((since - DRAW) / GROW);
const bump = bumps.get(b);
const lift = bump === undefined ? 0 : Math.max(0, 1 - (now - bump) / BUMP);
if (bump !== undefined && lift === 0) bumps.delete(b);
moving ||= drawn < 1 || since - DRAW < GROW || lift > 0;
const h = Math.max(b.h * grown * (1 + 0.35 * Math.sin(lift * Math.PI)), 0.0001);
const bounce = bump === undefined ? 0 : Math.max(0, 1 - (now - bump) / BUMP);
if (bump !== undefined && bounce === 0) bumps.delete(b);
moving ||= drawn < 1 || since - DRAW < GROW || bounce > 0;
const h = Math.max(b.h * grown * (1 + 0.35 * Math.sin(bounce * Math.PI)), 0.0001);
const f = faces(b, h);
if (drawn === 1) shown.set(b, h);
const stroke = b.accent ? colors.accent : colors.line;
@@ -112,35 +112,48 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
if (!frame) frame = requestAnimationFrame(draw);
};
const pointer = (e: PointerEvent) => {
if (still) return;
/** Model index of the grown block at a point, not the table. */
const pick = (x: number, y: number): number | null => {
const r = canvas.getBoundingClientRect();
const dpr = canvas.width / r.width;
const p: Point = [((e.clientX - r.left) * dpr - view.dx) / view.scale, ((e.clientY - r.top) * dpr - view.dy) / view.scale];
const p: Point = [((x - r.left) * dpr - view.dx) / view.scale, ((y - r.top) * dpr - view.dy) / view.scale];
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
const h = shown.get(b);
if (h === undefined) continue;
const f = faces(b, h);
if (inside(p, f.top) || inside(p, f.front) || inside(p, f.right)) {
// The table itself stays put.
if (b.y === 0) return;
if (!bumps.has(b)) {
bumps.set(b, performance.now());
strike(Math.round(b.x + b.z + b.h * 2), 0.5);
request();
}
return;
}
if (inside(p, f.top) || inside(p, f.front) || inside(p, f.right)) return b.y === 0 ? null : model.indexOf(b);
}
return null;
};
const lift = (index: number) => {
const b = model[index];
if (still || bumps.has(b)) return;
bumps.set(b, performance.now());
strike(Math.round(b.x + b.z + b.h * 2), 0.5);
request();
};
let hovered: number | null = null;
const hover = (at: number | null) => {
if (at === hovered) return;
hovered = at;
if (at !== null) lift(at);
onHover?.(at);
};
const pointer = (e: PointerEvent) => hover(pick(e.clientX, e.clientY));
const leave = () => hover(null);
const resized = new ResizeObserver(() => (fit(), request()));
resized.observe(canvas);
canvas.addEventListener("pointermove", pointer);
canvas.addEventListener("pointerleave", leave);
fit();
request();
return {
pick,
lift,
start() {
// Still, it stands grown from the first frame.
if (started !== null) return;
@@ -163,6 +176,7 @@ export function miniature(canvas: HTMLCanvasElement, model: Block[], still: bool
cancelAnimationFrame(frame);
resized.disconnect();
canvas.removeEventListener("pointermove", pointer);
canvas.removeEventListener("pointerleave", leave);
},
};
}
+1 -1
View File
@@ -27,7 +27,7 @@ function under(a: Block, b: Block): boolean {
const apartZ = a.z + a.d <= b.z + EPS || b.z + b.d <= a.z + EPS;
if (!apartX && !apartZ) return a.y < b.y || (a.y === b.y && a.h < b.h);
const [behindX, behindZ] = [a.x + a.w <= b.x + EPS, a.z + a.d <= b.z + EPS];
// Behind on one axis, before on the other: side by side.
// Behind on one axis, before on the other: apart.
if (apartX && apartZ) return behindX && behindZ;
return apartX ? behindX : behindZ;
}
+81
View File
@@ -18,3 +18,84 @@ export function app(): Block[] {
s.push(b(13, 9.8, 1.6, 0.8, 0.3, 0.5), b(13.9, 9.8, 0.7, 0.8, 0.6, 0.5, true));
return sweep(s, 0.9);
}
/** Twenty candles over their volume. */
export function candles(): Block[] {
const s: Block[] = [b(0, 0, 21, 8, 0.3)];
const days: Array<[number, number, number, number]> = [];
let close = 0;
for (let i = 0; i < 20; i++) {
const open = close;
close = open + Math.sin(i * 1.7) * 1.1 + Math.cos(i * 0.6) * 0.6 + 0.25;
days.push([open, close, Math.min(open, close) - 0.4 - (i % 3) * 0.2, Math.max(open, close) + 0.35 + (i % 2) * 0.3]);
}
const floor = Math.min(...days.map((d) => d[2]));
const range = Math.max(...days.map((d) => d[3])) - floor;
const lift = (price: number) => 0.9 + ((price - floor) / range) * 4;
days.forEach(([open, close, low, high], i) => {
const x = 0.7 + i;
const [lo, hi] = [lift(Math.min(open, close)), lift(Math.max(open, close))];
s.push({ ...b(x + 0.33, 2.73, 0.14, 0.14, lift(high) - lift(low), lift(low)), beat: 2 + i });
s.push({ ...b(x, 2.4, 0.8, 0.8, Math.max(hi - lo, 0.2), lo, close >= open), beat: 2 + i });
s.push({ ...b(x, 5, 0.8, 1.6, 0.2 + ((i * 7) % 5) * 0.22, 0.3), beat: 3 + i });
});
return s;
}
/** Five series of eight months, one in the accent. */
export function bars(): Block[] {
const s: Block[] = [b(0, 0, 11, 8, 0.3)];
for (let z = 0; z < 5; z++) {
for (let x = 0; x < 8; x++) {
const h = 0.4 + 2.4 * (0.5 + 0.5 * Math.sin(x * 0.7 + z * 1.1)) * (1 + z * 0.12);
s.push({ ...b(1 + x * 1.2, 1 + z * 1.3, 0.8, 0.8, h, 0.3, z === 2), beat: 1 + x + z });
}
}
return s;
}
/** A conversation, turn by turn, three dots still typing. */
export function chat(): Block[] {
const s: Block[] = [b(0, 0, 10, 12, 0.3)];
const turns: Array<[boolean, number]> = [[false, 6], [true, 4.4], [false, 7.2], [true, 3.2], [false, 5.4]];
turns.forEach(([mine, w], i) => {
const z = 1 + i * 2;
if (!mine) s.push({ ...b(0.8, z + 0.3, 0.8, 0.8, 0.8, 0.3), beat: 1 + i * 3 });
s.push({ ...b(mine ? 9.2 - w : 2, z, w, 1.4, 0.45, 0.3, mine), beat: 2 + i * 3 });
});
for (let i = 0; i < 3; i++) s.push({ ...b(2.2 + i * 0.7, 10.9, 0.45, 0.45, 0.45, 0.3, true), beat: 17 + i });
return s;
}
/** Indented code beside its gutter, the cursor's line raised. */
export function editor(): Block[] {
const s: Block[] = [b(0, 0, 14, 11, 0.3)];
const lines = [6, 8, 5, 0, 9, 7, 10, 4, 0, 8, 6, 3];
const indent = [0, 1, 2, 0, 0, 1, 2, 3, 0, 1, 1, 0];
lines.forEach((w, i) => {
const z = 0.8 + i * 0.8;
s.push({ ...b(0.7, z, 0.6, 0.45, 0.2, 0.3), beat: 1 + i });
if (w) s.push({ ...b(2 + indent[i] * 0.8, z, w * 0.9, 0.45, i === 6 ? 0.7 : 0.25, 0.3, i === 6), beat: 1 + i });
});
return s;
}
/** A month of days, a few holding events. */
export function calendar(): Block[] {
const s: Block[] = [b(0, 0, 15, 11, 0.3)];
for (let i = 0; i < 35; i++) {
const [col, row] = [i % 7, Math.floor(i / 7)];
const busy = [3, 8, 9, 15, 22, 23, 24, 30].includes(i);
s.push({ ...b(0.7 + col * 2, 0.7 + row * 2, 1.7, 1.7, busy ? 0.6 + (i % 3) * 0.3 : 0.15, 0.3, i === 23), beat: 1 + col + row });
}
return s;
}
/** Every chapter a plinth, as tall as its stories. */
export function city(counts: number[]): Block[] {
const s: Block[] = [b(0, 0, 25, 16, 0.3)];
counts.forEach((n, i) => {
const [cx, cz] = [1 + (i % 9) * 2.6, 1 + Math.floor(i / 9) * 3];
s.push({ ...b(cx, cz, 1.6, 1.6, 0.4 + n * 0.1, 0.3), beat: 2 + i });
});
return s;
}
+37
View File
@@ -0,0 +1,37 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { chapters } from "../data";
import { Iso } from "../iso/Iso";
import { city } from "../iso/models";
const CITY = city(chapters.map((c) => c.stories.length));
/** Chapters as plinths; a plinth and its name light together. */
export function City() {
const navigate = useNavigate();
// Plinth i + 1 is chapter i; 0 is the table.
const [at, setAt] = useState<number | null>(null);
const open = (i: number) => navigate({ to: "/components/$page/", params: { page: chapters[i].slug } });
return (
<section className="city">
<h2 className="rise">{at === null ? `${chapters.length} chapters.` : `${chapters[at].title}.`}</h2>
<Iso
model={CITY}
label="Every chapter a plinth, as tall as its stories are many"
className="city-iso"
onHover={(i) => setAt(i === null ? null : i - 1)}
onPick={(i) => open(i - 1)}
lift={at === null ? null : at + 1}
/>
<ol className="city-names" onMouseLeave={() => setAt(null)}>
{chapters.map((c, i) => (
<li key={c.slug} className={at === i ? "on" : undefined}>
<Link to="/components/$page/" params={{ page: c.slug }} onMouseEnter={() => setAt(i)} onFocus={() => setAt(i)} onBlur={() => setAt(null)}>
{c.title}
</Link>
</li>
))}
</ol>
</section>
);
}
+115 -14
View File
@@ -1,25 +1,126 @@
import { Link } from "@tanstack/react-router";
import { useEffect } from "react";
import { chapters, componentCount } from "../data";
import { Iso } from "../iso/Iso";
import { app } from "../iso/models";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { all, chapters, componentCount, REPO } from "../data";
import { Iso, useStill } from "../iso/Iso";
import { BEAT, strike } from "../iso/marimba";
import { app, bars, calendar, candles, chat, editor } from "../iso/models";
import { Live } from "../Live";
import { City } from "./City";
const APP = app();
const FEATURES = [
{ title: "Markets.", line: "Candles, books and tapes, redrawn every frame.", model: candles(), page: "finance", story: "candlestickchart-ohlcchart-heikinashichart-linequotechart-volumechart-charttypeswitcher-timerangeselector-intervalselector" },
{ title: "Charts.", line: "Areas, bars and stacks that answer the pointer.", model: bars(), page: "charts", story: "areachart-stackedarea-barchart" },
{ title: "Conversations.", line: "Bubbles, avatars and the day between them.", model: chat(), page: "chat", story: "chatcontainer-messagelist-messagebubble-messageavatar-messageheader-messagefooter-dateseparator-scrolltobottombutton" },
{ title: "Code.", line: "Folds, minimap, brackets and many cursors.", model: editor(), page: "editor", story: "codeeditor-editorgutter-linenumbers-foldingcontrol-minimap-ruler-indentguide-bracketmatcher-rainbowbrackets-multicursor-selectionhighlight-stickyscroll-breadcrumb-inlayhints-codelens-inlinedecoration" },
{ title: "Time.", line: "A month of days, its events in place.", model: calendar(), page: "calendar", story: "calendarmonthview" },
].map((f) => {
const story = chapters.find((c) => c.slug === f.page)?.stories.find((s) => s.slug === f.story);
if (!story) throw new Error(`home: no story ${f.page}/${f.story}`);
return { ...f, name: story.title };
});
/** Mounts its children only while near the view. */
function Near({ className, children }: { className: string; children: ReactNode }) {
const box = useRef<HTMLDivElement>(null);
const [near, setNear] = useState(false);
useEffect(() => {
const seen = new IntersectionObserver(([e]) => setNear(e.isIntersecting), { rootMargin: "100% 0px" });
seen.observe(box.current!);
return () => seen.disconnect();
}, []);
return (
<div ref={box} className={className}>
{near && children}
</div>
);
}
/** Counts up in sixteen beats once it shows. */
function Count({ to }: { to: number }) {
const box = useRef<HTMLSpanElement>(null);
const [n, setN] = useState(to);
const still = useStill();
useEffect(() => {
if (still) return setN(to);
let frame = 0;
const seen = new IntersectionObserver(
([e]) => {
if (!e.isIntersecting) return;
seen.disconnect();
const start = performance.now();
let step = -1;
const tick = (now: number) => {
const next = Math.min(16, Math.floor((now - start) / BEAT));
if (next !== step) {
step = next;
setN(Math.round(to * (1 - (1 - step / 16) ** 3)));
strike(step, 0.4);
}
if (step < 16) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
},
{ threshold: 0.6 },
);
seen.observe(box.current!);
return () => {
seen.disconnect();
cancelAnimationFrame(frame);
};
}, [to, still]);
return <span ref={box}>{n}</span>;
}
export function Home() {
useEffect(() => {
document.title = "Ely · GPUI components";
}, []);
return (
<section className="hero">
<Iso model={APP} label="A miniature app growing cell by cell" className="hero-iso" />
<h1>Components for GPUI.</h1>
<p>
{componentCount} components in {chapters.length} chapters, live in the browser.
</p>
<Link to="/components/" className="button">
Browse
</Link>
</section>
<>
<section className="hero">
<Iso model={APP} label="A miniature app growing cell by cell" className="hero-iso" />
<h1>Components for GPUI.</h1>
<p>
{componentCount} components in {chapters.length} chapters, live in the browser.
</p>
<Link to="/components/" className="button">
Browse
</Link>
</section>
{FEATURES.map((f) => (
<section className="feature" key={f.page}>
<div className="feature-words">
<h2 className="rise">{f.title}</h2>
<p className="rise">{f.line}</p>
</div>
<Iso model={f.model} label={`A miniature of ${f.title.slice(0, -1).toLowerCase()}`} className="feature-iso" />
<Near className="feature-live">
<Live page={f.page} story={f.story} title={f.name} />
</Near>
</section>
))}
<section className="tally">
{[
[componentCount, "components"],
[all.length, "stories"],
[chapters.length, "chapters"],
].map(([n, label]) => (
<p className="rise" key={label}>
<Count to={n as number} />
<span>{label}</span>
</p>
))}
</section>
<City />
<section className="close">
<h2 className="rise">Build with Ely.</h2>
<code className="rise">ely-gpui-component = {`{ git = "${REPO}" }`}</code>
<Link to="/components/" className="button rise">
Browse
</Link>
</section>
</>
);
}
+47
View File
@@ -77,6 +77,42 @@ main { min-height: 100vh; }
.hero > :not(.iso) { position: relative; z-index: 1; max-width: 44%; }
.hero h1 { font-size: clamp(48px, 8vw, 128px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; }
.hero p { font-size: 20px; line-height: 30px; }
.hero h1, .hero p, .hero .button { animation: up 900ms cubic-bezier(0.2, 0.7, 0.2, 1) both; }
.hero p { animation-delay: 120ms; }
.hero .button { animation-delay: 240ms; }
.feature { min-height: 100vh; display: grid; grid-template-columns: 5fr 7fr; grid-template-rows: auto 1fr; gap: 0 64px; padding: 160px 64px 64px; }
.feature-words { grid-area: 1 / 1; }
.feature-iso { grid-area: 2 / 1; width: 100%; height: 100%; min-height: 280px; }
.feature-live { grid-area: 1 / 2 / 3; height: calc(100vh - 224px); min-height: 520px; }
.feature-live .live { height: 100%; min-height: 0; }
.feature:nth-of-type(odd) { grid-template-columns: 7fr 5fr; }
.feature:nth-of-type(odd) .feature-words, .feature:nth-of-type(odd) .feature-iso { grid-column: 2; }
.feature:nth-of-type(odd) .feature-live { grid-column: 1; }
.feature h2, .city h2, .close h2 { font-size: clamp(48px, 7vw, 112px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; }
.feature p { margin-top: 24px; font-size: 18px; line-height: 28px; max-width: 30ch; }
.tally { min-height: 100vh; display: grid; grid-template-columns: repeat(3, 1fr); align-content: center; gap: 64px; padding: 128px 64px; }
.tally p { display: grid; gap: 8px; font-size: 18px; }
.tally p > span:first-child { font-size: clamp(64px, 11vw, 176px); line-height: 0.9; font-weight: 800; letter-spacing: -0.05em; color: var(--fg); font-variant-numeric: tabular-nums; }
.city { height: 100vh; min-height: 720px; display: grid; grid-template-columns: 7fr 5fr; grid-template-rows: auto 1fr; gap: 48px 64px; padding: 160px 64px 96px; }
.city h2 { grid-column: 1 / 3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.city > * { min-width: 0; }
.city-iso { width: 100%; height: 100%; min-height: 420px; }
.city-names { list-style: none; margin: 0; padding: 0; columns: 3; column-gap: 24px; font-size: 13px; line-height: 24px; align-self: end; }
.city-names a { color: var(--muted); transition: color 160ms ease-out; }
.city-names a:hover, .city-names a:focus-visible, .city-names .on a { color: var(--fg); }
.close { min-height: 100vh; display: grid; align-content: center; justify-items: start; gap: 40px; padding: 128px 64px; }
.close code { font: 400 15px/24px var(--mono); color: var(--muted); overflow-wrap: anywhere; }
@keyframes up { from { opacity: 0; transform: translateY(32px); } }
@supports (animation-timeline: view()) {
.rise { animation: up linear both; animation-timeline: view(); animation-range: entry 0% entry 80%; }
}
main > :first-child { animation: enter 480ms ease-out both; }
@keyframes enter { from { opacity: 0; } }
.chapters { padding: 160px 64px 128px; }
.chapters h1 { font-size: clamp(40px, 6vw, 96px); letter-spacing: -0.04em; margin-bottom: 96px; }
@@ -125,6 +161,17 @@ img.shot { display: block; max-width: 100%; height: auto; }
.chapters .name { font-size: 22px; }
.hero-iso { width: 100%; height: 55%; }
.hero > :not(.iso) { max-width: none; }
.city { height: auto; }
.hero h1 { font-size: 13vw; }
.feature h2, .city h2, .close h2 { font-size: 10vw; }
.feature, .feature:nth-of-type(odd), .city { grid-template-columns: 1fr; grid-template-rows: none; gap: 32px; padding: 112px 20px 48px; }
.feature-words, .feature-iso, .feature-live, .feature:nth-of-type(odd) .feature-words, .feature:nth-of-type(odd) .feature-iso, .feature:nth-of-type(odd) .feature-live, .city h2 { grid-area: auto; }
.feature-iso { min-height: 220px; }
.feature-live { height: 70vh; min-height: 420px; }
.tally { grid-template-columns: 1fr; padding: 112px 20px; }
.city-iso { min-height: 260px; }
.city-names { columns: 2; }
.close { padding: 112px 20px; }
}
@media (prefers-reduced-motion: reduce) {