From 1afff5b094fdaba914b8be8e311d21a40ec25e9f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:33:24 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20Patternflo?= =?UTF-8?q?w=202=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb From 2e47366ee6698fbcfbb0b8670f04fa9e0a8bf18b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:39:24 +0000 Subject: [PATCH 2/4] web: give every page its own link preview A page that set openGraph lost the root's picture, and a page that set none previewed as the home page; twitter:title was the home title on all. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb --- web/src/app/build/breadboard/layout.tsx | 17 +++++ web/src/app/build/page.tsx | 2 + web/src/app/contact/page.tsx | 2 + web/src/app/features/page.tsx | 2 + web/src/app/inside/[build]/page.tsx | 5 +- web/src/app/inside/page.tsx | 2 + web/src/app/layout.tsx | 25 ++----- web/src/app/page.tsx | 19 ++++++ web/src/app/pattern/page.tsx | 2 + web/src/app/sitemap.ts | 3 + web/src/app/terms/page.tsx | 2 + web/src/app/update/page.tsx | 2 + web/src/components/guide/meta.ts | 12 +++- web/src/lib/siteMetadata.test.ts | 89 +++++++++++++++++++++++++ web/src/lib/siteMetadata.ts | 26 ++++++++ 15 files changed, 188 insertions(+), 22 deletions(-) create mode 100644 web/src/app/build/breadboard/layout.tsx create mode 100644 web/src/lib/siteMetadata.test.ts create mode 100644 web/src/lib/siteMetadata.ts diff --git a/web/src/app/build/breadboard/layout.tsx b/web/src/app/build/breadboard/layout.tsx new file mode 100644 index 00000000..366a6234 --- /dev/null +++ b/web/src/app/build/breadboard/layout.tsx @@ -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 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; +} diff --git a/web/src/app/build/page.tsx b/web/src/app/build/page.tsx index 145bd206..be5bbfe0 100644 --- a/web/src/app/build/page.tsx +++ b/web/src/app/build/page.tsx @@ -1,5 +1,6 @@ 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", @@ -7,6 +8,7 @@ export const metadata: Metadata = { "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.", diff --git a/web/src/app/contact/page.tsx b/web/src/app/contact/page.tsx index 7edfc999..eada130d 100644 --- a/web/src/app/contact/page.tsx +++ b/web/src/app/contact/page.tsx @@ -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() { diff --git a/web/src/app/features/page.tsx b/web/src/app/features/page.tsx index e1c54df0..c86c2f66 100644 --- a/web/src/app/features/page.tsx +++ b/web/src/app/features/page.tsx @@ -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"; @@ -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"; diff --git a/web/src/app/inside/[build]/page.tsx b/web/src/app/inside/[build]/page.tsx index 1e35b407..1159c763 100644 --- a/web/src/app/inside/[build]/page.tsx +++ b/web/src/app/inside/[build]/page.tsx @@ -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"; @@ -26,10 +27,12 @@ export async function generateMetadata({ params }: RouteParams): Promise 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 ; diff --git a/web/src/app/pattern/page.tsx b/web/src/app/pattern/page.tsx index 4db6b2fa..d449a298 100644 --- a/web/src/app/pattern/page.tsx +++ b/web/src/app/pattern/page.tsx @@ -1,5 +1,6 @@ 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", @@ -7,6 +8,7 @@ export const metadata: Metadata = { "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.", diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index 5ff658f3..41d7c788 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -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 @@ -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 diff --git a/web/src/app/terms/page.tsx b/web/src/app/terms/page.tsx index 8f7bd5f7..cd8df20f 100644 --- a/web/src/app/terms/page.tsx +++ b/web/src/app/terms/page.tsx @@ -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 @@ -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"; diff --git a/web/src/app/update/page.tsx b/web/src/app/update/page.tsx index d3cf1a39..b6e66b27 100644 --- a/web/src/app/update/page.tsx +++ b/web/src/app/update/page.tsx @@ -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. @@ -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() { diff --git a/web/src/components/guide/meta.ts b/web/src/components/guide/meta.ts index 8e9e4c71..6b32930a 100644 --- a/web/src/components/guide/meta.ts +++ b/web/src/components/guide/meta.ts @@ -1,4 +1,5 @@ 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"; @@ -6,7 +7,8 @@ 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; @@ -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" } : {}), + }, }; } diff --git a/web/src/lib/siteMetadata.test.ts b/web/src/lib/siteMetadata.test.ts new file mode 100644 index 00000000..9fd60713 --- /dev/null +++ b/web/src/lib/siteMetadata.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "./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; + +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); + } + }); +}); diff --git a/web/src/lib/siteMetadata.ts b/web/src/lib/siteMetadata.ts new file mode 100644 index 00000000..2e6855ed --- /dev/null +++ b/web/src/lib/siteMetadata.ts @@ -0,0 +1,26 @@ +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 keeps title and url out of its openGraph for the same +// reason. Until 16 October 2026 it carried the home page's, and /features, +// /editions, /roadmap and every page like them 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; From 45e1200108b0942008f29b0862cf5a7f2239d662 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:43:22 +0000 Subject: [PATCH 3/4] web: keep the link preview test in app/, where it may import pages Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb --- web/src/{lib/siteMetadata.test.ts => app/metadata.test.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename web/src/{lib/siteMetadata.test.ts => app/metadata.test.ts} (98%) diff --git a/web/src/lib/siteMetadata.test.ts b/web/src/app/metadata.test.ts similarity index 98% rename from web/src/lib/siteMetadata.test.ts rename to web/src/app/metadata.test.ts index 9fd60713..e35e89b1 100644 --- a/web/src/lib/siteMetadata.test.ts +++ b/web/src/app/metadata.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Metadata } from "next"; -import { SITE_OPEN_GRAPH } from "./siteMetadata"; +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"; From 405b1ddc0d1463e0c1ab14fae40b02ba515c2dc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 15:44:36 +0000 Subject: [PATCH 4/4] web: say plainly what the root layout's preview used to carry Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XcyiJssExyAsixL6TfR9mb --- web/src/app/build/breadboard/layout.tsx | 4 ++-- web/src/lib/siteMetadata.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/web/src/app/build/breadboard/layout.tsx b/web/src/app/build/breadboard/layout.tsx index 366a6234..80abfca9 100644 --- a/web/src/app/build/breadboard/layout.tsx +++ b/web/src/app/build/breadboard/layout.tsx @@ -2,8 +2,8 @@ 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 tab and its link preview were the home -// page's. +// 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: diff --git a/web/src/lib/siteMetadata.ts b/web/src/lib/siteMetadata.ts index 2e6855ed..5e301a07 100644 --- a/web/src/lib/siteMetadata.ts +++ b/web/src/lib/siteMetadata.ts @@ -9,9 +9,10 @@ import type { Metadata } from "next"; // 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 keeps title and url out of its openGraph for the same -// reason. Until 16 October 2026 it carried the home page's, and /features, -// /editions, /roadmap and every page like them previewed as the home page. +// 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",