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. +