From d643ab8b5260dcfdae3a3d54ead79c67db767c77 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 28 Aug 2026 12:27:50 +0200 Subject: [PATCH] Build a model on the front page instead of describing one The gallery opened with three paragraphs claiming a model would assemble itself, and six cards of equal weight underneath: the bundled models, free build, the set search and the drop target all read as the same offer. The header is now the demonstration. A canvas behind the type loads the real Example Car through the same loader the builder uses and assembles it in the eight steps its author wrote, holds it, lifts it apart and starts again. Drag turns it. It is a packed LDraw file rather than a hand-made stand-in, because a hero that fakes the product is the one thing a hero must not do, and it costs 44ms: the model is 61 bricks and the import happens after mount. The copy above it is down to one heading, since the model says the rest. Underneath, the ways in are ranked. Search leads, at three fifths of the row with room for three sets to press rather than type, because it reaches all 1,470 official sets where everything below it reaches one. Free build sits beside it as the same decision made the other way. Bringing your own file became a strip rather than a tile: it is a real way in, but the only one that assumes you already have an .ldr on disk. The bundled models follow. Also in here, from the same pass over the page: - Icon-only buttons are square. The theme toggle was 4px wider than it was tall everywhere it appeared. `.hud-square` sizes both axes from one line of type plus the padding, so it matches the labelled buttons beside it. - Opening a set shows a spinner and an indeterminate bar, and neither the bar nor the note that goes with it moves anything: the suggestions are hidden rather than unmounted so they hold the row, and the working note is written to the length of the licence one. Packing a cold set is seconds of nothing visible happening. - The back link out of the builder has a 112x32 target instead of 72x12, with the padding cancelled by margin so nothing moved. - The OG card carries the page's heading and one line, not a paragraph and a feature list. - The favicon is the wordmark folded into a 2x2. The 2x2 brick it replaces was a drawing of a brick, and studs and highlights are mush at 16px. - A footer credit. --- src/app/globals.css | 50 +++ src/app/icon.svg | 25 +- src/app/opengraph-image.tsx | 30 +- src/app/page.tsx | 223 +++++++---- src/components/DropZone.tsx | 35 +- src/components/HeroBuild.tsx | 135 +++++++ src/components/Icon.tsx | 15 + src/components/OpenSetCard.test.tsx | 2 +- src/components/OpenSetCard.tsx | 106 ++++- src/components/SetCombobox.tsx | 9 +- src/components/ThemeToggle.tsx | 2 +- src/components/free/FreeBuildCard.tsx | 10 +- src/components/free/FreeBuildClient.tsx | 7 +- src/components/shell/LegalFooter.tsx | 10 + src/components/viewer/BuilderClient.tsx | 11 +- src/scene/HeroScene.ts | 505 ++++++++++++++++++++++++ 16 files changed, 1028 insertions(+), 147 deletions(-) create mode 100644 src/components/HeroBuild.tsx create mode 100644 src/scene/HeroScene.ts 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 @@ LDraw Builder - - - - - - + + + + + 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.

); diff --git a/src/components/DropZone.tsx b/src/components/DropZone.tsx index 4332750..0c4e2d1 100644 --- a/src/components/DropZone.tsx +++ b/src/components/DropZone.tsx @@ -142,29 +142,30 @@ export function DropZone({ className = "" }: { className?: string }) { const working = status.kind === "working"; return ( -
+
+ {/* + 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. +