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:
1 parent
7fd05df4dc
commit
b47f2267e4
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 |
@@ -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");
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user