diff --git a/src/app/globals.css b/src/app/globals.css
index ed19159..c058a2d 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -216,6 +216,26 @@
background-color 120ms ease;
}
+ /*
+ An icon-only button.
+
+ Sized to the box a hud-button with a word in it would have taken: one line
+ of type, the padding above, and the two hairlines box-sizing folds in. That
+ is what keeps it square without a magic number, and what keeps it the same
+ height as the labelled buttons it sits beside.
+
+ Both axes are written out because neither can be inferred. Equal padding
+ around the icon squares the box only until a labelled neighbour makes the
+ flex line taller than the glyph, and `aspect-ratio` does not help either: a
+ flex item is sized from its content first, so the ratio loses to a 14px
+ icon.
+ */
+ .hud-square {
+ width: calc(1lh + 0.8rem + 2px);
+ height: calc(1lh + 0.8rem + 2px);
+ padding: 0;
+ }
+
.hud-button:hover:not(:disabled) {
color: var(--color-ink);
background: var(--color-panel-hover);
@@ -237,6 +257,36 @@
border-color: var(--color-accent);
}
+ /*
+ An indeterminate bar, for waits whose length is not knowable in advance:
+ packing a set fetches every part it uses, and how many that is is exactly
+ what nobody knows until it is done. A segment that travels says the app is
+ still working; a static one that fills would be a lie about how far along
+ it is.
+ */
+ .progress-track {
+ overflow: hidden;
+ background: var(--color-edge);
+ }
+
+ .progress-track::after {
+ display: block;
+ width: 34%;
+ height: 100%;
+ content: "";
+ background: var(--color-accent-fg);
+ animation: progress-slide 1.4s ease-in-out infinite;
+ }
+
+ @keyframes progress-slide {
+ from {
+ transform: translateX(-100%);
+ }
+ to {
+ transform: translateX(394%);
+ }
+ }
+
/* Needs to outrank the plain hover rule, which is why it repeats the state. */
.hud-button[data-active="true"]:hover:not(:disabled) {
color: var(--color-on-accent);
diff --git a/src/app/icon.svg b/src/app/icon.svg
index 2e3afb8..d3964bd 100644
--- a/src/app/icon.svg
+++ b/src/app/icon.svg
@@ -1,9 +1,22 @@
diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx
index 40012f0..4205747 100644
--- a/src/app/opengraph-image.tsx
+++ b/src/app/opengraph-image.tsx
@@ -1,7 +1,7 @@
import { ImageResponse } from "next/og";
export const alt =
- "LDraw Builder: watch a LEGO model assemble itself, one build step at a time";
+ "LDraw Builder: watch a LEGO model build itself, then build one yourself";
export const size = { height: 630, width: 1200 };
export const contentType = "image/png";
@@ -57,28 +57,30 @@ export default function OpenGraphImage() {
-
-
- Tip the bricks onto the floor and watch a model assemble itself.
+ {/*
+ The page's own heading, at the page's own weights. A card that is read
+ in a second in a feed cannot carry the paragraph and the feature list
+ that used to be here as well, and the four words below say what it is
+ for anyone the heading leaves guessing.
+ */}
+
+
+ Watch it build itself.
- LDraw models in the browser. Follow the build step by step, explode
- it, slice it open, or click any brick.
+ Then build it yourself.
-
-
Build order
-
Explode
-
Layer slice
-
Part inspector
+
+ LDraw models, in the browser.
,
size
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 9dbc546..77cee90 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,19 +1,30 @@
import Link from "next/link";
import { DropZone } from "@/components/DropZone";
import { FreeBuildCard } from "@/components/free/FreeBuildCard";
+import { HeroBuild } from "@/components/HeroBuild";
import { OpenSetCard } from "@/components/OpenSetCard";
import { ResumeBadge } from "@/components/ResumeBadge";
import { LegalFooter } from "@/components/shell/LegalFooter";
import { ThemeToggle } from "@/components/ThemeToggle";
-import { getManifest } from "@/lib/manifest";
+import { getManifest, type ModelMeta } from "@/lib/manifest";
export default async function GalleryPage() {
const models = await getManifest();
+ const hero = pickHeroModel(models);
return (
-
-
+ {/*
+ The header is the model: the canvas sits behind the type, building the
+ set the gallery below is full of. It is the claim and the demonstration
+ in the same rectangle, which is why the text over it can be two lines.
+ */}
+
+ {hero ? (
+
+ ) : null}
+
+
@@ -26,93 +37,97 @@ export default async function GalleryPage() {
-
- Tip the bricks onto the floor and watch a model assemble itself, one
- build step at a time. Or tip them out and build it yourself.
-
-
- Every model is an LDraw file rendered with three.js. Follow the
- authored build order, explode the finished model to see how it fits
- together, slice it open layer by layer, or click any single brick to
- find out what it is. In build mode the pile is a live physics
- simulation: dig through it, throw pieces aside, and drop each one
- into its slot. Progress is kept in this browser, so a long set can
- be picked up where you left it.
-
+ {/* Held clear of the right-hand third so the model has somewhere to
+ stand, and so the drag that turns it lands on the canvas. */}
+
+ {/* Broken by hand rather than left to wrap: the two halves are a
+ promise and its answer, and the second is a shade back so the
+ first is what the eye lands on. */}
+
+ Watch it build itself.
+ Then build it yourself.
+
+
-
-
Models
- {models.length} available
-
+
+
+ Start a build
+
+
+ {/* Search first and largest: it reaches every official set, where
+ everything below it reaches one. Free build sits beside it because
+ it is the same decision made the other way. */}
+
+
+
+
+
+
+
+ Official sets come from the{" "}
+
+ LDraw Official Model Repository
+
+ , where every file is redistributable under CC BY 2.0 and credits
+ the person who built it. Parts that cannot be found are skipped and
+ flagged, and the rest of the model still builds.
+
+
- {models.length === 0 && }
-
-
- {models.map((model) => (
-
-
-
-
- {model.title}
-
- {model.blurb ? (
-
- {model.blurb}
+
+
+
+ Models
+
+
+ {models.length} available
+
+
+
+ {models.length === 0 && }
+
+
+ {models.map((model) => (
+
+
+
+
+ {model.title}
+
+ {model.blurb ? (
+
+ {model.blurb}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ {model.credit}
- ) : null}
-
-
-
-
-
-
-
-
-
- {model.credit}
-
-
-
-
-
- ))}
-
- {/* Last cells, so opening a model, adding one, and building without
- one are the same gesture in the same place. With six bundled
- models they also fill the gap the grid would otherwise leave. */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Official sets come from the{" "}
-
- LDraw Official Model Repository
-
- , where every file is redistributable under CC BY 2.0 and credits the
- person who built it. Parts that cannot be found are skipped and
- flagged, and the rest of the model still builds.
-
+
+
+
+
+ ))}
+
+
@@ -120,6 +135,44 @@ export default async function GalleryPage() {
);
}
+/**
+ * Above this a hero would cost more to load than the page it decorates.
+ *
+ * It is also the loader's smoothing threshold, so the model behind the header
+ * is one whose studs are round.
+ */
+const HERO_MAX_BRICKS = 200;
+
+/**
+ * Which bundled model builds itself behind the header.
+ *
+ * The car by name, because it is the canonical LDraw demo and it reads as a
+ * thing rather than as a shape; otherwise the largest model this deployment
+ * packed that is still cheap enough to put on a page nobody has committed to.
+ * A checkout with nothing packed gets a header with no model in it.
+ */
+function pickHeroModel(
+ models: ModelMeta[]
+): { slug: string; title: string; url: string } | null {
+ const affordable = models.filter(
+ (model) => model.bricks <= HERO_MAX_BRICKS && model.bricks > 0
+ );
+ const chosen =
+ affordable.find((model) => model.slug === "car") ??
+ affordable.reduce(
+ (best, model) => (best && best.bricks >= model.bricks ? best : model),
+ null
+ );
+
+ return chosen
+ ? {
+ slug: chosen.slug,
+ title: chosen.title,
+ url: `/models/${chosen.slug}.mpd`,
+ }
+ : null;
+}
+
function Stat({ label, value }: { label: string; value: number }) {
return (
@@ -138,7 +191,7 @@ function EmptyState() {
The bundled models have not been packed for this deployment yet. The
setup steps are in the project README. You can still drop your own
- self-contained .mpd file below without them.
+ self-contained .mpd file above without them.
+ {/*
+ A strip rather than a card: bringing your own file is a real way in, but
+ it is the one that presumes you already have an .ldr on disk, so it sits
+ under the two that presume nothing and takes a line rather than a tile.
+ */}
diff --git a/src/components/HeroBuild.tsx b/src/components/HeroBuild.tsx
new file mode 100644
index 0000000..9bb06be
--- /dev/null
+++ b/src/components/HeroBuild.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import type { HeroScene } from "@/scene/HeroScene";
+
+/**
+ * The model behind the header.
+ *
+ * Decorative, and marked as such: it says nothing the text beside it does not
+ * already say, and everything it does happens on its own. The three.js side is
+ * imported after mount, so a page that is mostly type does not ship a renderer
+ * to get painted, and the loop is parked the moment the header scrolls away.
+ */
+
+/**
+ * Where the model sits in the frame.
+ *
+ * Wide enough and it stands to the right of the type. Narrow, and there is no
+ * beside: it drops into the band the header keeps clear underneath instead,
+ * which is the only arrangement where a phone gets both the words and the car.
+ */
+const WIDE_BIAS = { x: 0.72, y: 0.5 };
+const NARROW_BIAS = { x: 0.5, y: 0.74 };
+
+/**
+ * Where the layout switches, in pixels.
+ *
+ * Tailwind's `lg`, and it has to stay Tailwind's `lg`: the veil below picks its
+ * direction at that breakpoint, and a canvas biased for one arrangement under
+ * the veil meant for the other buries the model in the heavy end of a gradient.
+ */
+const WIDE_PX = 1024;
+
+export function HeroBuild({
+ className = "",
+ slug,
+ title,
+ url,
+}: {
+ className?: string;
+ slug: string;
+ title: string;
+ url: string;
+}) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ // biome-ignore lint/suspicious/noUnnecessaryConditions: a ref is null until React attaches it; Biome does not resolve useRef's generic
+ if (!canvas) {
+ return;
+ }
+
+ let scene: HeroScene | null = null;
+ let disposed = false;
+ const parent = canvas.parentElement;
+
+ const size = new ResizeObserver(([entry]) => {
+ const { width, height } = entry.contentRect;
+ const bias = width >= WIDE_PX ? WIDE_BIAS : NARROW_BIAS;
+ scene?.setBias(bias.x, bias.y);
+ scene?.resize(width, height);
+ });
+ // A hero that keeps rendering after it has been scrolled past is a battery
+ // bill for something nobody is looking at.
+ const visible = new IntersectionObserver(([entry]) => {
+ if (entry.isIntersecting) {
+ scene?.start();
+ } else {
+ scene?.stop();
+ }
+ });
+
+ const run = async () => {
+ const { HeroScene: Scene } = await import("@/scene/HeroScene");
+ // The import is a suspension point, so the effect may already have been
+ // cleaned up; the same is true after the model has loaded.
+ if (disposed) {
+ return;
+ }
+ scene = new Scene(canvas);
+ if (parent) {
+ size.observe(parent);
+ const bias = parent.clientWidth >= WIDE_PX ? WIDE_BIAS : NARROW_BIAS;
+ scene.setBias(bias.x, bias.y);
+ scene.resize(parent.clientWidth, parent.clientHeight);
+ }
+
+ await scene.open({ slug, title, url });
+ if (disposed) {
+ return;
+ }
+ visible.observe(canvas);
+ scene.start();
+ };
+
+ run().catch((error: unknown) => {
+ // No WebGL, or a model this deployment has not packed. The header reads
+ // fine as type on its own, so nothing is put in its place.
+ console.warn("[ldraw] hero could not start", error);
+ });
+
+ return () => {
+ disposed = true;
+ size.disconnect();
+ visible.disconnect();
+ scene?.dispose();
+ scene = null;
+ };
+ }, [slug, title, url]);
+
+ return (
+ // Hidden from assistive tech as a whole: the canvas says nothing the
+ // header does not, and it has no control a screen reader could offer.
+
+
+ {/*
+ The type has to stay readable over whatever brick happens to be behind
+ it, and no further: one heading in 60px semibold needs far less cover
+ than a paragraph would, so the veil is gone well before the model is.
+ It runs left to right beside the type and top to bottom above it,
+ matching whichever way the header has arranged the two.
+
+ The far stop is the ground colour at zero alpha rather than
+ `transparent`. They are not the same: `transparent` is transparent
+ black, and a gradient run to it in oklab passes through colours that are
+ neither, which on a near-black header shows up as vertical banding.
+ */}
+
+
+ );
+}
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
index 7e770e4..705c6cc 100644
--- a/src/components/Icon.tsx
+++ b/src/components/Icon.tsx
@@ -196,3 +196,18 @@ export function Save({ className = "h-3.5 w-3.5" }: IconProps) {
);
}
+
+/**
+ * A spinner for waits with no measurable progress.
+ *
+ * Drawn as a ring with a gap rather than a partial arc plus a full circle, so
+ * the thing that turns is the only thing on screen and there is nothing behind
+ * it to alias against at 14px.
+ */
+export function Spinner({ className = "h-3.5 w-3.5" }: IconProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/OpenSetCard.test.tsx b/src/components/OpenSetCard.test.tsx
index 8d2b9cd..56601ac 100644
--- a/src/components/OpenSetCard.test.tsx
+++ b/src/components/OpenSetCard.test.tsx
@@ -38,7 +38,7 @@ function stubApi(setResponse: () => Promise) {
}
const OPEN_BUTTON = /open/i;
-const TAKES_A_MOMENT = /takes a few seconds/i;
+const TAKES_A_MOMENT = /only the first open is slow/i;
const NOT_IN_OMR = /not in the OMR/;
const COULD_NOT_OPEN = /could not be opened/i;
const COULD_NOT_REACH = /could not reach/i;
diff --git a/src/components/OpenSetCard.tsx b/src/components/OpenSetCard.tsx
index 2333c76..5d29ae9 100644
--- a/src/components/OpenSetCard.tsx
+++ b/src/components/OpenSetCard.tsx
@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
-import { Search } from "@/components/Icon";
+import { Search, Spinner } from "@/components/Icon";
import { SetCombobox } from "@/components/SetCombobox";
import { putUpload } from "@/lib/uploadStore";
@@ -20,7 +20,15 @@ interface SetResponse {
setId?: string;
}
-/** The line under the field: the licence note, progress, or what went wrong. */
+/**
+ * The line under the field: the licence note, what is happening, or what went
+ * wrong.
+ *
+ * All three are written to about the same length. The card sits in a grid row
+ * whose height is its own, so a note that grows by a line while a set is being
+ * fetched moves everything under it, and the thing being moved is a button
+ * somebody may be about to press.
+ */
function StatusNote({ status }: { status: Status }) {
if (status.kind === "error") {
return (
@@ -32,12 +40,25 @@ function StatusNote({ status }: { status: Status }) {
return (
{status.kind === "working"
- ? "Fetching every part this set uses. The first time a set is opened takes a few seconds; after that it is immediate."
+ ? "Fetching every part this set uses. Only the first open is slow."
: "Redistributable under CC BY 2.0, credited to whoever built it."}
);
}
+/**
+ * A few sets to press rather than type.
+ *
+ * An empty search field is a question with 1,470 answers, and someone who has
+ * never opened an LDraw file has no reason to know that "10220" is one of them.
+ * These are small enough to pack quickly and famous enough to be worth a click.
+ */
+const SUGGESTIONS: readonly { label: string; setId: string }[] = [
+ { label: "Camper Van", setId: "10220-1" },
+ { label: "Opera House", setId: "21012-1" },
+ { label: "Mini Falcon", setId: "4488-1" },
+];
+
/**
* Open any set from the LDraw Official Model Repository.
*
@@ -47,7 +68,14 @@ function StatusNote({ status }: { status: Status }) {
* still has to be packed before it can load. That packing is what makes the
* first open of a set slow, hence the note the card shows while it works.
*/
-export function OpenSetCard({ className = "" }: { className?: string }) {
+export function OpenSetCard({
+ className = "",
+ featured = false,
+}: {
+ className?: string;
+ /** The front page's headline way in, given the room to look like one. */
+ featured?: boolean;
+}) {
const router = useRouter();
const [value, setValue] = useState("");
const [status, setStatus] = useState({ kind: "idle" });
@@ -96,33 +124,91 @@ export function OpenSetCard({ className = "" }: { className?: string }) {
return (