diff --git a/frontend/public/og.png b/frontend/public/og.png index d77bc1f..1e1185b 100644 Binary files a/frontend/public/og.png and b/frontend/public/og.png differ diff --git a/frontend/scripts/og.mjs b/frontend/scripts/og.mjs index afa08db..a21cce3 100644 --- a/frontend/scripts/og.mjs +++ b/frontend/scripts/og.mjs @@ -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", '
Ely
'); - 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"); diff --git a/frontend/src/Shell.tsx b/frontend/src/Shell.tsx index a59643e..495d334 100644 --- a/frontend/src/Shell.tsx +++ b/frontend/src/Shell.tsx @@ -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 ( <> diff --git a/frontend/src/iso/Iso.tsx b/frontend/src/iso/Iso.tsx index 9c1ece9..fa41fa6 100644 --- a/frontend/src/iso/Iso.tsx +++ b/frontend/src/iso/Iso.tsx @@ -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(null); const mini = useRef | 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 ; + 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 press(e.clientX, e.clientY)} />; return ( - ); diff --git a/frontend/src/iso/draw.ts b/frontend/src/iso/draw.ts index 85d7221..dbe89db 100644 --- a/frontend/src/iso/draw.ts +++ b/frontend/src/iso/draw.ts @@ -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); }, }; } diff --git a/frontend/src/iso/engine.ts b/frontend/src/iso/engine.ts index c350757..53f8bd4 100644 --- a/frontend/src/iso/engine.ts +++ b/frontend/src/iso/engine.ts @@ -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; } diff --git a/frontend/src/iso/models.ts b/frontend/src/iso/models.ts index 99ed18b..532fada 100644 --- a/frontend/src/iso/models.ts +++ b/frontend/src/iso/models.ts @@ -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; +} diff --git a/frontend/src/pages/City.tsx b/frontend/src/pages/City.tsx new file mode 100644 index 0000000..dd86de0 --- /dev/null +++ b/frontend/src/pages/City.tsx @@ -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(null); + const open = (i: number) => navigate({ to: "/components/$page/", params: { page: chapters[i].slug } }); + return ( +
+

{at === null ? `${chapters.length} chapters.` : `${chapters[at].title}.`}

+ setAt(i === null ? null : i - 1)} + onPick={(i) => open(i - 1)} + lift={at === null ? null : at + 1} + /> +
    setAt(null)}> + {chapters.map((c, i) => ( +
  1. + setAt(i)} onFocus={() => setAt(i)} onBlur={() => setAt(null)}> + {c.title} + +
  2. + ))} +
+
+ ); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 0d879a0..59022aa 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -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(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 ( +
+ {near && children} +
+ ); +} + +/** Counts up in sixteen beats once it shows. */ +function Count({ to }: { to: number }) { + const box = useRef(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 {n}; +} + export function Home() { useEffect(() => { document.title = "Ely ยท GPUI components"; }, []); return ( -
- -

Components for GPUI.

-

- {componentCount} components in {chapters.length} chapters, live in the browser. -

- - Browse - -
+ <> +
+ +

Components for GPUI.

+

+ {componentCount} components in {chapters.length} chapters, live in the browser. +

+ + Browse + +
+ {FEATURES.map((f) => ( +
+
+

{f.title}

+

{f.line}

+
+ + + + +
+ ))} +
+ {[ + [componentCount, "components"], + [all.length, "stories"], + [chapters.length, "chapters"], + ].map(([n, label]) => ( +

+ + {label} +

+ ))} +
+ +
+

Build with Ely.

+ ely-gpui-component = {`{ git = "${REPO}" }`} + + Browse + +
+ ); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4c779e2..7cc3421 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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) {