T45 Website: an isometric miniature grows to a marimba; finance, charts and chat beside their models; no bars, no boxes
This commit is contained in:
1 parent
f50a708951
commit
587e08b5cb
9 files changed
+542
-198
No files matched your search
@@ -64,7 +64,7 @@ function detail(current: Chapter, story: Story): string {
|
||||
return `<article class="detail">
|
||||
<div class="crumb"><a href="/components/">Components</a> / <a href="${href(current)}">${esc(current.title)}</a></div>
|
||||
<h1>${esc(story.title)}</h1>
|
||||
${story.components.length ? `<div class="chips">${story.components.map((c) => `<span>${esc(c)}</span>`).join("")}</div>` : `<div class="chips"></div>`}
|
||||
<p class="names">${story.components.map(esc).join(" · ")}</p>
|
||||
<div class="live"></div>
|
||||
<nav class="pager" aria-label="More stories">${pager(prev, "prev", "Previous", icon("ArrowLeft"))}${pager(next, "next", "Next", icon("ArrowRight"))}</nav>
|
||||
</article>`;
|
||||
|
||||
+93
-43
@@ -1,71 +1,121 @@
|
||||
import Lenis from "lenis";
|
||||
import "lenis/dist/lenis.css";
|
||||
import { REPO, chapters, componentCount, storyCount } from "./data";
|
||||
import { REPO, chapters, componentCount, esc, storyCount } from "./data";
|
||||
import { icon } from "./icons";
|
||||
import { app, bars, candles, chat, type Model } from "./iso/models";
|
||||
import { stage } from "./iso/stage";
|
||||
import { preview } from "./preview";
|
||||
import { onMode } from "./theme";
|
||||
|
||||
const FEATURED = { page: "forms", story: "input-textfield-clearableinput", title: "Input / TextField · ClearableInput" };
|
||||
interface Show {
|
||||
glyph: "circle" | "square" | "triangle";
|
||||
name: string;
|
||||
line: string;
|
||||
page: string;
|
||||
story: string;
|
||||
title: string;
|
||||
model: () => Model;
|
||||
}
|
||||
|
||||
/** Folding tiles, a few facts, one component live. */
|
||||
const SHOWS: Show[] = [
|
||||
{
|
||||
glyph: "circle",
|
||||
name: "Finance",
|
||||
line: "Candles, volume and a crosshair. Drag to pan, Cmd-scroll to zoom, switch the chart type.",
|
||||
page: "finance",
|
||||
story: "candlestickchart-ohlcchart-heikinashichart-linequotechart-volumechart-charttypeswitcher-timerangeselector-intervalselector",
|
||||
title: "CandlestickChart",
|
||||
model: candles,
|
||||
},
|
||||
{
|
||||
glyph: "square",
|
||||
name: "Charts",
|
||||
line: "A year in two series. Hover for values, drag across to zoom, press a name to hide it.",
|
||||
page: "charts",
|
||||
story: "linechart-chartaxis-chartgrid-chartlegend-charttooltip-chartcrosshair-chartannotation-chartzoom-chartexport",
|
||||
title: "LineChart",
|
||||
model: bars,
|
||||
},
|
||||
{
|
||||
glyph: "triangle",
|
||||
name: "AI chat",
|
||||
line: "Bubbles, avatars, math and code, the way a model answers.",
|
||||
page: "chat",
|
||||
story: "chatcontainer-messagelist-messagebubble-messageavatar-messageheader-messagefooter-dateseparator-scrolltobottombutton",
|
||||
title: "ChatContainer",
|
||||
model: chat,
|
||||
},
|
||||
];
|
||||
|
||||
/** A miniature that grows to a marimba, then each model beside the real thing. */
|
||||
export function home(main: HTMLElement): () => void {
|
||||
document.title = "Ely · GPUI components";
|
||||
main.innerHTML = `
|
||||
<section class="hero"><div class="pin">
|
||||
<canvas aria-hidden="true"></canvas>
|
||||
<section class="hero">
|
||||
<div class="words">
|
||||
<h1>Ely, a component library for GPUI.<strong>Every one of them runs here, live.</strong></h1>
|
||||
<p>${componentCount} components in ${chapters.length} chapters, in light and dark, written in Rust and compiled to WebAssembly for this page.</p>
|
||||
<span class="glyphs" aria-hidden="true"><i class="circle"></i><i class="square"></i><i class="triangle"></i></span>
|
||||
<h1>A component library for GPUI.<strong>Every part runs on this page.</strong></h1>
|
||||
<p>${componentCount} components in ${chapters.length} chapters, written in Rust, compiled to WebAssembly. Point at the model. Turn the sound on.</p>
|
||||
<div class="cta">
|
||||
<a class="button primary" href="/components/">Browse components ${icon("ArrowRight")}</a>
|
||||
<a class="button" href="${REPO}" rel="noopener">${icon("GitHub")}GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
<section class="band">
|
||||
<div class="label">In numbers</div>
|
||||
<h2>One crate, one theme, every part tested at 280 pixels wide.</h2>
|
||||
<div class="facts">
|
||||
<div><strong>${storyCount}</strong><span>stories, each its own page</span></div>
|
||||
<div><strong>${chapters.length}</strong><span>chapters, from primitives to maps</span></div>
|
||||
<div><strong>2</strong><span>modes, light and dark, from one palette</span></div>
|
||||
<div><strong>0</strong><span>screenshots: what you see is running</span></div>
|
||||
</div>
|
||||
<div class="iso-host hero-iso" data-model="app"></div>
|
||||
</section>
|
||||
<section class="band">
|
||||
<div class="label">Try one</div>
|
||||
<h2>A text field, live. Type in it.</h2>
|
||||
<div class="home-stage"></div>
|
||||
${SHOWS.map(
|
||||
(s, i) => `<section class="show">
|
||||
<div class="pair">
|
||||
<header class="mark">
|
||||
<i class="${s.glyph}" aria-hidden="true"></i>
|
||||
<span class="n">${String(i + 1).padStart(2, "0")}</span>
|
||||
<h2>${esc(s.name)}</h2>
|
||||
<p>${esc(s.line)}</p>
|
||||
</header>
|
||||
<div class="iso-host" data-model="${s.page}"></div>
|
||||
</div>
|
||||
<div class="live" data-i="${i}"></div>
|
||||
</section>`,
|
||||
).join("")}
|
||||
<section class="facts">
|
||||
<div><strong>${storyCount}</strong><span>stories, each its own page</span></div>
|
||||
<div><strong>${chapters.length}</strong><span>chapters, primitives to maps</span></div>
|
||||
<div><strong>2</strong><span>modes from one palette</span></div>
|
||||
<div><strong>0</strong><span>screenshots</span></div>
|
||||
</section>`;
|
||||
|
||||
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const lenis = still ? null : new Lenis({ autoRaf: true, anchors: true });
|
||||
const hero = main.querySelector<HTMLElement>(".hero")!;
|
||||
// The pinned hero folds as it scrolls.
|
||||
const fold = () => Math.min(1, Math.max(0, scrollY / Math.max(hero.offsetHeight - innerHeight, 1)));
|
||||
let stops: Array<() => void> = [];
|
||||
let sceneStop = () => {};
|
||||
// three.js loads only here.
|
||||
import("./scene").then(({ scene }) => {
|
||||
if (!hero.isConnected) return;
|
||||
const s = scene(main.querySelector("canvas")!, fold, still);
|
||||
sceneStop = s.stop;
|
||||
stops.push(onMode(() => s.paint()));
|
||||
});
|
||||
const iso = stage(still);
|
||||
iso.add(main.querySelector<HTMLElement>(".hero-iso")!, app());
|
||||
main.querySelectorAll<HTMLElement>(".pair .iso-host").forEach((host, i) => iso.add(host, SHOWS[i].model()));
|
||||
const stopMode = onMode(() => iso.paint());
|
||||
|
||||
const stage = main.querySelector<HTMLElement>(".home-stage")!;
|
||||
const lazy = new IntersectionObserver(([entry]) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
lazy.disconnect();
|
||||
stops.push(preview(stage, FEATURED.page, FEATURED.story, FEATURED.title));
|
||||
}, { rootMargin: "200px" });
|
||||
lazy.observe(stage);
|
||||
// A live example loads near the view and unloads far from it.
|
||||
const stops = new Map<HTMLElement, () => void>();
|
||||
const near = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
const host = e.target as HTMLElement;
|
||||
const show = SHOWS[Number(host.dataset.i)];
|
||||
if (e.isIntersecting && !stops.has(host)) {
|
||||
stops.set(host, preview(host, show.page, show.story, show.title));
|
||||
} else if (!e.isIntersecting && stops.has(host)) {
|
||||
stops.get(host)!();
|
||||
stops.delete(host);
|
||||
host.innerHTML = "";
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "100% 0px" },
|
||||
);
|
||||
main.querySelectorAll<HTMLElement>(".live").forEach((host) => near.observe(host));
|
||||
|
||||
return () => {
|
||||
lenis?.destroy();
|
||||
lazy.disconnect();
|
||||
sceneStop();
|
||||
near.disconnect();
|
||||
stops.forEach((stop) => stop());
|
||||
stops = [];
|
||||
stopMode();
|
||||
iso.stop();
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import Minimize from "reicon/icons/Minimize";
|
||||
import Moon from "reicon/icons/Moon";
|
||||
import Refresh from "reicon/icons/Refresh";
|
||||
import Search from "reicon/icons/Search";
|
||||
import Sound from "reicon/icons/Sound";
|
||||
import Sun from "reicon/icons/Sun";
|
||||
import VolumeCross from "reicon/icons/VolumeCross";
|
||||
|
||||
const set = { ArrowLeft, ArrowRight, ArrowRightUp, ChevronRight, Maximize, Minimize, Moon, Refresh, Search, Sun };
|
||||
const set = { ArrowLeft, ArrowRight, ArrowRightUp, ChevronRight, Maximize, Minimize, Moon, Refresh, Search, Sound, Sun, VolumeCross };
|
||||
|
||||
// simple-icons' GitHub mark (CC0), since Reicon has none.
|
||||
const GITHUB =
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** A sixteenth at 132 beats a minute. */
|
||||
export const BEAT = 60_000 / 132 / 4;
|
||||
|
||||
// C major pentatonic, C4 to E6.
|
||||
const SCALE = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24, 26, 28];
|
||||
const LISTENERS = new Set<(on: boolean) => void>();
|
||||
|
||||
let context: AudioContext | null = null;
|
||||
let bus: GainNode | null = null;
|
||||
let on = false;
|
||||
let last = 0;
|
||||
|
||||
export function soundOn(): boolean {
|
||||
return on;
|
||||
}
|
||||
|
||||
/** Turns sound on or off; the first turn on needs a press, as browsers ask. */
|
||||
export function setSound(next: boolean): void {
|
||||
on = next;
|
||||
if (on && !context) {
|
||||
context = new AudioContext();
|
||||
bus = context.createGain();
|
||||
bus.gain.value = 0.28;
|
||||
const squeeze = context.createDynamicsCompressor();
|
||||
bus.connect(squeeze).connect(context.destination);
|
||||
console.info("marimba: audio started");
|
||||
}
|
||||
if (on) void context!.resume();
|
||||
LISTENERS.forEach((listen) => listen(on));
|
||||
}
|
||||
|
||||
export function onSound(listen: (on: boolean) => void): () => void {
|
||||
LISTENERS.add(listen);
|
||||
return () => LISTENERS.delete(listen);
|
||||
}
|
||||
|
||||
/** Strikes the scale's `step`th note, soft or hard; strikes within 30 ms merge. */
|
||||
export function strike(step: number, velocity = 0.7): void {
|
||||
if (!on || !context || !bus) return;
|
||||
const now = context.currentTime;
|
||||
if (now - last < 0.03) return;
|
||||
last = now;
|
||||
const index = ((step % SCALE.length) + SCALE.length) % SCALE.length;
|
||||
const hz = 261.63 * 2 ** (SCALE[index] / 12);
|
||||
// A struck bar: the fundamental, a partial near four times up, a short click.
|
||||
for (const [ratio, gain, decay] of [
|
||||
[1, 1, 0.9],
|
||||
[3.93, 0.28, 0.18],
|
||||
[9.2, 0.06, 0.05],
|
||||
]) {
|
||||
const tone = context.createOscillator();
|
||||
const env = context.createGain();
|
||||
tone.type = "sine";
|
||||
tone.frequency.value = hz * ratio;
|
||||
env.gain.setValueAtTime(0, now);
|
||||
env.gain.linearRampToValueAtTime(gain * velocity, now + 0.004);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, now + decay);
|
||||
tone.connect(env).connect(bus);
|
||||
tone.start(now);
|
||||
tone.stop(now + decay + 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export type Tone = "paper" | "ink" | "soft" | "accent";
|
||||
|
||||
/** A box on the table: cell, footprint, lift, height, tone, the beat it grows on. */
|
||||
export interface Block {
|
||||
x: number;
|
||||
z: number;
|
||||
w: number;
|
||||
d: number;
|
||||
y: number;
|
||||
h: number;
|
||||
tone: Tone;
|
||||
beat: number;
|
||||
}
|
||||
|
||||
export type Model = { blocks: Block[]; span: [number, number] };
|
||||
|
||||
const block = (x: number, z: number, w: number, d: number, h: number, tone: Tone, y = 0): Block => ({
|
||||
x, z, w, d, y, h, tone, beat: 0,
|
||||
});
|
||||
|
||||
/** Beats from the far corner toward the eye, one diagonal a step. */
|
||||
function sweep(blocks: Block[], pace = 1): Block[] {
|
||||
return blocks.map((b) => ({ ...b, beat: b.beat || Math.round((b.x + b.z) * pace) }));
|
||||
}
|
||||
|
||||
/** A small app on its slab: a sidebar, a chart, a chat, a switch. */
|
||||
export function app(): Model {
|
||||
const b: Block[] = [block(0, 0, 18, 12, 0.5, "paper")];
|
||||
b.push(block(0.5, 0.5, 17, 1, 0.3, "soft", 0.5));
|
||||
for (let i = 0; i < 3; i++) b.push(block(1 + i * 0.9, 0.8, 0.5, 0.4, 0.3, i === 0 ? "accent" : "ink", 0.8));
|
||||
for (let i = 0; i < 6; i++) b.push(block(0.8, 2.2 + i * 1.5, 3.4, 1, 0.3, i === 1 ? "accent" : "soft", 0.5));
|
||||
b.push(block(5, 2.2, 7.4, 1, 0.25, "soft", 0.5));
|
||||
b.push(block(5, 4, 7.4, 5.6, 0.12, "soft", 0.5));
|
||||
const bars = [0.9, 1.5, 1.2, 2.1, 1.7, 2.8, 2.3, 3.2];
|
||||
bars.forEach((h, i) => b.push(block(5.4 + i * 0.85, 7.2, 0.55, 1.2, h, i === 5 ? "accent" : "ink", 0.62)));
|
||||
b.push(block(5.4, 10.4, 6.6, 0.3, 0.15, "soft", 0.5));
|
||||
b.push(block(8.4, 10.3, 0.5, 0.5, 0.5, "accent", 0.5));
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const mine = i % 2 === 1;
|
||||
b.push(block(mine ? 14.2 : 13, 2.2 + i * 1.7, mine ? 2.8 : 3.6, 1.2, 0.5, mine ? "ink" : "soft", 0.5));
|
||||
}
|
||||
b.push(block(13, 9.6, 1.6, 0.8, 0.35, "soft", 0.5));
|
||||
b.push(block(13.9, 9.6, 0.7, 0.8, 0.7, "accent", 0.5));
|
||||
return { blocks: sweep(b, 0.9), span: [18, 12] };
|
||||
}
|
||||
|
||||
/** Twenty candles and their volume, rising in the accent, falling in ink. */
|
||||
export function candles(): Model {
|
||||
const b: Block[] = [block(0, 0, 21, 8, 0.3, "paper")];
|
||||
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]);
|
||||
}
|
||||
// Prices fill 0.9 to 4.5 above the slab.
|
||||
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) * 3.6;
|
||||
days.forEach(([open, close, low, high], i) => {
|
||||
const x = 0.7 + i;
|
||||
b.push({ ...block(x + 0.33, 2.73, 0.14, 0.14, lift(high) - lift(low), "soft", lift(low)), beat: 2 + i * 2 });
|
||||
const [bottom, top] = [lift(Math.min(open, close)), lift(Math.max(open, close))];
|
||||
b.push({ ...block(x, 2.4, 0.8, 0.8, Math.max(top - bottom, 0.2), close >= open ? "accent" : "ink", bottom), beat: 3 + i * 2 });
|
||||
b.push({ ...block(x, 5, 0.8, 1.6, 0.2 + ((i * 7) % 5) * 0.22, "soft", 0.3), beat: 3 + i * 2 });
|
||||
});
|
||||
return { blocks: sweep(b), span: [21, 8] };
|
||||
}
|
||||
|
||||
/** Five series of eight months, one in the accent. */
|
||||
export function bars(): Model {
|
||||
const b: Block[] = [block(0, 0, 11, 8, 0.3, "paper")];
|
||||
for (let z = 0; z < 5; z++) {
|
||||
for (let x = 0; x < 8; x++) {
|
||||
const h = 0.4 + 2.2 * (0.5 + 0.5 * Math.sin(x * 0.7 + z * 1.1)) * (1 + z * 0.12);
|
||||
const tone: Tone = z === 2 ? "accent" : z === 4 ? "ink" : "soft";
|
||||
b.push({ ...block(1 + x * 1.2, 1 + z * 1.3, 0.8, 0.8, h, tone, 0.3), beat: x * 2 + z });
|
||||
}
|
||||
}
|
||||
return { blocks: b, span: [11, 8] };
|
||||
}
|
||||
|
||||
/** A conversation: turns left and right, and three dots still typing. */
|
||||
export function chat(): Model {
|
||||
const b: Block[] = [block(0, 0, 10, 12, 0.3, "paper")];
|
||||
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) b.push({ ...block(0.8, z + 0.3, 0.8, 0.8, 0.8, "ink", 0.3), beat: i * 4 });
|
||||
b.push({ ...block(mine ? 9.2 - w : 2, z, w, 1.4, 0.45, mine ? "accent" : "soft", 0.3), beat: i * 4 + 1 });
|
||||
});
|
||||
for (let i = 0; i < 3; i++) b.push({ ...block(2.2 + i * 0.7, 10.9, 0.45, 0.45, 0.45, "ink", 0.3), beat: 21 + i });
|
||||
return { blocks: b, span: [10, 12] };
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as THREE from "three";
|
||||
import { BEAT, strike } from "./marimba";
|
||||
import type { Block, Model, Tone } from "./models";
|
||||
|
||||
const GROW = 260;
|
||||
const BUMP = 420;
|
||||
|
||||
interface Scene {
|
||||
host: HTMLElement;
|
||||
model: Model;
|
||||
world: THREE.Scene;
|
||||
camera: THREE.OrthographicCamera;
|
||||
mesh: THREE.InstancedMesh;
|
||||
grid: THREE.LineSegments;
|
||||
started: number | null;
|
||||
struck: number;
|
||||
bumps: Map<number, number>;
|
||||
}
|
||||
|
||||
const box = new THREE.BoxGeometry(1, 1, 1).translate(0.5, 0.5, 0.5);
|
||||
const at = new THREE.Object3D();
|
||||
const ray = new THREE.Raycaster();
|
||||
const tones: Record<Tone, THREE.Color> = { paper: new THREE.Color(), ink: new THREE.Color(), soft: new THREE.Color(), accent: new THREE.Color() };
|
||||
const lines = new THREE.Color();
|
||||
|
||||
/** Back-out easing: a block overshoots, then settles on its height. */
|
||||
const rise = (t: number) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2;
|
||||
|
||||
/** Isometric dioramas, one canvas over the page, each drawn in its host's box. */
|
||||
export function stage(still: boolean) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "iso";
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
document.body.append(canvas);
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
renderer.setScissorTest(true);
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
const scenes: Scene[] = [];
|
||||
let frame = 0;
|
||||
|
||||
const paint = () => {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
for (const tone of Object.keys(tones) as Tone[]) tones[tone].set(css.getPropertyValue(`--iso-${tone}`).trim());
|
||||
lines.set(css.getPropertyValue("--line").trim());
|
||||
for (const s of scenes) {
|
||||
s.model.blocks.forEach((b, i) => s.mesh.setColorAt(i, tones[b.tone]));
|
||||
s.mesh.instanceColor!.needsUpdate = true;
|
||||
(s.grid.material as THREE.LineBasicMaterial).color.copy(lines);
|
||||
}
|
||||
};
|
||||
|
||||
const place = (s: Scene, now: number) => {
|
||||
let moving = false;
|
||||
s.model.blocks.forEach((b: Block, i) => {
|
||||
const since = s.started === null ? -1 : now - s.started - b.beat * BEAT;
|
||||
const grown = still ? 1 : since < 0 ? 0 : since >= GROW ? 1 : rise(since / GROW);
|
||||
const bump = s.bumps.get(i);
|
||||
const lift = bump === undefined ? 0 : Math.max(0, 1 - (now - bump) / BUMP);
|
||||
if (bump !== undefined && lift === 0) s.bumps.delete(i);
|
||||
moving ||= (since >= 0 && since < GROW) || lift > 0 || (s.started !== null && since < 0);
|
||||
at.position.set(b.x, b.y, b.z);
|
||||
at.scale.set(b.w, Math.max(b.h * grown * (1 + 0.5 * Math.sin(lift * Math.PI)), 0.0001), b.d);
|
||||
at.updateMatrix();
|
||||
s.mesh.setMatrixAt(i, at.matrix);
|
||||
});
|
||||
s.mesh.instanceMatrix.needsUpdate = true;
|
||||
// One note for each beat that starts a block.
|
||||
if (s.started !== null && !still) {
|
||||
const beat = Math.floor((now - s.started) / BEAT);
|
||||
for (; s.struck <= beat; s.struck++) {
|
||||
const first = s.model.blocks.findIndex((b) => b.beat === s.struck);
|
||||
if (first >= 0) strike(s.struck + Math.round(s.model.blocks[first].h));
|
||||
}
|
||||
}
|
||||
return moving;
|
||||
};
|
||||
|
||||
const draw = (now: number) => {
|
||||
frame = 0;
|
||||
const { innerWidth: w, innerHeight: h } = window;
|
||||
renderer.setSize(w, h, false);
|
||||
renderer.setScissor(0, 0, w, h);
|
||||
renderer.clear();
|
||||
let again = false;
|
||||
for (const s of scenes) {
|
||||
const r = s.host.getBoundingClientRect();
|
||||
if (r.bottom < 0 || r.top > h || r.width === 0) continue;
|
||||
again = place(s, now) || again;
|
||||
fit(s, r.width / r.height);
|
||||
renderer.setViewport(r.left, h - r.bottom, r.width, r.height);
|
||||
renderer.setScissor(r.left, h - r.bottom, r.width, r.height);
|
||||
renderer.render(s.world, s.camera);
|
||||
}
|
||||
if (again) request();
|
||||
};
|
||||
const request = () => {
|
||||
if (!frame) frame = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const seen = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const s = scenes.find((x) => x.host === e.target);
|
||||
if (s && e.isIntersecting && s.started === null) {
|
||||
s.started = performance.now();
|
||||
console.info(`iso: ${s.host.dataset.model} grows`);
|
||||
}
|
||||
}
|
||||
request();
|
||||
}, { threshold: 0.35 });
|
||||
|
||||
addEventListener("scroll", request, { passive: true });
|
||||
addEventListener("resize", request);
|
||||
|
||||
return {
|
||||
/** Grows `model` in `host` once it shows; pointing at a block strikes it. */
|
||||
add(host: HTMLElement, model: Model) {
|
||||
const world = new THREE.Scene();
|
||||
world.add(new THREE.HemisphereLight(0xffffff, 0x9a9a9a, 1.6));
|
||||
const [w, d] = model.span;
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.9);
|
||||
sun.position.set(w / 2 - 12, 30, d / 2 + 7);
|
||||
sun.target.position.set(w / 2, 0, d / 2);
|
||||
sun.castShadow = true;
|
||||
sun.shadow.mapSize.set(2048, 2048);
|
||||
sun.shadow.radius = 4;
|
||||
sun.shadow.bias = -0.0006;
|
||||
Object.assign(sun.shadow.camera, { left: -w, right: w, top: w, bottom: -w, near: 1, far: 90 });
|
||||
world.add(sun, sun.target);
|
||||
const mesh = new THREE.InstancedMesh(box, new THREE.MeshLambertMaterial(), model.blocks.length);
|
||||
mesh.castShadow = mesh.receiveShadow = true;
|
||||
// The table: its grid, and a floor that shows only the shadow the model casts.
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w * 3, d * 3).rotateX(-Math.PI / 2), new THREE.ShadowMaterial({ opacity: 0.12 }));
|
||||
floor.position.set(w / 2, -0.001, d / 2);
|
||||
floor.receiveShadow = true;
|
||||
const grid = new THREE.LineSegments(table(w, d), new THREE.LineBasicMaterial({ transparent: true, opacity: 0.9 }));
|
||||
world.add(mesh, floor, grid);
|
||||
const s: Scene = { host, model, world, camera: new THREE.OrthographicCamera(), mesh, grid, started: null, struck: 0, bumps: new Map() };
|
||||
scenes.push(s);
|
||||
paint();
|
||||
place(s, performance.now());
|
||||
host.addEventListener("pointermove", (e) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
ray.setFromCamera(new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), s.camera);
|
||||
const hit = ray.intersectObject(mesh)[0];
|
||||
if (hit?.instanceId === undefined || hit.instanceId === 0 || s.bumps.has(hit.instanceId)) return;
|
||||
s.bumps.set(hit.instanceId, performance.now());
|
||||
strike(hit.instanceId + Math.round(model.blocks[hit.instanceId].h), 0.5);
|
||||
request();
|
||||
});
|
||||
host.addEventListener("click", () => {
|
||||
s.started = performance.now();
|
||||
s.struck = 0;
|
||||
request();
|
||||
});
|
||||
seen.observe(host);
|
||||
},
|
||||
paint: () => (paint(), request()),
|
||||
stop() {
|
||||
cancelAnimationFrame(frame);
|
||||
seen.disconnect();
|
||||
removeEventListener("scroll", request);
|
||||
removeEventListener("resize", request);
|
||||
for (const s of scenes) (s.mesh.material as THREE.Material).dispose();
|
||||
renderer.dispose();
|
||||
canvas.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Grid lines a cell apart around the slab, a drafting table under the model. */
|
||||
function table(w: number, d: number): THREE.BufferGeometry {
|
||||
const points: number[] = [];
|
||||
const [x0, x1, z0, z1] = [-4, w + 4, -4, d + 4];
|
||||
for (let x = x0; x <= x1; x++) points.push(x, 0, z0, x, 0, z1);
|
||||
for (let z = z0; z <= z1; z++) points.push(x0, 0, z, x1, 0, z);
|
||||
return new THREE.BufferGeometry().setAttribute("position", new THREE.Float32BufferAttribute(points, 3));
|
||||
}
|
||||
|
||||
/** A true isometric view that keeps the model's table and tallest block in the box. */
|
||||
function fit(s: Scene, aspect: number) {
|
||||
const [w, d] = s.model.span;
|
||||
const tall = Math.max(...s.model.blocks.map((b) => b.y + b.h));
|
||||
const centre = new THREE.Vector3(w / 2, tall / 3, d / 2);
|
||||
s.camera.position.copy(centre).add(new THREE.Vector3(1, 1, 1).multiplyScalar(60));
|
||||
s.camera.up.set(0, 1, 0);
|
||||
s.camera.lookAt(centre);
|
||||
// The table's diagonal and the tallest block set the half extents.
|
||||
const across = ((w + d) * Math.SQRT1_2) / 2 + 0.6;
|
||||
const high = ((w + d) * 0.41) / 2 + tall * 0.82 / 2 + 0.6;
|
||||
const half = Math.max(across / aspect, high);
|
||||
s.camera.left = -half * aspect;
|
||||
s.camera.right = half * aspect;
|
||||
s.camera.top = half;
|
||||
s.camera.bottom = -half;
|
||||
s.camera.near = 0.1;
|
||||
s.camera.far = 400;
|
||||
s.camera.updateProjectionMatrix();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { components } from "./components";
|
||||
import { REPO } from "./data";
|
||||
import { home } from "./home";
|
||||
import { icon } from "./icons";
|
||||
import { onSound, setSound, soundOn } from "./iso/marimba";
|
||||
import { menu } from "./menu";
|
||||
import { search } from "./search";
|
||||
import { mode, setMode } from "./theme";
|
||||
@@ -43,6 +44,7 @@ bar.innerHTML = `
|
||||
</nav>
|
||||
<div class="end">
|
||||
<button class="find" data-find aria-label="Search components">${icon("Search")}<span>Search</span><kbd>⌘K</kbd></button>
|
||||
<button class="tool" data-sound></button>
|
||||
<button class="tool" data-mode></button>
|
||||
<a class="tool" href="${REPO}" rel="noopener" aria-label="Ely on GitHub">${icon("GitHub")}</a>
|
||||
</div>`;
|
||||
@@ -66,6 +68,16 @@ modeButton.addEventListener("click", () => {
|
||||
});
|
||||
drawMode();
|
||||
|
||||
const soundButton = bar.querySelector<HTMLButtonElement>("[data-sound]")!;
|
||||
const drawSound = (on: boolean) => {
|
||||
soundButton.innerHTML = icon(on ? "Sound" : "VolumeCross");
|
||||
soundButton.setAttribute("aria-label", on ? "Sound off" : "Sound on");
|
||||
soundButton.setAttribute("aria-pressed", String(on));
|
||||
};
|
||||
soundButton.addEventListener("click", () => setSound(!soundOn()));
|
||||
onSound(drawSound);
|
||||
drawSound(false);
|
||||
|
||||
bar.querySelector("[data-find]")!.addEventListener("click", search(go));
|
||||
menu(bar.querySelector<HTMLButtonElement>("[data-menu]")!, go);
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
|
||||
import { chapters } from "./data";
|
||||
|
||||
const STEP = 1.2;
|
||||
|
||||
/** A tile per story, folding into chapter columns. */
|
||||
export function scene(canvas: HTMLCanvasElement, fold: () => number, still: boolean): { stop: () => void; paint: () => void } {
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 200);
|
||||
const world = new THREE.Scene();
|
||||
world.add(new THREE.HemisphereLight(0xffffff, 0x888888, 2.2));
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
sun.position.set(-6, 10, 12);
|
||||
world.add(sun);
|
||||
|
||||
const tiles = chapters.flatMap((c, col) => c.stories.map((_, row) => ({ col, row })));
|
||||
const mesh = new THREE.InstancedMesh(
|
||||
new RoundedBoxGeometry(1, 1, 0.24, 3, 0.16),
|
||||
new THREE.MeshStandardMaterial({ roughness: 0.7, metalness: 0 }),
|
||||
tiles.length,
|
||||
);
|
||||
world.add(mesh);
|
||||
|
||||
// At rest a near-square lattice; folded, one column per chapter.
|
||||
const across = Math.ceil(Math.sqrt(tiles.length * 1.6));
|
||||
const rest = tiles.map((_, i) => new THREE.Vector3(((i % across) - across / 2) * STEP, (Math.floor(i / across) - tiles.length / across / 2) * -STEP, 0));
|
||||
const tallest = Math.max(...chapters.map((c) => c.stories.length));
|
||||
const folded = tiles.map(({ col, row }) => new THREE.Vector3((col - chapters.length / 2) * STEP * 0.9, (row - tallest / 2) * -STEP * 0.5, 0));
|
||||
// The tiles' widest and tallest reach, kept in view.
|
||||
const reach = { w: Math.max(across, chapters.length * 0.9) * STEP + 2, h: Math.max(tiles.length / across, tallest * 0.5) * STEP + 2 };
|
||||
let distance = 34;
|
||||
|
||||
const paint = () => {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const [ink, accent] = [new THREE.Color(css.getPropertyValue("--line").trim()), new THREE.Color(css.getPropertyValue("--accent").trim())];
|
||||
// About one tile in ten wears the accent.
|
||||
tiles.forEach((_, i) => mesh.setColorAt(i, (i * 7) % 10 === 3 ? accent : ink));
|
||||
mesh.instanceColor!.needsUpdate = true;
|
||||
};
|
||||
|
||||
const size = () => {
|
||||
const { clientWidth: w, clientHeight: h } = canvas;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / Math.max(h, 1);
|
||||
camera.updateProjectionMatrix();
|
||||
const k = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
|
||||
distance = Math.max(reach.w / (k * camera.aspect), reach.h / k);
|
||||
};
|
||||
|
||||
const at = new THREE.Object3D();
|
||||
const spot = new THREE.Vector3();
|
||||
let running = true;
|
||||
let frame = 0;
|
||||
const draw = (time: number) => {
|
||||
const t = still ? 0 : fold();
|
||||
const ease = t * t * (3 - 2 * t);
|
||||
tiles.forEach((_, i) => {
|
||||
spot.lerpVectors(rest[i], folded[i], ease);
|
||||
const wave = still ? 0 : Math.sin(time / 1400 + rest[i].x * 0.35 + rest[i].y * 0.2) * 0.35 * (1 - ease);
|
||||
at.position.set(spot.x, spot.y, wave);
|
||||
at.rotation.set(-0.5 * (1 - ease), 0, 0);
|
||||
at.scale.setScalar(1 - 0.45 * ease);
|
||||
at.updateMatrix();
|
||||
mesh.setMatrixAt(i, at.matrix);
|
||||
});
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
camera.position.set(0, -0.12 * distance * (1 - ease), distance);
|
||||
camera.lookAt(0, 0, 0);
|
||||
renderer.render(world, camera);
|
||||
if (running && !still) frame = requestAnimationFrame(draw);
|
||||
};
|
||||
const start = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
const seen = new IntersectionObserver(([entry]) => {
|
||||
running = entry.isIntersecting && !document.hidden;
|
||||
if (running) start();
|
||||
});
|
||||
seen.observe(canvas);
|
||||
const visible = () => {
|
||||
running = !document.hidden;
|
||||
if (running) start();
|
||||
};
|
||||
const resized = new ResizeObserver(() => (size(), start()));
|
||||
resized.observe(canvas);
|
||||
document.addEventListener("visibilitychange", visible);
|
||||
paint();
|
||||
size();
|
||||
start();
|
||||
|
||||
const stop = () => {
|
||||
running = false;
|
||||
cancelAnimationFrame(frame);
|
||||
seen.disconnect();
|
||||
resized.disconnect();
|
||||
document.removeEventListener("visibilitychange", visible);
|
||||
renderer.dispose();
|
||||
mesh.geometry.dispose();
|
||||
(mesh.material as THREE.Material).dispose();
|
||||
};
|
||||
return { stop, paint: () => (paint(), start()) };
|
||||
}
|
||||
+76
-47
@@ -6,6 +6,10 @@
|
||||
--hover: #f2f0ed;
|
||||
--accent: #3772bb;
|
||||
--overlay: #ffffff;
|
||||
--iso-paper: #ffffff;
|
||||
--iso-soft: #e3dfda;
|
||||
--iso-ink: #2b2927;
|
||||
--iso-accent: #3772bb;
|
||||
--sans: "Geist Variable", system-ui, sans-serif;
|
||||
--mono: "Geist Mono Variable", ui-monospace, monospace;
|
||||
color-scheme: light;
|
||||
@@ -19,6 +23,10 @@
|
||||
--hover: #252422;
|
||||
--accent: #69a1e8;
|
||||
--overlay: #1c1a19;
|
||||
--iso-paper: #2e2b29;
|
||||
--iso-soft: #45413d;
|
||||
--iso-ink: #d8d4cf;
|
||||
--iso-accent: #69a1e8;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -45,11 +53,21 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
||||
|
||||
.label { font: 500 12px/16px var(--mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||
|
||||
/* Geometry: circle, square, triangle, the site's three marks. */
|
||||
i.circle, i.square, i.triangle { display: inline-block; width: 16px; height: 16px; flex: none; }
|
||||
i.circle { border-radius: 50%; background: var(--accent); }
|
||||
i.square { background: var(--fg); }
|
||||
i.triangle { background: var(--fg); clip-path: polygon(50% 0, 100% 100%, 0 100%); }
|
||||
|
||||
/* The dioramas' one canvas; each draws in its host's box. */
|
||||
canvas.iso { position: fixed; inset: 0; width: 100vw; height: 100vh; pointer-events: none; z-index: 5; }
|
||||
.iso-host { position: relative; cursor: crosshair; }
|
||||
|
||||
/* Header */
|
||||
.bar {
|
||||
position: sticky; top: 0; z-index: 20; height: 56px;
|
||||
display: flex; align-items: center; gap: 24px; padding: 0 24px;
|
||||
background: color-mix(in srgb, var(--bg) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
@@ -57,12 +75,12 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
||||
.bar nav { display: flex; gap: 4px; }
|
||||
.bar .end { margin-left: auto; display: flex; align-items: center; gap: 4px; }
|
||||
.link { padding: 4px 12px; border-radius: 8px; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--muted); }
|
||||
.link:hover, .link[aria-current="page"], .link[aria-expanded="true"] { color: var(--fg); background: var(--hover); }
|
||||
.link:hover, .link[aria-current="page"], .link[aria-expanded="true"] { color: var(--fg); }
|
||||
.tool { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; color: var(--muted); }
|
||||
.tool:hover { color: var(--fg); background: var(--hover); }
|
||||
.tool:hover, .tool[aria-pressed="true"] { color: var(--fg); }
|
||||
.find { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 8px 0 4px; border-radius: 8px; color: var(--muted); font-size: 14px; }
|
||||
.find:hover { background: var(--hover); color: var(--fg); }
|
||||
.find kbd { font: 500 12px/16px var(--mono); padding: 0 4px; border: 1px solid var(--line); border-radius: 4px; }
|
||||
.find:hover { color: var(--fg); }
|
||||
.find kbd { font: 500 12px/16px var(--mono); }
|
||||
|
||||
/* Cascading menu */
|
||||
.menu {
|
||||
@@ -72,47 +90,45 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
||||
}
|
||||
.menu a, .menu button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 4px 8px; min-height: 32px; border-radius: 8px; font-size: 14px; line-height: 20px; text-align: left; }
|
||||
.menu a:hover, .menu button:hover, .menu [aria-expanded="true"], .menu :focus-visible { background: var(--hover); outline: none; }
|
||||
.menu .count { color: var(--muted); font: 500 12px/16px var(--mono); }
|
||||
|
||||
/* Components page */
|
||||
.docs { display: grid; grid-template-columns: 264px minmax(0, 1fr); min-height: calc(100vh - 56px); }
|
||||
.side { position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; padding: 24px 12px 48px; border-right: 1px solid var(--line); }
|
||||
.side { position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; padding: 24px 16px 48px; border-right: 1px solid var(--line); }
|
||||
.side input { appearance: none; width: 100%; height: 32px; margin-bottom: 16px; padding: 0 12px; font: inherit; font-size: 14px; color: var(--fg); background: transparent; border: 1px solid var(--line); border-radius: 8px; }
|
||||
.side input::placeholder { color: var(--muted); }
|
||||
.side input:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }
|
||||
.side .chapter { display: flex; gap: 8px; align-items: center; width: 100%; padding: 4px 8px; min-height: 32px; border-radius: 8px; font-size: 14px; line-height: 20px; color: var(--muted); }
|
||||
.side .chapter .n { font: 500 12px/16px var(--mono); width: 20px; }
|
||||
.side .chapter:hover { background: var(--hover); color: var(--fg); }
|
||||
.side .chapter { display: flex; gap: 8px; align-items: center; width: 100%; padding: 4px 0; min-height: 32px; font-size: 14px; line-height: 20px; color: var(--muted); }
|
||||
.side .chapter .n { font: 500 12px/16px var(--mono); width: 24px; }
|
||||
.side .chapter:hover { color: var(--fg); }
|
||||
.side .chapter[aria-current="true"] { color: var(--fg); font-weight: 500; }
|
||||
.side ul { list-style: none; margin: 0 0 8px; padding: 0 0 0 28px; }
|
||||
.side li a { display: block; padding: 4px 8px; border-radius: 8px; font-size: 14px; line-height: 20px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.side li a:hover { background: var(--hover); color: var(--fg); }
|
||||
.side li a[aria-current="page"] { color: var(--fg); background: var(--hover); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.side .none { padding: 8px; font-size: 14px; color: var(--muted); }
|
||||
.side ul { list-style: none; margin: 0 0 8px; padding: 0 0 0 32px; }
|
||||
.side li a { display: block; padding: 4px 0; font-size: 14px; line-height: 20px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.side li a:hover { color: var(--fg); }
|
||||
.side li a[aria-current="page"] { color: var(--fg); font-weight: 500; }
|
||||
.side .none { padding: 8px 0; font-size: 14px; color: var(--muted); }
|
||||
|
||||
.detail { padding: 48px 48px 96px; max-width: 1136px; }
|
||||
.crumb { font-size: 14px; line-height: 20px; color: var(--muted); }
|
||||
.crumb a:hover { color: var(--fg); }
|
||||
.detail h1 { margin: 4px 0 12px; font-size: 18px; line-height: 28px; font-weight: 600; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; }
|
||||
.chips span { font: 500 12px/16px var(--mono); padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); }
|
||||
.detail h1 { margin: 4px 0 4px; font-size: 18px; line-height: 28px; font-weight: 600; }
|
||||
.names { margin: 0 0 32px; font: 500 12px/16px var(--mono); color: var(--muted); }
|
||||
|
||||
.stage { position: relative; height: 640px; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: var(--bg); }
|
||||
.stage { position: relative; height: 640px; overflow: hidden; background: var(--bg); }
|
||||
.stage iframe { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); }
|
||||
.stage .state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 14px; color: var(--muted); background: var(--bg); }
|
||||
.stage .state[hidden] { display: none; }
|
||||
.stage .actions { position: absolute; top: 12px; right: 12px; display: flex; gap: 4px; opacity: 0; transition: opacity 150ms ease-out; }
|
||||
.stage .actions { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; opacity: 0; transition: opacity 150ms ease-out; }
|
||||
.stage:hover .actions, .stage:focus-within .actions { opacity: 1; }
|
||||
.stage .actions .tool { background: var(--bg); border: 1px solid var(--line); }
|
||||
.stage.full { position: fixed; inset: 0; z-index: 40; height: auto; border-radius: 0; border: 0; }
|
||||
.stage .actions .tool { background: var(--bg); }
|
||||
.stage.full { position: fixed; inset: 0; z-index: 40; height: auto; }
|
||||
.spin { width: 16px; height: 16px; border: 1.5px solid var(--line); border-top-color: var(--muted); border-radius: 50%; animation: spin 800ms linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.stage-skip { position: absolute; left: -9999px; }
|
||||
.stage-skip:focus { position: static; display: inline-block; margin-bottom: 8px; font-size: 14px; color: var(--accent); }
|
||||
|
||||
.pager { display: flex; justify-content: space-between; gap: 16px; margin-top: 24px; }
|
||||
.pager a { display: flex; flex-direction: column; gap: 4px; max-width: 48%; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 20px; }
|
||||
.pager a:hover { background: var(--hover); }
|
||||
.pager { display: flex; justify-content: space-between; gap: 16px; margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--line); }
|
||||
.pager a { display: flex; flex-direction: column; gap: 4px; max-width: 48%; font-size: 14px; line-height: 20px; }
|
||||
.pager a:hover { color: var(--accent); }
|
||||
.pager a.next { text-align: right; margin-left: auto; }
|
||||
.pager .label { display: flex; align-items: center; gap: 4px; }
|
||||
.pager a.next .label { justify-content: flex-end; }
|
||||
@@ -120,10 +136,10 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
||||
.index { padding: 48px; max-width: 1136px; }
|
||||
.index h1 { margin: 0 0 8px; font-size: 18px; line-height: 28px; font-weight: 600; }
|
||||
.index p { margin: 0 0 48px; color: var(--muted); max-width: 640px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 4px 24px; }
|
||||
.grid a { display: flex; gap: 12px; align-items: baseline; padding: 12px; border-radius: 12px; }
|
||||
.grid a:hover { background: var(--hover); }
|
||||
.grid .n { font: 500 12px/16px var(--mono); color: var(--muted); width: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 24px; border-top: 1px solid var(--line); }
|
||||
.grid a { display: flex; gap: 12px; align-items: baseline; padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||||
.grid a:hover strong { color: var(--accent); }
|
||||
.grid .n { font: 500 12px/16px var(--mono); color: var(--muted); width: 24px; }
|
||||
.grid strong { font-weight: 500; display: block; }
|
||||
.grid small { font-size: 14px; line-height: 20px; color: var(--muted); }
|
||||
|
||||
@@ -138,28 +154,43 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
||||
.palette .none { padding: 16px; color: var(--muted); font-size: 14px; }
|
||||
|
||||
/* Home */
|
||||
.hero { height: 220vh; }
|
||||
.pin { position: sticky; top: 56px; height: calc(100vh - 56px); display: grid; align-content: center; padding: 48px; overflow: hidden; }
|
||||
.hero canvas { position: absolute; top: 0; right: 0; width: 62%; height: 100%; }
|
||||
.hero .words { position: relative; max-width: 440px; }
|
||||
.hero { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); align-items: center; gap: 48px; min-height: calc(100vh - 56px); padding: 48px; }
|
||||
.hero-iso { height: min(720px, calc(100vh - 152px)); }
|
||||
.words { max-width: 440px; }
|
||||
.glyphs { display: flex; gap: 8px; margin-bottom: 32px; }
|
||||
.hero h1 { margin: 0 0 16px; font-size: 18px; line-height: 28px; font-weight: 400; }
|
||||
.hero h1 strong { display: block; font-weight: 600; }
|
||||
.hero p { margin: 0 0 24px; color: var(--muted); }
|
||||
.hero p { margin: 0 0 32px; color: var(--muted); }
|
||||
.cta { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.button { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 12px; font-size: 14px; font-weight: 500; border: 1px solid var(--line); }
|
||||
.button:hover { background: var(--hover); }
|
||||
.button.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
|
||||
.button.primary:hover { opacity: 0.88; }
|
||||
.band { padding: 96px 48px; border-top: 1px solid var(--line); }
|
||||
.band .label { margin-bottom: 12px; }
|
||||
.band h2 { margin: 0 0 48px; font-size: 18px; line-height: 28px; font-weight: 600; max-width: 560px; }
|
||||
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 48px; }
|
||||
.button { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 12px; font-size: 14px; font-weight: 500; }
|
||||
.button:hover { color: var(--accent); }
|
||||
.button.primary { background: var(--fg); color: var(--bg); }
|
||||
.button.primary:hover { color: var(--bg); opacity: 0.88; }
|
||||
|
||||
.show { padding: 96px 48px 48px; border-top: 1px solid var(--line); }
|
||||
.show .mark { display: grid; grid-template-columns: 16px 32px auto; align-items: center; column-gap: 16px; row-gap: 8px; margin-bottom: 48px; }
|
||||
.show .mark .n { font: 500 12px/16px var(--mono); color: var(--muted); }
|
||||
.show .mark h2 { margin: 0; font-size: 18px; line-height: 28px; font-weight: 600; }
|
||||
.show .mark p { grid-column: 3; margin: 0; max-width: 560px; color: var(--muted); }
|
||||
.pair { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 48px; align-items: center; margin-bottom: 48px; }
|
||||
.show .mark { margin-bottom: 0; }
|
||||
.pair .iso-host { height: 400px; }
|
||||
.show .stage { height: 640px; }
|
||||
|
||||
.facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 48px; padding: 96px 48px; border-top: 1px solid var(--line); }
|
||||
.facts strong { display: block; font: 500 18px/28px var(--mono); }
|
||||
.facts span { color: var(--muted); font-size: 14px; line-height: 20px; }
|
||||
.home-stage { max-width: 1136px; }
|
||||
|
||||
.foot { padding: 24px 48px; border-top: 1px solid var(--line); font-size: 12px; line-height: 16px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 8px 24px; }
|
||||
.foot a { text-decoration: underline; text-underline-offset: 2px; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.hero, .pair { grid-template-columns: 1fr; }
|
||||
.hero-iso { height: 60vh; }
|
||||
.pair .iso-host { height: 320px; }
|
||||
.facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding: 0 16px; gap: 12px; }
|
||||
.bar nav .link { padding: 4px 8px; }
|
||||
@@ -167,10 +198,8 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
||||
.docs { grid-template-columns: 1fr; }
|
||||
.side { position: static; height: auto; max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.detail, .index { padding: 24px 16px 64px; }
|
||||
.stage { height: 480px; }
|
||||
.pin, .band { padding: 48px 16px; }
|
||||
.pin { align-content: end; }
|
||||
.hero canvas { width: 100%; height: 55%; }
|
||||
.stage, .show .stage { height: 480px; }
|
||||
.hero, .show, .facts { padding: 48px 16px; }
|
||||
.foot { padding: 24px 16px; }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user