Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ playwright-report/
.agents/
.claude/
.windsurf/

# Deterministic model-specific Next.js route entries generated before dev/build.
/src/app/(generated-variant-routes)/
39 changes: 38 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,45 @@ import type { NextConfig } from "next";

const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1"],
poweredByHeader: false,
images: {
unoptimized: true,
deviceSizes: [640, 750, 828, 1080, 1200, 1600, 1920],
imageSizes: [32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 86_400,
qualities: [70, 75],
remotePatterns: [
{
protocol: "https",
hostname: "picsum.photos",
},
],
},
experimental: {
optimizePackageImports: ["@phosphor-icons/react"],
},
async headers() {
return [
{
// Compare output is deterministic for the full URL and contains no user data.
// Cache it at Vercel's edge while keeping the browser response revalidated.
source: "/compare",
headers: [
{
key: "Vercel-CDN-Cache-Control",
value: "public, s-maxage=31536000, stale-while-revalidate=86400",
},
],
},
...["/gallery-previews/:path*", "/variants/:path*"].map((source) => ({
source,
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
],
})),
];
},
};

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"predev": "node scripts/scope-variant-css.mjs",
"prebuild": "node scripts/scope-variant-css.mjs",
"predev": "node scripts/generate-variant-routes.mjs && node scripts/scope-variant-css.mjs",
"prebuild": "node scripts/generate-variant-routes.mjs && node scripts/scope-variant-css.mjs",
"start": "next start",
"lint": "eslint",
"capture-previews": "node scripts/capture-previews.mjs",
"scope:variant-css": "node scripts/scope-variant-css.mjs",
"generate:variant-routes": "node scripts/generate-variant-routes.mjs",
"test:routes": "playwright test tests/gallery-routes.spec.ts",
"test:visual": "playwright test tests/gallery-visual-smoke.spec.ts"
},
Expand Down
92 changes: 92 additions & 0 deletions scripts/generate-variant-routes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { readdir, rm, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const variantsRoot = path.join(projectRoot, "src", "variants");
const appRoot = path.join(projectRoot, "src", "app");
const outputRoot = path.join(appRoot, "(generated-variant-routes)");
const nextRoot = path.join(projectRoot, ".next");

if (path.dirname(outputRoot) !== appRoot) {
throw new Error(`Refusing to generate routes outside the app directory: ${outputRoot}`);
}

const iterationIds = ["1", "2", "3", "4", "5"];

function pageSource({ group, model, preview }) {
const componentName = preview ? "PreviewIterationPage" : "IterationPage";
return `import "./variant-tailwind.css";
import { GalleryIterationView } from "@/components/gallery/gallery-iteration-view";
import variantModule from "@/variants/${group}/${model}";

export const dynamicParams = false;

export function generateStaticParams() {
return ${JSON.stringify(iterationIds)}.map((iteration) => ({ iteration }));
}

export default async function ${componentName}({
params,
}: {
params: Promise<{ iteration: string }>;
}) {
const { iteration } = await params;
return (
<GalleryIterationView
group=${JSON.stringify(group)}
model=${JSON.stringify(model)}
iteration={iteration}
preview={${preview}}
variantModule={variantModule}
/>
);
}
`;
}

await rm(outputRoot, { recursive: true, force: true });
// Route type validators are incremental and can retain imports for the two
// generic routes this generator replaces.
await Promise.all(
[path.join(nextRoot, "types"), path.join(nextRoot, "dev", "types")].map((target) =>
rm(target, { recursive: true, force: true }),
),
);

let generatedPages = 0;
const groups = await readdir(variantsRoot, { withFileTypes: true });
for (const groupEntry of groups) {
if (!groupEntry.isDirectory()) continue;
const group = groupEntry.name;
const models = await readdir(path.join(variantsRoot, group), { withFileTypes: true });

for (const modelEntry of models) {
if (!modelEntry.isDirectory()) continue;
const model = modelEntry.name;
const variantDir = path.join(variantsRoot, group, model);
const files = await readdir(variantDir);
if (!files.includes("index.tsx")) continue;

for (const preview of [false, true]) {
const routeDir = preview
? path.join(outputRoot, "preview", group, model, "[iteration]")
: path.join(outputRoot, group, model, "[iteration]");
await mkdir(routeDir, { recursive: true });
await writeFile(
path.join(routeDir, "page.tsx"),
pageSource({ group, model, preview }),
"utf8",
);
const relativeVariantDir = path.relative(routeDir, variantDir).split(path.sep).join("/");
await writeFile(
path.join(routeDir, "variant-tailwind.css"),
`@layer theme, utilities;\n@import "tailwindcss/theme.css" layer(theme);\n@import "tailwindcss/utilities.css" layer(utilities) source(none);\n@source "${relativeVariantDir}";\n`,
"utf8",
);
generatedPages += 1;
}
}
}

console.log(`Generated ${generatedPages} model-specific route modules in ${path.relative(projectRoot, outputRoot)}`);
17 changes: 0 additions & 17 deletions src/app/[group]/[model]/[iteration]/page.tsx

This file was deleted.

7 changes: 6 additions & 1 deletion src/app/[group]/[model]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { GalleryBreadcrumb, IterationLinks } from "@/components/gallery/gallery-shell";
import { buildCompareHrefForSelection } from "@/lib/compare";
import { buildCompareHrefForSelection } from "@/lib/compare-server";
import { getGalleryEntry } from "@/lib/gallery-manifest";
import { getStaticGalleryModelParams } from "@/lib/gallery-static-params";
import { buildVariantHref, isGalleryGroup } from "@/lib/gallery-paths";
Expand Down Expand Up @@ -48,6 +48,7 @@ export default async function ModelPage({
model: entry.model,
iteration: "1",
})}
prefetch={false}
className="inline-flex items-center rounded-full border border-[var(--gallery-divider-strong)] bg-[var(--gallery-surface)] px-4 py-2 text-sm font-medium text-[var(--gallery-text-secondary)] transition-colors hover:border-[var(--gallery-text-quaternary)] hover:text-[var(--gallery-text-primary)]"
aria-label="Compare this model"
>
Expand All @@ -69,6 +70,9 @@ export default async function ModelPage({
src={iteration.thumbnailPath}
alt={`${entry.modelLabel} iteration ${iteration.id}`}
fill
sizes="(max-width: 639px) calc(100vw - 2rem), (max-width: 1279px) calc(50vw - 2.5rem), 24rem"
quality={70}
preload={iteration.id === entry.defaultIteration}
className="object-cover"
/>
</div>
Expand All @@ -94,6 +98,7 @@ export default async function ModelPage({
model: entry.model,
iteration: iteration.id,
})}
prefetch={false}
className="inline-flex text-sm font-medium text-[var(--gallery-text-secondary)] underline decoration-[var(--gallery-divider-strong)] underline-offset-4 transition-colors hover:text-[var(--gallery-text-primary)] hover:decoration-[var(--gallery-text-primary)]"
aria-label={`Compare ${entry.modelLabel} iteration ${iteration.id}`}
>
Expand Down
12 changes: 10 additions & 2 deletions src/app/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { notFound, redirect } from "next/navigation";
import { ComparePage } from "@/components/compare/compare-page";
import { buildCompareHref, DEFAULT_COMPARE_STATE, parseCompareSearchParams } from "@/lib/compare";
import { GalleryRankingsNav } from "@/components/gallery/gallery-rankings-nav";
import { buildCompareHref, DEFAULT_COMPARE_STATE } from "@/lib/compare";
import { parseCompareSearchParams } from "@/lib/compare-server";
import { galleryCatalog } from "@/lib/gallery-catalog";

export default async function CompareRoutePage({
searchParams,
Expand All @@ -18,5 +21,10 @@ export default async function CompareRoutePage({
notFound();
}

return <ComparePage initialState={compareState} />;
return (
<>
<GalleryRankingsNav current="compare" />
<ComparePage initialState={compareState} catalog={galleryCatalog} />
</>
);
}
4 changes: 2 additions & 2 deletions src/app/experiments/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ const iterations = [
export default function ExperimentsPage() {
return (
<>
<GalleryRankingsNav />
<GalleryRankingsNav current="experiments" />
<main className="mx-auto max-w-6xl px-5 py-16 sm:px-8 sm:py-20">
<header className="max-w-3xl">
<p className="text-sm font-medium text-[var(--gallery-accent)]">Exploration · not implemented yet</p>
Expand All @@ -208,7 +208,7 @@ export default function ExperimentsPage() {
</p>
<p className="mt-4 text-sm text-neutral-500">
Back to the{" "}
<Link href="/" className="font-medium text-neutral-800 underline decoration-neutral-300 underline-offset-2 hover:text-[var(--gallery-accent)]">
<Link href="/" prefetch={false} className="font-medium text-neutral-800 underline decoration-neutral-300 underline-offset-2 hover:text-[var(--gallery-accent)]">
main gallery
</Link>
.
Expand Down
25 changes: 24 additions & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
@import "tailwindcss";
@import "tailwindcss" source(none);
@source ".";
@source "../components";

@custom-variant dark (&:where(.dark, .dark *));

Expand Down Expand Up @@ -111,6 +113,10 @@ body {

.gallery-card-shell {
border-color: #d4d4d4;
/* Render long gallery grids incrementally instead of activating a whole
section in one main-thread task as it enters the viewport. */
content-visibility: auto;
contain-intrinsic-size: auto 28rem;
}

.gallery-card-shell:hover {
Expand Down Expand Up @@ -403,6 +409,23 @@ html {
animation: spin-slow 28s linear infinite;
}

@keyframes gallery-toast-enter {
from {
opacity: 0;
translate: 0 0.35rem;
scale: 0.97;
}
to {
opacity: 1;
translate: 0 0;
scale: 1;
}
}

.gallery-toast-enter {
animation: gallery-toast-enter 160ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
Expand Down
5 changes: 3 additions & 2 deletions src/app/lab-guess/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { GalleryRankingsNav } from "@/components/gallery/gallery-rankings-nav";
import { ModelLabWordle } from "@/components/game/model-lab-wordle";
import { galleryCatalog } from "@/lib/gallery-catalog";

export default function LabGuessPage() {
return (
<>
<GalleryRankingsNav />
<ModelLabWordle />
<GalleryRankingsNav current="lab-guess" />
<ModelLabWordle catalog={galleryCatalog} />
</>
);
}
5 changes: 1 addition & 4 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { GeistSans } from "geist/font/sans";
import { GalleryThemeProvider } from "@/components/gallery/gallery-theme-provider";
import { galleryThemeInitScript } from "@/lib/gallery-theme";
import "./globals.css";

Expand All @@ -20,9 +19,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<head>
<script dangerouslySetInnerHTML={{ __html: galleryThemeInitScript }} />
</head>
<body>
<GalleryThemeProvider>{children}</GalleryThemeProvider>
</body>
<body>{children}</body>
</html>
);
}
11 changes: 8 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function HomePage() {

return (
<>
<GalleryRankingsNav />
<GalleryRankingsNav current="gallery" />
<main className="mx-auto max-w-[98rem] px-4 py-16 sm:px-6 sm:py-20 lg:px-4">
<header className="max-w-2xl">
<h1 className="text-3xl font-medium tracking-tight text-[var(--gallery-text-primary)] sm:text-4xl">
Expand Down Expand Up @@ -73,12 +73,17 @@ export default function HomePage() {
</header>

<div className="mt-10 space-y-12">
{groups.map((group) => {
{groups.map((group, groupIndex) => {
const entries = sortGalleryEntriesForHome(
galleryManifest.filter((entry) => entry.group === group),
);
return (
<GalleryGroupSection key={group} group={group} entries={entries} />
<GalleryGroupSection
key={group}
group={group}
entries={entries}
preloadFirstImage={groupIndex === 0}
/>
);
})}
</div>
Expand Down
17 changes: 0 additions & 17 deletions src/app/preview/[group]/[model]/[iteration]/page.tsx

This file was deleted.

Loading