Skip to content
Merged
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
17 changes: 17 additions & 0 deletions web/src/app/build/breadboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Metadata } from "next";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";

// page.tsx is a client component, and a client component cannot export
// metadata. Without this the guide's browser tab and its link preview were
// the home page's.
export const metadata: Metadata = {
title: "Breadboard Build Guide / Patternflow",
description:
"Build Patternflow with no custom PCB: one snapped-off breadboard power strip and a handful of jumper wires. The guide leads with the picture, because the wiring is the hard part.",
alternates: { canonical: "/build/breadboard" },
openGraph: { ...SITE_OPEN_GRAPH, url: "/build/breadboard" },
};

export default function BreadboardLayout({ children }: { children: React.ReactNode }) {
return children;
}
2 changes: 2 additions & 0 deletions web/src/app/build/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import HomeView from "@/components/HomeView";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";

export const metadata: Metadata = {
title: "Build Your Own — Patternflow",
description:
"Build the open-source Patternflow LED synthesizer from scratch: the hand-soldered v3.9 board, a printed or laser-cut case, and firmware flashed from the browser. Every file and the full build guide.",
alternates: { canonical: "/build" },
openGraph: {
...SITE_OPEN_GRAPH,
title: "Build Your Own — Patternflow",
description:
"Build the open-source Patternflow LED synthesizer from scratch — firmware, PCB, a printed or laser-cut case, and the browser flasher.",
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/contact/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
import ContactPage from "@/components/contact/ContactPage";

export const metadata: Metadata = {
title: "Contact / Patternflow",
description:
"Partnership, installation, distribution, and custom AV inquiries for Patternflow.",
openGraph: { ...SITE_OPEN_GRAPH, url: "/contact" },
};

export default function ContactRoute() {
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/features/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
import shelf from "../editions/Editions.module.css";
import ShelfTabs from "../editions/ShelfTabs";
import styles from "./Features.module.css";
Expand All @@ -26,6 +27,7 @@ export const metadata: Metadata = {
description:
"What a Patternflow panel can do, one feature at a time: a microphone, audio from a browser or phone, OSC, MIDI, a clock, MQTT, sequences, the weather. What each needs, which firmware carries it, and a reel of it running.",
alternates: { canonical: "/features" },
openGraph: { ...SITE_OPEN_GRAPH, url: "/features" },
};

export const dynamic = "force-static";
Expand Down
5 changes: 4 additions & 1 deletion web/src/app/inside/[build]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import HomeView from "@/components/HomeView";
import { builds, buildBySlug } from "@/components/sections/InsideGlobe/builds";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";

const SITE_URL = "https://patternflow.work";

Expand All @@ -26,10 +27,12 @@ export async function generateMetadata({ params }: RouteParams): Promise<Metadat
description: build.description,
alternates: { canonical: `/inside/${build.slug}` },
openGraph: {
...SITE_OPEN_GRAPH,
title,
description: build.description,
url,
images: photo ? [{ url: `${SITE_URL}${photo.src}`, alt: photo.alt }] : undefined,
// The build's own photo when it has one, the site's picture when not.
...(photo ? { images: [{ url: `${SITE_URL}${photo.src}`, alt: photo.alt }] } : {}),
},
};
}
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/inside/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import HomeView from "@/components/HomeView";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";

export const metadata: Metadata = {
title: "Inside Patternflow — How It's Built",
description:
"How Patternflow works: an ESP32-S3 driving a HUB75 RGB LED matrix, four rotary encoders, and a modular C++ pattern architecture — a contemporary reinterpretation of Nam June Paik's Participation TV.",
alternates: { canonical: "/inside" },
openGraph: {
...SITE_OPEN_GRAPH,
title: "Inside Patternflow — How It's Built",
description:
"ESP32-S3, a HUB75 RGB LED matrix, four rotary encoders, and a modular C++ pattern architecture.",
Expand Down
25 changes: 6 additions & 19 deletions web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Analytics } from "@vercel/analytics/next";
import DeviceHostCapture from "@/components/DeviceHostCapture";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { LEGACY_GUIDE_REDIRECT } from "@/components/guide/legacy";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";

const inter = Inter({
variable: "--font-inter",
Expand Down Expand Up @@ -64,27 +65,13 @@ export const metadata: Metadata = {
apple: "/apple-touch-icon.png",
shortcut: "/favicon.ico",
},
openGraph: {
type: "website",
url: "https://patternflow.work",
title: "Patternflow — An LED synthesizer",
description:
"An open-source LED synthesizer. Play light patterns with your fingertips — and every Patternflow plays every pattern we make.",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
type: "image/png",
},
],
},
// No title, description or url: those are each page's (lib/siteMetadata.ts).
// The home page's own are in page.tsx.
openGraph: SITE_OPEN_GRAPH,
// The card only. Next copies the title, description and picture into it
// from the page's openGraph, and it copies nothing over a title set here.
twitter: {
card: "summary_large_image",
title: "Patternflow — An LED synthesizer",
description:
"Patternflow is an open-source LED synthesizer. Play light with your fingertips.",
images: ["/og-image.png"],
},
alternates: {
types: {
Expand Down
89 changes: 89 additions & 0 deletions web/src/app/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import type { Metadata } from "next";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
import { guideMetadata } from "@/components/guide/meta";
import { PAGES, pagePath } from "@/components/guide/pages";
import { builds } from "@/components/sections/InsideGlobe/builds";
import type { GuideScreen } from "@/components/guide/store";

// A link pasted into a chat shows the page's own title, address and a
// picture. Next merges metadata one key deep, so the root layout's openGraph
// and twitter decide what every page without its own carries, and a page that
// sets openGraph keeps the picture only by spreading SITE_OPEN_GRAPH in.

// The pages render nothing here: only their metadata is read.
vi.mock("@/components/HomeView", () => ({ default: () => null }));
vi.mock("next/font/google", () => {
const font = () => ({ variable: "", className: "" });
return { Inter: font, JetBrains_Mono: font, DM_Sans: font, Silkscreen: font, Newsreader: font };
});

type OpenGraph = NonNullable<Metadata["openGraph"]>;

function urlOf(og: OpenGraph): string {
return String(og.url ?? "");
}

describe("site metadata", () => {
it("leaves each page's title and address to the page, in the root layout", async () => {
const { metadata } = await import("@/app/layout");
const og = metadata.openGraph as OpenGraph;
expect(og).toEqual(SITE_OPEN_GRAPH);
expect(og).not.toHaveProperty("title");
expect(og).not.toHaveProperty("url");
// Next copies a page's og:title into twitter:title only when no layout
// set one.
expect(metadata.twitter).toEqual({ card: "summary_large_image" });
});

it.each([
["/", () => import("@/app/page")],
["/pattern", () => import("@/app/pattern/page")],
["/build", () => import("@/app/build/page")],
["/build/breadboard", () => import("@/app/build/breadboard/layout")],
["/inside", () => import("@/app/inside/page")],
["/features", () => import("@/app/features/page")],
["/contact", () => import("@/app/contact/page")],
["/terms", () => import("@/app/terms/page")],
["/update", () => import("@/app/update/page")],
] as const)("%s previews with its own address and the site's picture", async (path, load) => {
const { metadata } = (await load()) as { metadata: Metadata };
const og = metadata.openGraph as OpenGraph;
expect(og.images).toEqual(SITE_OPEN_GRAPH.images);
expect(urlOf(og).replace("https://patternflow.work", "") || "/").toBe(path);
});

it("gives /build/breadboard a title of its own", async () => {
const { metadata } = await import("@/app/build/breadboard/layout");
expect(metadata.title).toBe("Breadboard Build Guide / Patternflow");
});

it("gives every guide page, in both languages, the site's picture", () => {
const screens: GuideScreen[] = ["hub", ...(Object.keys(PAGES) as GuideScreen[])];
for (const page of screens) {
for (const lang of ["en", "ko"] as const) {
const og = guideMetadata(page, lang).openGraph as OpenGraph;
expect(og.images).toEqual(SITE_OPEN_GRAPH.images);
expect(urlOf(og)).toBe(pagePath(page, lang));
}
}
});

it("previews a build on the map with its photo, or the site's picture without one", async () => {
const { generateMetadata } = await import("@/app/inside/[build]/page");
const ogOf = async (slug: string) =>
(await generateMetadata({ params: Promise.resolve({ build: slug }) })).openGraph as OpenGraph;

const withPhoto = builds.find((b) => b.images?.length);
expect(withPhoto).toBeDefined();
expect(await ogOf(withPhoto!.slug)).toMatchObject({
images: [{ url: `https://patternflow.work${withPhoto!.images![0].src}` }],
url: `https://patternflow.work/inside/${withPhoto!.slug}`,
});

const withoutPhoto = builds.find((b) => !b.images?.length);
if (withoutPhoto) {
expect((await ogOf(withoutPhoto.slug)).images).toEqual(SITE_OPEN_GRAPH.images);
}
});
});
19 changes: 19 additions & 0 deletions web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
import type { Metadata } from "next";
import HomeView from "@/components/HomeView";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";

// The <title> and description are the root layout's. The preview is here,
// not there, so that a page without one does not borrow the home page's.
export const metadata: Metadata = {
openGraph: {
...SITE_OPEN_GRAPH,
url: "https://patternflow.work",
title: "Patternflow — An LED synthesizer",
description:
"An open-source LED synthesizer. Play light patterns with your fingertips — and every Patternflow plays every pattern we make.",
},
twitter: {
card: "summary_large_image",
description:
"Patternflow is an open-source LED synthesizer. Play light with your fingertips.",
},
};

export default function Home() {
return <HomeView initialTab="hero" />;
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/pattern/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import HomeView from "@/components/HomeView";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";

export const metadata: Metadata = {
title: "Live Editor & Patterns — Patternflow",
description:
"Browse a preset library of LED matrix patterns and remix them in the browser Live Editor. Generate your own with AI, preview on virtual knobs, and flash to the device.",
alternates: { canonical: "/pattern" },
openGraph: {
...SITE_OPEN_GRAPH,
title: "Live Editor & Patterns — Patternflow",
description:
"Browse and remix LED matrix patterns in the browser, generate your own with AI, and flash them to the device.",
Expand Down
3 changes: 3 additions & 0 deletions web/src/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${siteUrl}/`, lastModified: now, changeFrequency: "weekly", priority: 1 },
{ url: `${siteUrl}/pattern`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
{ url: `${siteUrl}/build`, lastModified: now, changeFrequency: "monthly", priority: 0.8 },
{ url: `${siteUrl}/build/breadboard`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
{ url: `${siteUrl}/inside`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
{ url: `${siteUrl}/roadmap`, lastModified: now, changeFrequency: "weekly", priority: 0.6 },
// The guide: the hub, then its four guides in order — Build (soldering
Expand All @@ -33,6 +34,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${siteUrl}/editions`, lastModified: now, changeFrequency: "monthly", priority: 0.6 },
// The catalogue of features, with a reel of each - the other axis of the shelf.
{ url: `${siteUrl}/features`, lastModified: now, changeFrequency: "monthly", priority: 0.6 },
// Where a device's console sends you when a newer firmware exists.
{ url: `${siteUrl}/update`, lastModified: now, changeFrequency: "monthly", priority: 0.4 },
{ url: `${siteUrl}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.5 },
{ url: `${siteUrl}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 },
// Printed on the safety leaflet in every box as the address of the EU
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
import styles from "./Terms.module.css";

// Terms of use + privacy notice, deliberately as one page. Two documents means
Expand All @@ -15,6 +16,7 @@ export const metadata: Metadata = {
title: "Terms & Privacy / Patternflow",
description:
"Terms of use and privacy notice for the Patternflow community: who owns what, what we do with your patterns, how to report something, and what data we keep.",
openGraph: { ...SITE_OPEN_GRAPH, url: "/terms" },
};

const UPDATED = "24 September 2026";
Expand Down
2 changes: 2 additions & 0 deletions web/src/app/update/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
import UpdateClient from "./UpdateClient";

// /update — where a device's console sends you when a newer firmware exists.
Expand All @@ -11,6 +12,7 @@ export const metadata: Metadata = {
title: "Firmware update / Patternflow",
description:
"Update your Patternflow over Wi-Fi. Your patterns, Wi-Fi networks and storage are untouched — an update rewrites the program only.",
openGraph: { ...SITE_OPEN_GRAPH, url: "/update" },
};

export default function UpdatePage() {
Expand Down
12 changes: 10 additions & 2 deletions web/src/components/guide/meta.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { Metadata } from "next";
import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata";
import { PAGES, pagePath } from "./pages";
import { HUB_COPY } from "./copy/hub";
import type { GuideLang, GuideScreen } from "./store";

/**
* A guide page's metadata (app/guide/**): its title and description in the
* page's language, its own address as canonical, and the other language's as
* an alternate — English is the x-default.
* an alternate — English is the x-default. Its link preview carries the
* site's picture (lib/siteMetadata.ts).
*/
export function guideMetadata(page: GuideScreen, lang: GuideLang): Metadata {
const meta = page === "hub" ? HUB_COPY[lang].meta : PAGES[page].text(lang).meta;
Expand All @@ -17,6 +19,12 @@ export function guideMetadata(page: GuideScreen, lang: GuideLang): Metadata {
title: `${meta.title} / Patternflow`,
description: meta.description,
alternates: { canonical: url, languages: { en, ko, "x-default": en } },
openGraph: { title: meta.title, description: meta.description, url, ...(lang === "ko" ? { locale: "ko_KR" } : {}) },
openGraph: {
...SITE_OPEN_GRAPH,
title: meta.title,
description: meta.description,
url,
...(lang === "ko" ? { locale: "ko_KR" } : {}),
},
};
}
27 changes: 27 additions & 0 deletions web/src/lib/siteMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Metadata } from "next";

// What a link to any page carries when it is pasted into a chat or a post.
//
// Next merges metadata one key deep: a page that sets openGraph replaces the
// root layout's openGraph whole, picture and all ("Overwriting fields" in
// node_modules/next/dist/docs/01-app/03-api-reference/04-functions/
// generate-metadata.md). So a page that names its own preview spreads this in
// first. A page that sets no openGraph inherits this as it is, and Next fills
// og:title and og:description from the page's own title and description.
//
// The root layout's openGraph is this and nothing more. Until 16 October
// 2026 it also carried the home page's title and address, and /features,
// /editions, /roadmap and every other page that sets no openGraph previewed
// as the home page.
export const SITE_OPEN_GRAPH = {
type: "website",
siteName: "Patternflow",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
type: "image/png",
},
],
} satisfies NonNullable<Metadata["openGraph"]>;
Loading