diff --git a/gallery/app/icon.ts b/gallery/app/icon.ts
index 676e391b4..65dbb2f92 100644
--- a/gallery/app/icon.ts
+++ b/gallery/app/icon.ts
@@ -1,9 +1,14 @@
-// app/icon.ts serves /icon (the dynamic favicon). The default export is a
+// app/icon.ts serves /icon (a dynamic favicon). The default export is a
// (possibly async) server function; returning a Response lets you set the exact
-// content type, so an inline SVG needs no asset file. For a favicon that never
-// changes, put a static file in public/ instead (e.g. public/favicon.ico) and
-// delete this route. Generate it dynamically (per-theme, per-tenant) when the
-// mark must be computed at request time.
+// content type, so an inline SVG needs no asset file. Generate it dynamically
+// (per-theme, per-tenant) when the mark must be computed at request time.
+//
+// This is the DEMO of that surface, not the gallery's own favicon. A metadata
+// route is not auto-linked: the framework emits ` ` only from
+// metadata.icons, so the gallery declares the static WebJs brand mark from
+// public/ there (see app/layout.ts) and this route stays browsable at /icon.
+// For a favicon that never changes, that static path is the one to copy; drop
+// this route when your app has no request-time mark to compute.
export default function Icon() {
const svg = `
diff --git a/gallery/app/layout.ts b/gallery/app/layout.ts
index 7e38dff90..d049da73f 100644
--- a/gallery/app/layout.ts
+++ b/gallery/app/layout.ts
@@ -4,13 +4,25 @@ import '#components/theme-toggle.ts';
export const metadata = {
title: 'WebJs Gallery',
description: 'Interactive showcase and single-concept feature gallery for WebJs applications.',
- // app/icon.ts SERVES the favicon at /icon, but a metadata route is not
- // auto-linked: the framework emits ` ` only from
- // metadata.icons. Without this the head pointed at nothing and the browser
- // fell back to /favicon.ico, which the gallery does not ship, so the tab
- // showed no mark at all. Declare it here, never as a hand-written ,
- // so the URL stays in one place.
- icons: { icon: { url: '/icon', type: 'image/svg+xml', sizes: 'any' } },
+ // The WebJs brand mark, byte-identical to what webjs.dev serves, so the
+ // gallery reads as the same product in a tab strip rather than as a
+ // separate site. Declared here and never as a hand-written : the
+ // framework emits ` ` only from metadata.icons, which is
+ // also what the scaffold's generated layout does.
+ //
+ // Raster is declared FIRST on purpose. Google's favicon crawler takes the
+ // first usable icon and wants a square raster whose side is a multiple of
+ // 48px, which is why the 192 exists (512 % 48 is 32, so the full-size mark
+ // does not qualify). public/favicon.ico rides along unlinked: the framework
+ // serves it at the origin root as the fallback for crawlers that read no
+ // markup at all.
+ icons: {
+ icon: [
+ { url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
+ { url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
+ ],
+ apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
+ },
};
export default function RootLayout({ children }: { children: unknown }) {
diff --git a/gallery/public/apple-touch-icon.png b/gallery/public/apple-touch-icon.png
new file mode 100644
index 000000000..eb675e1ba
Binary files /dev/null and b/gallery/public/apple-touch-icon.png differ
diff --git a/gallery/public/favicon-192.png b/gallery/public/favicon-192.png
new file mode 100644
index 000000000..34869b7c0
Binary files /dev/null and b/gallery/public/favicon-192.png differ
diff --git a/gallery/public/favicon.ico b/gallery/public/favicon.ico
new file mode 100644
index 000000000..90e21a7a6
Binary files /dev/null and b/gallery/public/favicon.ico differ
diff --git a/gallery/public/favicon.svg b/gallery/public/favicon.svg
new file mode 100644
index 000000000..89ee6a6d1
--- /dev/null
+++ b/gallery/public/favicon.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gallery/test/seo/favicon.test.ts b/gallery/test/seo/favicon.test.ts
deleted file mode 100644
index a908efcd8..000000000
--- a/gallery/test/seo/favicon.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { test } from 'node:test';
-import assert from 'node:assert/strict';
-import { fileURLToPath } from 'node:url';
-import { dirname, resolve } from 'node:path';
-
-import { createRequestHandler } from '@webjsdev/server';
-import { testRequest } from '@webjsdev/server/testing';
-
-const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
-
-/**
- * The gallery served its favicon and linked it nowhere.
- *
- * `app/icon.ts` answered /icon with a real SVG the whole time, but a metadata
- * ROUTE is not auto-linked: the framework emits ` ` only from
- * `metadata.icons` (packages/server/src/ssr.js). The gallery's root layout
- * declared only a title and a description, so the head pointed at nothing, the
- * browser fell back to /favicon.ico, and the gallery ships no such file. Tab
- * showed no mark on gallery.webjs.dev.
- *
- * Each half alone stayed green through that, which is why this asserts BOTH
- * and, critically, that the href the head declares is the URL that answers.
- */
-
-function makeHandler() {
- return createRequestHandler({ appDir, dev: true });
-}
-
-test('the home page head declares a favicon', async () => {
- const app = await makeHandler();
- const res = await testRequest(app.handle, '/');
- assert.equal(res.status, 200, 'the home page renders');
-
- const head = (await res.text()).split('')[0];
- const link = head.match(/ ]*>/);
- assert.ok(link, 'the head emits a ');
- // A favicon in is ignored by browsers, so landing in the head
- // is the assertion, not merely appearing in the document.
- assert.match(link[0], /href="[^"]*\/icon"/, 'it points at the /icon route');
-});
-
-test('the declared favicon URL actually serves an image', async () => {
- const app = await makeHandler();
- const res = await testRequest(app.handle, '/icon');
- assert.equal(res.status, 200, '/icon answers');
- assert.match(
- res.headers.get('content-type') ?? '',
- /^image\//,
- 'served as an image, so a browser renders it rather than downloading markup',
- );
-});
-
-test('the head links no favicon the app does not serve', async () => {
- // The failure mode this whole file exists for is a head that names a URL
- // nothing answers. Resolve every icon href the layout emits, so a later edit
- // pointing at /public/favicon.svg (which the gallery has never shipped)
- // fails here instead of on a live tab.
- const app = await makeHandler();
- const head = (await (await testRequest(app.handle, '/')).text()).split('')[0];
-
- // One pattern covering every rel the framework emits from metadata.icons:
- // "icon", "shortcut icon" and "apple-touch-icon" all carry `icon` in the rel.
- const hrefs = [...head.matchAll(/ ]*href="([^"]+)"/g)].map((m) => m[1]);
- assert.ok(hrefs.length > 0, 'at least one icon is declared');
-
- for (const href of new Set(hrefs)) {
- // absUrl() may have made it absolute; the handler routes on the path.
- const path = href.startsWith('http') ? new URL(href).pathname : href;
- const res = await testRequest(app.handle, path);
- assert.equal(res.status, 200, `${href} is served, not a 404`);
- }
-});
diff --git a/test/repo-health/gallery-favicon.test.mjs b/test/repo-health/gallery-favicon.test.mjs
new file mode 100644
index 000000000..4e35320e8
--- /dev/null
+++ b/test/repo-health/gallery-favicon.test.mjs
@@ -0,0 +1,119 @@
+/**
+ * The gallery's favicon: linked, served, and the real brand mark.
+ *
+ * gallery.webjs.dev rendered with a blank tab. `gallery/app/icon.ts` answered
+ * /icon the whole time, but a metadata ROUTE is not auto-linked: the framework
+ * emits ` ` only from `metadata.icons`
+ * (packages/server/src/ssr.js), and the gallery's root layout declared only a
+ * title and a description. So the head named no icon, the browser fell back to
+ * /favicon.ico, and the gallery shipped no such file. Linking /icon then fixed
+ * the blank tab with the DEMO route's placeholder grey "w" rather than the
+ * WebJs mark webjs.dev serves, so the gallery read as a different product in a
+ * tab strip.
+ *
+ * Three independent things have to hold and each stayed green while another was
+ * broken, so all three are asserted: the head must LINK an icon, every URL it
+ * links must be SERVED, and the bytes must be the BRAND mark. The last is why
+ * the middle is not enough, since a linked-and-served placeholder is
+ * indistinguishable from the real thing to the request pipeline.
+ *
+ * This lives in the REPO suite rather than in `gallery/test/`, for two reasons.
+ * It is a cross-app assertion (it reads `website/public/`, which exists only
+ * here), and `gallery/test/**` is scaffold PAYLOAD: `copyGallery()` copies it
+ * into every generated app, where a `website/` to compare against never exists
+ * and where a stray directory also defeats `gallery:clear`'s prune of an empty
+ * `test/` (asserted by test/scaffolds/scaffold-gallery.test.js).
+ *
+ * Companion to site-seo-tags.test.mjs, which covers the website's own icons.
+ * That one asserts hand-written ` ` markup because the website writes its
+ * icons that way; this renders the page instead, because the gallery declares
+ * them through metadata.icons and there is no markup in the layout to read.
+ */
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { resolve, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { createRequestHandler } from '@webjsdev/server';
+
+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
+const GALLERY = resolve(REPO_ROOT, 'gallery');
+
+/** The website's committed asset, which is the canonical copy of the mark. */
+const canonical = (file) => readFileSync(resolve(REPO_ROOT, 'website', 'public', file));
+
+const makeHandler = () => createRequestHandler({ appDir: GALLERY, dev: true });
+
+/** Every icon href the rendered head declares, as request paths. */
+async function declaredIconPaths(handle) {
+ const res = await handle(new Request('http://localhost/'));
+ assert.equal(res.status, 200, 'the gallery home page renders');
+ // A favicon in is ignored by browsers, so landing in the HEAD
+ // is the assertion, not merely appearing somewhere in the document.
+ const head = (await res.text()).split('')[0];
+ // One pattern covering every rel the framework emits from metadata.icons:
+ // "icon", "shortcut icon" and "apple-touch-icon" all carry `icon` in the rel.
+ return [...head.matchAll(/ ]*href="([^"]+)"/g)]
+ // absUrl() may have made the href absolute; the handler routes on the path.
+ .map((m) => (m[1].startsWith('http') ? new URL(m[1]).pathname : m[1]));
+}
+
+test('the gallery head declares a favicon', async () => {
+ const app = await makeHandler();
+ const paths = await declaredIconPaths(app.handle);
+ assert.ok(paths.length > 0, 'the head emits at least one ');
+});
+
+test('every favicon the gallery head declares is actually served', async () => {
+ // The original failure mode: a head naming a URL nothing answers. Resolve
+ // each one rather than trusting the markup.
+ const app = await makeHandler();
+ for (const path of new Set(await declaredIconPaths(app.handle))) {
+ const res = await app.handle(new Request(`http://localhost${path}`));
+ assert.equal(res.status, 200, `${path} is served, not a 404`);
+ assert.match(
+ res.headers.get('content-type') ?? '',
+ /^image\//,
+ `${path} is served as an image, so a browser renders it rather than downloading markup`,
+ );
+ }
+});
+
+test('the gallery answers /favicon.ico at the origin root', async () => {
+ // The no-markup fallback: crawlers that parse no HTML fetch this path
+ // directly. The framework serves public/favicon.ico from the root, so
+ // shipping the file is the whole wiring, and it 404'd before.
+ const app = await makeHandler();
+ const res = await app.handle(new Request('http://localhost/favicon.ico'));
+ assert.equal(res.status, 200, '/favicon.ico is served');
+});
+
+test('the gallery serves the same brand mark as the website', async () => {
+ const app = await makeHandler();
+ const bytes = async (path) =>
+ Buffer.from(await (await app.handle(new Request(`http://localhost${path}`))).arrayBuffer());
+
+ for (const file of ['favicon.svg', 'favicon-192.png', 'apple-touch-icon.png']) {
+ assert.ok(
+ (await bytes(`/public/${file}`)).equals(canonical(file)),
+ `/public/${file} is byte-identical to the website's copy`,
+ );
+ }
+ assert.ok(
+ (await bytes('/favicon.ico')).equals(canonical('favicon.ico')),
+ "/favicon.ico is byte-identical to the website's copy",
+ );
+});
+
+test('the gallery declares the raster icon ahead of the SVG', async () => {
+ // Same rule the website follows: Google's favicon crawler takes the first
+ // usable icon and renders raster reliably. metadata.icons emits array order,
+ // so the order in the layout is the order in the head.
+ const app = await makeHandler();
+ const paths = await declaredIconPaths(app.handle);
+ const png = paths.findIndex((p) => p.endsWith('favicon-192.png'));
+ const svg = paths.findIndex((p) => p.endsWith('favicon.svg'));
+ assert.ok(png > -1 && svg > -1, 'declares both a PNG and an SVG icon');
+ assert.ok(png < svg, 'the PNG is declared ahead of the SVG');
+});