diff --git a/frontend/package.json b/frontend/package.json index c4101b6..cba0f4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,10 +8,12 @@ "build": "tsc --noEmit && vite build", "preview": "vite preview", "deploy": "wrangler deploy", - "e2e": "playwright test" + "e2e": "playwright test", + "test": "node --test src/iso/engine.test.ts" }, "devDependencies": { "@playwright/test": "1.63.0", + "@types/node": "24.19.0", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6275621..fea3f33 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@playwright/test': specifier: 1.63.0 version: 1.63.0 + '@types/node': + specifier: 24.19.0 + version: 24.19.0 '@types/react': specifier: 19.3.0 version: 19.3.0 @@ -41,16 +44,16 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.3.1(esbuild@0.28.1)) + version: 6.1.1(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.1)) typescript: specifier: ^7.0.2 version: 7.0.2 vite: specifier: 8.3.1 - version: 8.3.1(esbuild@0.28.1) + version: 8.3.1(@types/node@24.19.0)(esbuild@0.28.1) wrangler: specifier: 4.143.0 - version: 4.143.0 + version: 4.143.0(@types/node@24.19.0) packages: @@ -585,6 +588,9 @@ packages: '@tanstack/store@0.11.2': resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + '@types/node@24.19.0': + resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -964,6 +970,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.29.0: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} @@ -1379,6 +1388,10 @@ snapshots: '@tanstack/store@0.11.2': {} + '@types/node@24.19.0': + dependencies: + undici-types: 7.24.6 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -1447,10 +1460,10 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.1.1(vite@8.3.1(esbuild@0.28.1))': + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.1))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.1(esbuild@0.28.1) + vite: 8.3.1(@types/node@24.19.0)(esbuild@0.28.1) blake3-wasm@2.1.5: {} @@ -1557,10 +1570,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 - miniflare@5.20260926.0-alpha: + miniflare@5.20260926.0-alpha(@types/node@24.19.0): dependencies: '@cspotcode/source-map-support': 0.8.1 - sharp: 0.35.4 + sharp: 0.35.4(@types/node@24.19.0) undici: 7.29.0 workerd: 1.20260926.1 ws: 8.21.0 @@ -1632,7 +1645,7 @@ snapshots: seroval@1.6.8: {} - sharp@0.35.4: + sharp@0.35.4(@types/node@24.19.0): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -1663,6 +1676,7 @@ snapshots: '@img/sharp-win32-arm64': 0.35.4 '@img/sharp-win32-ia32': 0.35.4 '@img/sharp-win32-x64': 0.35.4 + '@types/node': 24.19.0 source-map-js@1.2.1: {} @@ -1699,6 +1713,8 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@7.24.6: {} + undici@7.29.0: {} unenv@2.0.0-rc.24: @@ -1709,7 +1725,7 @@ snapshots: dependencies: react: 19.3.0 - vite@8.3.1(esbuild@0.28.1): + vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1717,6 +1733,7 @@ snapshots: rolldown: 1.2.11 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.19.0 esbuild: 0.28.1 fsevents: 2.3.3 @@ -1728,13 +1745,13 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260926.1 '@cloudflare/workerd-windows-64': 1.20260926.1 - wrangler@4.143.0: + wrangler@4.143.0(@types/node@24.19.0): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260926.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 5.20260926.0-alpha + miniflare: 5.20260926.0-alpha(@types/node@24.19.0) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 workerd: 1.20260926.1 diff --git a/frontend/src/Shell.tsx b/frontend/src/Shell.tsx index 08885e1..a59643e 100644 --- a/frontend/src/Shell.tsx +++ b/frontend/src/Shell.tsx @@ -1,11 +1,13 @@ import { Link, Outlet } from "@tanstack/react-router"; import { REPO } from "./data"; import { Icon } from "./icons"; +import { setSound, useSound } from "./iso/marimba"; import { setMode, useMode } from "./theme"; function Header() { const theme = useMode(); const dark = theme === "dark"; + const sound = useSound(); return (
@@ -17,6 +19,9 @@ function Header() {
+ diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx index 8469ed4..bdd2447 100644 --- a/frontend/src/icons.tsx +++ b/frontend/src/icons.tsx @@ -3,9 +3,11 @@ import Maximize from "reicon/icons/Maximize"; import Minimize from "reicon/icons/Minimize"; import Moon from "reicon/icons/Moon"; import Refresh from "reicon/icons/Refresh"; +import Sound from "reicon/icons/Sound"; import Sun from "reicon/icons/Sun"; +import VolumeCross from "reicon/icons/VolumeCross"; -const set = { ArrowRightUp, Maximize, Minimize, Moon, Refresh, Sun }; +const set = { ArrowRightUp, Maximize, Minimize, Moon, Refresh, Sound, Sun, VolumeCross }; // simple-icons' GitHub mark (CC0); Reicon has none. const GITHUB = diff --git a/frontend/src/iso/Iso.tsx b/frontend/src/iso/Iso.tsx new file mode 100644 index 0000000..d21df15 --- /dev/null +++ b/frontend/src/iso/Iso.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; +import { useMode } from "../theme"; +import { miniature } from "./draw"; +import type { Block } from "./engine"; + +/** Grows once it shows, and again when pressed. */ +export function Iso({ model, label, className }: { model: Block[]; label: string; className?: string }) { + const canvas = useRef(null); + const mini = useRef | null>(null); + const theme = useMode(); + + useEffect(() => { + const still = matchMedia("(prefers-reduced-motion: reduce)").matches; + const m = miniature(canvas.current!, model, still); + mini.current = m; + const seen = new IntersectionObserver(([e]) => e.isIntersecting && m.start(), { threshold: 0.35 }); + seen.observe(canvas.current!); + return () => { + seen.disconnect(); + m.stop(); + }; + }, [model]); + + useEffect(() => mini.current?.repaint(), [theme]); + + return mini.current?.replay()} />; +} diff --git a/frontend/src/iso/draw.ts b/frontend/src/iso/draw.ts new file mode 100644 index 0000000..589f323 --- /dev/null +++ b/frontend/src/iso/draw.ts @@ -0,0 +1,162 @@ +import { bounds, faces, inside, painted, rise, type Block, type Point } from "./engine"; +import { BEAT, strike } from "./marimba"; + +const DRAW = BEAT * 2; +const GROW = 320; +const BUMP = 520; + +interface Ink { + ground: string; + line: string; + accent: string; + side: string; + shade: string; +} + +function ink(): Ink { + const css = getComputedStyle(document.documentElement); + const v = (name: string) => css.getPropertyValue(name).trim(); + 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) { + const blocks = painted(model); + const ctx = canvas.getContext("2d")!; + let colors = ink(); + let started: number | null = still ? -Infinity : null; + let struck = -1; + let frame = 0; + let view = { scale: 1, dx: 0, dy: 0 }; + const bumps = new Map(); + + const fit = () => { + const dpr = Math.min(devicePixelRatio, 2); + const { clientWidth: w, clientHeight: h } = canvas; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + const [x0, y0, x1, y1] = bounds(blocks); + const scale = Math.min((w * 0.9) / (x1 - x0), (h * 0.86) / (y1 - y0)); + view = { scale: scale * dpr, dx: (w * dpr - (x1 + x0) * scale * dpr) / 2, dy: (h * dpr - (y1 + y0) * scale * dpr) / 2 }; + }; + + const at = ([x, y]: Point): Point => [x * view.scale + view.dx, y * view.scale + view.dy]; + + const path = (poly: Point[], upto = 1) => { + ctx.beginPath(); + const pts = poly.map(at); + ctx.moveTo(...pts[0]); + // The footprint draws itself edge by edge. + const edges = pts.length * upto; + for (let i = 1; i <= Math.floor(edges); i++) ctx.lineTo(...pts[i % pts.length]); + const part = edges - Math.floor(edges); + if (part > 0 && edges < pts.length) { + const [a, b] = [pts[Math.floor(edges) % pts.length], pts[(Math.floor(edges) + 1) % pts.length]]; + ctx.lineTo(a[0] + (b[0] - a[0]) * part, a[1] + (b[1] - a[1]) * part); + } + }; + + const draw = (now: number) => { + frame = 0; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.lineJoin = "round"; + ctx.lineWidth = Math.max(1, view.scale * 0.018); + let moving = false; + for (const b of blocks) { + const since = started === null ? -1 : now - started - b.beat * BEAT; + if (since < 0) { + moving ||= started !== null; + continue; + } + 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 f = faces(b, h); + const stroke = b.accent ? colors.accent : colors.line; + if (drawn < 1) { + ctx.strokeStyle = stroke; + path(f.top, drawn); + ctx.stroke(); + continue; + } + for (const [poly, fill] of [[f.right, colors.shade], [f.front, colors.side], [f.top, colors.ground]] as const) { + path(poly); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + ctx.strokeStyle = stroke; + ctx.stroke(); + } + } + if (started !== null && !still) { + const beat = Math.floor((now - started) / BEAT); + for (; struck < beat; ) { + struck++; + const first = blocks.find((b) => b.beat === struck); + if (first) strike(struck + Math.round(first.h * 2)); + } + } + if (moving) request(); + }; + + const request = () => { + if (!frame) frame = requestAnimationFrame(draw); + }; + + const pointer = (e: PointerEvent) => { + if (still) return; + 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]; + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + const f = faces(b, 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; + } + } + }; + + const resized = new ResizeObserver(() => (fit(), request())); + resized.observe(canvas); + canvas.addEventListener("pointermove", pointer); + fit(); + request(); + return { + start() { + // Still, it stands grown from the first frame. + if (started !== null) return; + started = performance.now(); + struck = -1; + request(); + }, + replay() { + if (still) return; + started = performance.now(); + struck = -1; + request(); + }, + repaint() { + colors = ink(); + request(); + }, + stop() { + cancelAnimationFrame(frame); + resized.disconnect(); + canvas.removeEventListener("pointermove", pointer); + }, + }; +} + diff --git a/frontend/src/iso/engine.test.ts b/frontend/src/iso/engine.test.ts new file mode 100644 index 0000000..1f60d3b --- /dev/null +++ b/frontend/src/iso/engine.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { bounds, faces, inside, painted, project, rise, type Block } from "./engine.ts"; + +const block = (x: number, z: number, w: number, d: number, h: number, y = 0): Block => ({ x, z, w, d, h, y, beat: 0 }); + +test("the projection keeps vertical lines vertical and the axes at thirty degrees", () => { + const [ox, oy] = project(0, 0, 0); + const [ux, uy] = project(0, 1, 0); + assert.equal(ux, ox); + assert.equal(uy, oy - 1); + const [xx, xy] = project(1, 0, 0); + assert.ok(Math.abs(Math.atan2(xy, xx) - Math.PI / 6) < 1e-9); +}); + +test("a block standing on a slab paints after it", () => { + const slab = block(0, 0, 10, 10, 0.5); + const tower = block(2, 2, 1, 1, 3, 0.5); + assert.deepEqual(painted([tower, slab]), [slab, tower]); +}); + +test("a block behind another paints first, on either axis", () => { + const far = block(0, 0, 1, 1, 1); + const nearX = block(2, 0, 1, 1, 1); + const nearZ = block(0, 2, 1, 1, 1); + assert.deepEqual(painted([nearX, far]), [far, nearX]); + assert.deepEqual(painted([nearZ, far]), [far, nearZ]); +}); + +test("blocks apart on both axes keep the order they came in", () => { + const left = block(0, 3, 1, 1, 1); + const right = block(3, 0, 1, 1, 1); + assert.deepEqual(painted([left, right]), [left, right]); + assert.deepEqual(painted([right, left]), [right, left]); +}); + +test("a point inside a top face hits it, one outside misses", () => { + const top = faces(block(0, 0, 2, 2, 1), 1).top; + const centre = project(1, 1, 1); + assert.ok(inside(centre, top)); + assert.ok(!inside(project(5, 1, 5), top)); +}); + +test("bounds hold every corner at full height", () => { + const [x0, y0, x1, y1] = bounds([block(0, 0, 1, 1, 2)]); + const top = project(0, 2, 0); + assert.ok(top[1] >= y0 && top[1] <= y1 && top[0] >= x0 && top[0] <= x1); +}); + +test("the rise starts at zero, overshoots, and settles at one", () => { + assert.equal(rise(0), 0); + assert.equal(rise(1), 1); + assert.ok(Math.max(...Array.from({ length: 50 }, (_, i) => rise(i / 50))) > 1); +}); diff --git a/frontend/src/iso/engine.ts b/frontend/src/iso/engine.ts new file mode 100644 index 0000000..19a780b --- /dev/null +++ b/frontend/src/iso/engine.ts @@ -0,0 +1,99 @@ +/** A box on the table, its accent, and its beat. */ +export interface Block { + x: number; + z: number; + w: number; + d: number; + y: number; + h: number; + accent?: boolean; + beat: number; +} + +export type Point = [number, number]; + +const COS = Math.cos(Math.PI / 6); + +/** Isometric: x runs down-right, z down-left, y up. */ +export function project(x: number, y: number, z: number): Point { + return [(x - z) * COS, (x + z) * 0.5 - y]; +} + +const EPS = 1e-6; + +/** Whether `a` paints before `b`: under or behind it. */ +function under(a: Block, b: Block): boolean { + const apartX = a.x + a.w <= b.x + EPS || b.x + b.w <= a.x + EPS; + 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); + // Apart on both axes, they stand side by side. + if (apartX && apartZ) return false; + return apartX ? a.x + a.w <= b.x + EPS : a.z + a.d <= b.z + EPS; +} + +/** Back to front: each block after what it covers. */ +export function painted(blocks: Block[]): Block[] { + const n = blocks.length; + const after: number[][] = blocks.map(() => []); + const waiting = new Array(n).fill(0); + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + if (i !== j && under(blocks[i], blocks[j])) { + after[i].push(j); + waiting[j]++; + } + } + } + const ready = blocks.map((_, i) => i).filter((i) => waiting[i] === 0); + const out: Block[] = []; + while (ready.length) { + const i = ready.shift()!; + out.push(blocks[i]); + for (const j of after[i]) if (--waiting[j] === 0) ready.push(j); + } + if (out.length !== n) throw new Error(`iso: ${n - out.length} blocks overlap and have no painting order`); + return out; +} + +/** The three faces the eye sees, at height `h`. */ +export function faces(b: Block, h: number): { top: Point[]; right: Point[]; front: Point[] } { + const [x0, x1, z0, z1, y0, y1] = [b.x, b.x + b.w, b.z, b.z + b.d, b.y, b.y + h]; + return { + top: [project(x0, y1, z0), project(x1, y1, z0), project(x1, y1, z1), project(x0, y1, z1)], + right: [project(x1, y0, z0), project(x1, y0, z1), project(x1, y1, z1), project(x1, y1, z0)], + front: [project(x0, y0, z1), project(x1, y0, z1), project(x1, y1, z1), project(x0, y1, z1)], + }; +} + +/** Whether `p` lies inside the convex polygon `poly`. */ +export function inside(p: Point, poly: Point[]): boolean { + let sign = 0; + for (let i = 0; i < poly.length; i++) { + const [a, b] = [poly[i], poly[(i + 1) % poly.length]]; + const cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + if (cross === 0) continue; + if (sign === 0) sign = Math.sign(cross); + else if (Math.sign(cross) !== sign) return false; + } + return true; +} + +/** The projected box that holds every block at full height. */ +export function bounds(blocks: Block[]): [number, number, number, number] { + let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; + for (const b of blocks) { + const f = faces(b, b.h); + for (const [x, y] of [...f.top, ...f.right, ...f.front]) { + [x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x), Math.max(y1, y)]; + } + } + return [x0, y0, x1, y1]; +} + +/** Back-out easing: a block overshoots its height, then settles. */ +export function rise(t: number): number { + if (t <= 0) return 0; + if (t >= 1) return 1; + const u = t - 1; + return 1 + 2.4 * u ** 3 + 1.4 * u ** 2; +} diff --git a/frontend/src/iso/marimba.ts b/frontend/src/iso/marimba.ts new file mode 100644 index 0000000..772b55c --- /dev/null +++ b/frontend/src/iso/marimba.ts @@ -0,0 +1,69 @@ +import { useSyncExternalStore } from "react"; + +/** 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; browsers need a press first. */ +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; 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: a fundamental, a partial, a 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); + } +} + +/** Whether sound is on, redrawn when it turns. */ +export function useSound(): boolean { + return useSyncExternalStore((listen) => onSound(listen), soundOn); +} diff --git a/frontend/src/iso/models.ts b/frontend/src/iso/models.ts new file mode 100644 index 0000000..99ed18b --- /dev/null +++ b/frontend/src/iso/models.ts @@ -0,0 +1,20 @@ +import type { Block } from "./engine"; + +const b = (x: number, z: number, w: number, d: number, h: number, y = 0, accent = false): Block => ({ x, z, w, d, h, y, accent, beat: 0 }); + +/** Beats from the far corner toward the eye. */ +const sweep = (blocks: Block[], pace: number) => blocks.map((k) => ({ ...k, beat: Math.round((k.x + k.z) * pace) })); + +/** An app: title bar, sidebar, chart, chat, switch. */ +export function app(): Block[] { + const s: Block[] = [b(0, 0, 18, 12, 0.5)]; + s.push(b(0.6, 0.6, 16.8, 1, 0.25, 0.5)); + for (let i = 0; i < 3; i++) s.push(b(1 + i * 0.8, 0.85, 0.45, 0.45, 0.25, 0.75, i === 0)); + for (let i = 0; i < 6; i++) s.push(b(0.8, 2.2 + i * 1.5, 3.4, 1, 0.25, 0.5, i === 1)); + s.push(b(5, 2.2, 7.4, 1, 0.2, 0.5)); + s.push(b(5, 3.8, 7.4, 6.4, 0.08, 0.5)); + [0.9, 1.5, 1.2, 2.1, 1.7, 2.8, 2.3, 3.2].forEach((h, i) => s.push(b(5.5 + i * 0.85, 7.6, 0.55, 1.2, h, 0.58, i === 5))); + for (let i = 0; i < 4; i++) s.push(b(i % 2 ? 14.2 : 13, 2.2 + i * 1.7, i % 2 ? 2.8 : 3.6, 1.2, 0.4, 0.5, i === 3)); + 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); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 0e5816a..0d879a0 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,6 +1,10 @@ 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"; + +const APP = app(); export function Home() { useEffect(() => { @@ -8,6 +12,7 @@ export function Home() { }, []); return (
+

Components for GPUI.

{componentCount} components in {chapters.length} chapters, live in the browser. diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0623102..0c8df51 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -6,6 +6,9 @@ --line: #e6e3df; --accent: #3772bb; --overlay: #ffffff; + --iso-line: #1a1918; + --iso-side: #f3f0ec; + --iso-shade: #e9e5df; --sans: "Geist Variable", system-ui, sans-serif; --mono: "Geist Mono Variable", ui-monospace, monospace; color-scheme: light; @@ -19,6 +22,9 @@ --line: #262422; --accent: #69a1e8; --overlay: #1b1917; + --iso-line: #d9d5d0; + --iso-side: #1a1816; + --iso-shade: #201e1c; color-scheme: dark; } @@ -63,7 +69,9 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0; main { min-height: 100vh; } /* Pages */ -.hero { min-height: 100vh; display: grid; align-content: center; justify-items: start; gap: 24px; padding: 96px 64px; } +.hero { position: relative; min-height: 100vh; display: grid; align-content: end; justify-items: start; gap: 24px; padding: 96px 64px; } +.hero-iso { position: absolute; top: 64px; right: 0; width: 64%; height: calc(100% - 64px); cursor: crosshair; } +.hero > :not(canvas) { 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; } @@ -111,6 +119,8 @@ img.shot { display: block; max-width: 100%; height: auto; } .hero, .chapters, .story, .missing { padding-left: 20px; padding-right: 20px; } .foot { padding: 32px 20px; } .chapters .name { font-size: 22px; } + .hero-iso { width: 100%; height: 55%; } + .hero > :not(canvas) { max-width: none; } } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2a6521d..f6706cb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,9 +10,10 @@ "skipLibCheck": true, "isolatedModules": true, "resolveJsonModule": true, + "allowImportingTsExtensions": true, "noUnusedLocals": true, "noUnusedParameters": true, - "types": ["vite/client"] + "types": ["vite/client", "node"] }, "include": ["src", "e2e"] }