diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index 450be5410..4d1f6afd5 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -2,15 +2,16 @@ import { connection } from "next/server";
import { BrowserTelemetry } from "@devfeed/telemetry/browser";
import { browserSettings } from "@devfeed/telemetry/receiver";
import type { Metadata } from "next";
-import brandMark from "@devfeed/theme/assets/devfeed-mark.png";
+import browserIcon from "@devfeed/theme/assets/devfeed-icon-32.png";
+import appleIcon from "@devfeed/theme/assets/devfeed-icon-180.png";
import { Toaster } from "@/components/atoms/sonner";
import { adminSiteTitle } from "@/lib/page-titles";
import "./globals.css";
export const metadata: Metadata = {
icons: {
- icon: { url: brandMark.src, type: "image/png" },
- apple: brandMark.src,
+ icon: { url: browserIcon.src, type: "image/png", sizes: "32x32" },
+ apple: { url: appleIcon.src, type: "image/png", sizes: "180x180" },
},
title: { default: adminSiteTitle, template: `%s · ${adminSiteTitle}` },
description: "DevFeed site administration",
diff --git a/apps/admin/tests/browser/overview.mjs b/apps/admin/tests/browser/overview.mjs
index 2a569fa49..c88a446fa 100644
--- a/apps/admin/tests/browser/overview.mjs
+++ b/apps/admin/tests/browser/overview.mjs
@@ -5,6 +5,7 @@ import { mkdir, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { chromium } from "playwright";
+import { checkBrowserIcons } from "../../../web/tests/browser/article-first.mjs";
// Run after npm run admin:build. All API traffic stays on disposable localhost servers.
const root = fileURLToPath(new URL("../../../../", import.meta.url));
@@ -236,6 +237,7 @@ try {
);
console.log("Opening", origin);
await page.goto(origin);
+ await checkBrowserIcons(page, true);
const response = await page.request.get(origin);
assert.match(response.headers()["content-security-policy"], /script-src [^;]*'nonce-[^']+'/);
assert.doesNotMatch(
diff --git a/apps/admin/tests/layout-metadata.test.ts b/apps/admin/tests/layout-metadata.test.ts
new file mode 100644
index 000000000..772ed1ef5
--- /dev/null
+++ b/apps/admin/tests/layout-metadata.test.ts
@@ -0,0 +1,21 @@
+import { expect, it, vi } from "vitest";
+import { metadata } from "@/app/layout";
+
+vi.mock("@devfeed/theme/assets/devfeed-icon-32.png", () => ({
+ default: { src: "/assets/favicon-32.png" },
+}));
+vi.mock("@devfeed/theme/assets/devfeed-icon-180.png", () => ({
+ default: { src: "/assets/apple-180.png" },
+}));
+
+it("uses compact icons while keeping admin pages out of search results", () => {
+ expect(metadata.icons).toEqual({
+ icon: { url: "/assets/favicon-32.png", type: "image/png", sizes: "32x32" },
+ apple: { url: "/assets/apple-180.png", type: "image/png", sizes: "180x180" },
+ });
+ expect(metadata.robots).toEqual({ index: false, follow: false });
+ expect(metadata.title).toEqual({
+ default: "DevFeed Admin",
+ template: "%s · DevFeed Admin",
+ });
+});
diff --git a/apps/extensions/build.mjs b/apps/extensions/build.mjs
index 32d7d4193..41784c16b 100644
--- a/apps/extensions/build.mjs
+++ b/apps/extensions/build.mjs
@@ -1,45 +1,54 @@
import { build } from "esbuild";
import { cp, mkdir } from "node:fs/promises";
-import { fileURLToPath } from "node:url";
+import { fileURLToPath, pathToFileURL } from "node:url";
const root = fileURLToPath(new URL("../../", import.meta.url)).replace(/\/$/, "");
-const browser = process.argv[2] ?? "chrome";
-if (!["chrome", "edge"].includes(browser)) throw new Error(`Unsupported browser: ${browser}`);
-const output = `${root}/apps/extensions/dist/${browser}`;
-await mkdir(output, { recursive: true });
-await cp(`${root}/apps/extensions/chrome`, output, { recursive: true });
-await cp(`${root}/packages/theme/assets/devfeed-mark.png`, `${output}/icon.png`);
-await cp(`${root}/apps/web/public/tool-icons`, `${output}/tool-icons`, { recursive: true });
-await build({
- absWorkingDir: root,
- entryPoints: { newtab: "apps/extensions/src/entry.tsx" },
- bundle: true,
- outdir: output,
- target: browser === "edge" ? "edge120" : "chrome120",
- jsx: "automatic",
- legalComments: "eof",
- define: { "process.env.NODE_ENV": '"production"', __DEVFEED_BROWSER__: JSON.stringify(browser) },
- alias: {
- "@": `${root}/apps/web/src`,
- "next/link": `${root}/apps/extensions/src/link.tsx`,
- "next/image": `${root}/apps/extensions/src/image.tsx`,
- "next/navigation": `${root}/apps/extensions/src/navigation.tsx`,
- },
- plugins: [
- {
- name: "bundled-brand-mark",
- setup(build) {
- build.onResolve({ filter: /^@devfeed\/theme\/assets\/devfeed-mark\.png$/ }, () => ({
- path: "icon.png",
- namespace: "brand",
- }));
- build.onLoad({ filter: /.*/, namespace: "brand" }, () => ({
- contents: 'export default { src: "icon.png" };',
- loader: "js",
- }));
- },
+export async function buildExtension(browser = "chrome") {
+ if (!["chrome", "edge"].includes(browser)) throw new Error(`Unsupported browser: ${browser}`);
+ const output = `${root}/apps/extensions/dist/${browser}`;
+ await mkdir(output, { recursive: true });
+ await cp(`${root}/apps/extensions/chrome`, output, { recursive: true });
+ await cp(`${root}/packages/theme/assets/devfeed-icon-128.png`, `${output}/icon.png`);
+ await cp(`${root}/packages/theme/assets/devfeed-icon-32.png`, `${output}/favicon.png`);
+ await cp(`${root}/apps/web/public/tool-icons`, `${output}/tool-icons`, { recursive: true });
+ await build({
+ absWorkingDir: root,
+ entryPoints: { newtab: "apps/extensions/src/entry.tsx" },
+ bundle: true,
+ outdir: output,
+ target: browser === "edge" ? "edge120" : "chrome120",
+ jsx: "automatic",
+ legalComments: "eof",
+ define: {
+ "process.env.NODE_ENV": '"production"',
+ __DEVFEED_BROWSER__: JSON.stringify(browser),
+ },
+ alias: {
+ "@": `${root}/apps/web/src`,
+ "next/link": `${root}/apps/extensions/src/link.tsx`,
+ "next/image": `${root}/apps/extensions/src/image.tsx`,
+ "next/navigation": `${root}/apps/extensions/src/navigation.tsx`,
},
- ],
- minify: true,
-});
-console.log(`Load unpacked: ${output}`);
+ plugins: [
+ {
+ name: "bundled-brand-mark",
+ setup(build) {
+ build.onResolve({ filter: /^@devfeed\/theme\/assets\/devfeed-mark\.png$/ }, () => ({
+ path: "icon.png",
+ namespace: "brand",
+ }));
+ build.onLoad({ filter: /.*/, namespace: "brand" }, () => ({
+ contents: 'export default { src: "icon.png" };',
+ loader: "js",
+ }));
+ },
+ },
+ ],
+ minify: true,
+ });
+ console.log(`Load unpacked: ${output}`);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await buildExtension(process.argv[2]);
+}
diff --git a/apps/extensions/chrome/newtab.html b/apps/extensions/chrome/newtab.html
index e37a74aab..0eff24f93 100644
--- a/apps/extensions/chrome/newtab.html
+++ b/apps/extensions/chrome/newtab.html
@@ -4,6 +4,7 @@
DevFeed — New Tab
+
diff --git a/apps/extensions/tests/reader.browser.mjs b/apps/extensions/tests/reader.browser.mjs
index 1fb816e90..0c0931740 100644
--- a/apps/extensions/tests/reader.browser.mjs
+++ b/apps/extensions/tests/reader.browser.mjs
@@ -12,6 +12,7 @@ import {
checkEngagementPagination,
} from "../../../scripts/testing/engagement-pagination.mjs";
import { checkPreviewBackground } from "../../../scripts/testing/preview-background.mjs";
+import { blockedFeed, checkArticleFirst } from "../../web/tests/browser/article-first.mjs";
import {
searchFixture,
checkSearchFilters,
@@ -89,6 +90,7 @@ test(
let engagementPagination = false;
let failNextPage = true;
let failArticle = true;
+ let articleFeedGate;
await context.route("https://identity.example/authorize?**", (route) =>
route.fulfill({ contentType: "text/html", body: "Sign-in provider
" }),
);
@@ -100,6 +102,10 @@ test(
);
await context.route("https://devfeed.tech/api/**", async (route) => {
const url = new URL(route.request().url());
+ const pendingFeed = articleFeedGate;
+ if (pendingFeed && (await pendingFeed.wait(url.pathname)) && pendingFeed.fail) {
+ return route.fulfill({ status: 503, json: {} });
+ }
requests.push(url);
if (
engagementPagination &&
@@ -298,11 +304,22 @@ test(
await retryPage.getByRole("button", { name: "Try again", exact: true }).click();
await retryPage.locator("#article-preview-title").waitFor();
await retryPage.close();
- const direct = await context.newPage();
- await direct.goto(page.url().split("#")[0] + "#/articles/direct-article");
- await direct.locator("#article-preview-title").waitFor();
- assert.ok(direct.url().endsWith("#/articles/direct-article"));
- await direct.close();
+ for (const fail of [false, true]) {
+ const direct = await context.newPage();
+ articleFeedGate = blockedFeed(fail);
+ try {
+ await checkArticleFirst(
+ direct,
+ page.url().split("#")[0] + "#/articles/direct-article",
+ articleFeedGate,
+ );
+ if (fail) assert.ok(direct.url().endsWith("#/articles/direct-article"));
+ } finally {
+ articleFeedGate.release();
+ articleFeedGate = undefined;
+ await direct.close();
+ }
+ }
await page.bringToFront();
await page.goto(page.url().split("#")[0] + "#/latest");
await page.locator(".article-card").first().waitFor();
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 581a6b7f4..6c1f3b3fc 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -17,6 +17,7 @@ COPY packages/telemetry packages/telemetry
COPY apps/web apps/web
# Reader type checks include the shared extension analytics regression test.
COPY apps/extensions apps/extensions
+COPY scripts/assets/browser-icons.mjs scripts/assets/browser-icons.mjs
RUN mkdir -p apps/web/public && npm run web:build && node packages/telemetry/scripts/private-sourcemaps.mjs apps/web/.next
FROM ${DOCKERHUB_PREFIX}alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS runtime
diff --git a/apps/web/src/app/articles/[slug]/page.tsx b/apps/web/src/app/articles/[slug]/page.tsx
index 6e05a13e8..a28f5ad81 100644
--- a/apps/web/src/app/articles/[slug]/page.tsx
+++ b/apps/web/src/app/articles/[slug]/page.tsx
@@ -7,6 +7,9 @@ import { ArticleModal } from "@/components/article-modal";
import { parseFilters } from "@/lib/feed-query";
import { ArticlePreview } from "@/components/article-preview";
import { articleMetadata } from "@/lib/metadata";
+import { Suspense } from "react";
+import { UserShell } from "@/components/user-shell";
+import { LoadingSkeleton } from "@/components/loading-skeleton";
export const dynamic = "force-dynamic";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise {
@@ -28,7 +31,15 @@ export default async function ArticlePage({ params }: Props) {
);
return (
<>
- {await FeedView({ filters: parseFilters({}), structuredData: false })}
+
+
+
+ }
+ >
+
+
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 125958cf6..07a74501e 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -14,6 +14,8 @@ import { connection } from "next/server";
import { headers } from "next/headers";
import { analyticsMeasurementId, clarityProjectId, xPixelEnabled } from "@/lib/server/config";
import brandMark from "@devfeed/theme/assets/devfeed-mark.png";
+import browserIcon from "@devfeed/theme/assets/devfeed-icon-32.png";
+import appleIcon from "@devfeed/theme/assets/devfeed-icon-180.png";
import "./globals.css";
import "./reader-motion.css";
import "./article-share.css";
@@ -31,8 +33,8 @@ export async function generateMetadata(): Promise {
metadataBase: new URL(canonicalUrl("/")),
...socialMetadata("DevFeed — Developer news", SITE_DESCRIPTION),
icons: {
- icon: { url: brandMark.src, type: "image/png" },
- apple: brandMark.src,
+ icon: { url: browserIcon.src, type: "image/png", sizes: "32x32" },
+ apple: { url: appleIcon.src, type: "image/png", sizes: "180x180" },
},
title: {
default: "DevFeed — Developer news",
diff --git a/apps/web/tests/article-page.test.tsx b/apps/web/tests/article-page.test.tsx
new file mode 100644
index 000000000..1b469c585
--- /dev/null
+++ b/apps/web/tests/article-page.test.tsx
@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { createElement, type ReactNode } from "react";
+import { renderToReadableStream } from "react-dom/server";
+import ArticlePage, { generateMetadata } from "@/app/articles/[slug]/page";
+import { loadArticle } from "@/lib/article";
+import { getFeed, getFeedOptions } from "@/lib/api";
+import type { Article, FeedOptions, FeedPage } from "@/lib/types";
+import type { FeedContentProps } from "@/components/feed-content";
+
+vi.mock("@/lib/article", () => ({ loadArticle: vi.fn() }));
+vi.mock("@/lib/api", () => ({ getFeed: vi.fn(), getFeedOptions: vi.fn() }));
+vi.mock("@/components/user-shell", () => ({
+ UserShell: ({ children }: { children: ReactNode }) => {children},
+}));
+vi.mock("@/components/loading-skeleton", () => ({
+ LoadingSkeleton: () => Loading background feed
,
+}));
+vi.mock("@/components/article-modal", () => ({
+ ArticleModal: ({ children, slug }: { children: ReactNode; slug: string }) => (
+
+ ),
+}));
+vi.mock("@/components/article-preview", () => ({
+ ArticlePreview: ({ article }: { article: Article }) => {article.title}
,
+}));
+vi.mock("@/components/article-unavailable", () => ({
+ ArticleUnavailable: ({ children }: { children: ReactNode }) => (
+ Article temporarily unavailable{children}
+ ),
+}));
+vi.mock("@/components/retry-feed", () => ({ RetryFeed: () => }));
+vi.mock("@/components/feed-content", () => ({
+ FeedContent: ({ feed }: FeedContentProps) => (
+
+ {feed.status === "fulfilled" ? feed.value.items[0]?.title : "Couldn’t load the feed"}
+
+ ),
+}));
+
+const article: Article = {
+ id: "article-id",
+ slug: "requested-article",
+ canonical_url: "https://publisher.example/story",
+ title: "Requested article",
+ summary: "A useful article",
+ ai_summary: null,
+ ai_description: null,
+ image_url: null,
+ author: null,
+ content_type: "news",
+ content_format: "article",
+ language: "en",
+ published_at: null,
+ feed_at: "2026-10-04T00:00:00Z",
+ tags: [],
+ sources: [],
+ topics: [],
+};
+const params = () => ({ params: Promise.resolve({ slug: article.slug }) });
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason: Error) => void;
+ const promise = new Promise((yes, no) => {
+ resolve = yes;
+ reject = no;
+ });
+ return { promise, resolve, reject };
+}
+
+beforeEach(() => {
+ vi.mocked(loadArticle).mockReset().mockResolvedValue(article);
+ vi.mocked(getFeed).mockReset();
+ vi.mocked(getFeedOptions).mockReset();
+ vi.stubEnv("DEVFEED_USER_BASE_URL", "https://reader.example");
+});
+afterEach(() => vi.unstubAllEnvs());
+
+it.each([false, true])(
+ "streams the requested article while feed and filters are pending (feed fails: %s)",
+ async (fails) => {
+ const feed = deferred();
+ const filters = deferred();
+ vi.mocked(getFeed).mockReturnValue(feed.promise);
+ vi.mocked(getFeedOptions).mockReturnValue(filters.promise);
+ const errors: unknown[] = [];
+ const page = await ArticlePage(params());
+ const stream = await renderToReadableStream(
+
+ {createElement("head")}
+ {page}
+ ,
+ {
+ onError: (error) => {
+ errors.push(error);
+ },
+ },
+ );
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ try {
+ const first = await reader.read();
+ const shell = decoder.decode(first.value);
+ expect(first.done).toBe(false);
+ expect(shell).toContain("Requested article");
+ expect(shell).toContain('data-slug="requested-article"');
+ expect(shell).toContain("Loading background feed");
+ expect(shell).not.toContain("Background article");
+ expect(getFeed).toHaveBeenCalledTimes(1);
+ expect(getFeedOptions).toHaveBeenCalledTimes(1);
+ if (fails) feed.reject(new Error("Background feed unavailable"));
+ else
+ feed.resolve({
+ items: [{ ...article, title: "Background article" }],
+ next_cursor: null,
+ });
+ filters.resolve({ content_types: [], sources: [] });
+ let remainder = "";
+ for (;;) {
+ const chunk = await reader.read();
+ if (chunk.done) break;
+ remainder += decoder.decode(chunk.value);
+ }
+ expect(remainder).toContain(fails ? "Couldn’t load the feed" : "Background article");
+ expect(shell).toContain("Requested article");
+ expect(errors).toEqual([]);
+ } finally {
+ feed.resolve({ items: [], next_cursor: null });
+ filters.resolve({ content_types: [], sources: [] });
+ await reader.cancel();
+ }
+ },
+);
+
+it("renders an unavailable article with retry without loading the background feed", async () => {
+ vi.mocked(loadArticle).mockResolvedValue(null);
+ const stream = await renderToReadableStream(await ArticlePage(params()));
+ const html = await new Response(stream).text();
+ expect(html).toContain("Article temporarily unavailable");
+ expect(html).toContain("Try again");
+ expect(html).toContain('data-slug="requested-article"');
+ expect(getFeed).not.toHaveBeenCalled();
+ expect(getFeedOptions).not.toHaveBeenCalled();
+});
+
+it("publishes canonical article metadata independently of feed availability", async () => {
+ expect(await generateMetadata(params())).toMatchObject({
+ title: "Requested article",
+ alternates: { canonical: "https://reader.example/articles/requested-article" },
+ });
+ expect(loadArticle).toHaveBeenCalledWith(article.slug);
+ expect(getFeed).not.toHaveBeenCalled();
+ expect(getFeedOptions).not.toHaveBeenCalled();
+});
+
+it("prevents indexing an unavailable article", async () => {
+ vi.mocked(loadArticle).mockResolvedValue(null);
+ expect(await generateMetadata(params())).toEqual({
+ title: "Article temporarily unavailable",
+ robots: { index: false, follow: false },
+ });
+});
diff --git a/apps/web/tests/browser-icons.test.ts b/apps/web/tests/browser-icons.test.ts
new file mode 100644
index 000000000..68545f37b
--- /dev/null
+++ b/apps/web/tests/browser-icons.test.ts
@@ -0,0 +1,42 @@
+import { afterEach, expect, it } from "vitest";
+import { cp, mkdtemp, readFile, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import sharp from "sharp";
+import { generateBrowserIcons } from "../../../scripts/assets/browser-icons.mjs";
+
+const assets = fileURLToPath(new URL("../../../packages/theme/assets/", import.meta.url));
+const directories: string[] = [];
+afterEach(async () => {
+ await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
+});
+
+it("generates reproducible PNGs within browser and extension transfer budgets", async () => {
+ const directory = await mkdtemp(path.join(os.tmpdir(), "devfeed-icon-test-"));
+ directories.push(directory);
+ await cp(path.join(assets, "devfeed-mark.png"), path.join(directory, "devfeed-mark.png"));
+ await generateBrowserIcons(directory);
+ for (const [size, budget] of [
+ [32, 2048],
+ [128, 6144],
+ [180, 10240],
+ ]) {
+ const name = `devfeed-icon-${size}.png`;
+ const generated = await readFile(path.join(directory, name));
+ expect(await sharp(generated).metadata()).toMatchObject({
+ format: "png",
+ width: size,
+ height: size,
+ });
+ expect(generated.length).toBeLessThanOrEqual(budget);
+ expect(generated).toEqual(await readFile(path.join(assets, name)));
+ }
+});
+
+it("reports a missing original without creating placeholder icons", async () => {
+ const directory = await mkdtemp(path.join(os.tmpdir(), "devfeed-icon-test-"));
+ directories.push(directory);
+ await expect(generateBrowserIcons(directory)).rejects.toThrow();
+ await expect(readFile(path.join(directory, "devfeed-icon-32.png"))).rejects.toThrow();
+});
diff --git a/apps/web/tests/browser/article-first.mjs b/apps/web/tests/browser/article-first.mjs
new file mode 100644
index 000000000..be0505013
--- /dev/null
+++ b/apps/web/tests/browser/article-first.mjs
@@ -0,0 +1,99 @@
+import assert from "node:assert/strict";
+
+export function blockedFeed(fail = false) {
+ let resume, started;
+ const pending = new Promise((resolve) => {
+ resume = resolve;
+ });
+ const firstRequest = new Promise((resolve) => {
+ started = resolve;
+ });
+ return {
+ fail,
+ released: false,
+ firstRequest,
+ async wait(path) {
+ if (!/^\/(?:api\/)?v1\/feed(?:\/options)?$/.test(path)) return false;
+ started();
+ await pending;
+ return true;
+ },
+ release() {
+ this.released = true;
+ resume();
+ },
+ };
+}
+
+export async function checkBrowserIcons(page, apple = false) {
+ for (const [rel, size, budget] of [
+ ["icon", 32, 2048],
+ ...(apple ? [["apple-touch-icon", 180, 10_240]] : []),
+ ]) {
+ const asset = await page.evaluate(async (relation) => {
+ const link = document.head.querySelector(`link[rel="${relation}"]`);
+ const response = await fetch(link.href);
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ const dimensions = new DataView(bytes.buffer);
+ return {
+ status: response.status,
+ declaredSize: link.sizes.value,
+ signature: [...bytes.slice(0, 8)],
+ width: dimensions.getUint32(16),
+ height: dimensions.getUint32(20),
+ bytes: bytes.length,
+ };
+ }, rel);
+ assert.equal(asset.status, 200);
+ assert.deepEqual(asset.signature, [137, 80, 78, 71, 13, 10, 26, 10]);
+ assert.equal(asset.declaredSize, `${size}x${size}`);
+ assert.equal(asset.width, size);
+ assert.equal(asset.height, size);
+ assert.ok(asset.bytes <= budget, `${rel} transfers ${asset.bytes} bytes (budget ${budget})`);
+ }
+}
+
+/** Article content must be visible and interactive while feed requests remain blocked. */
+export async function checkArticleFirst(page, href, gate, { apple = false, screenshot } = {}) {
+ let timer;
+ try {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto(href, { waitUntil: "commit", timeout: 10_000 });
+ await page
+ .locator("dialog.article-modal[open] #article-preview-title")
+ .waitFor({ timeout: 5000 });
+ await Promise.race([
+ gate.firstRequest,
+ new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error("No blocked background feed request")), 5000);
+ }),
+ ]);
+ clearTimeout(timer);
+ assert.equal(gate.released, false, "article renders before the feed completes");
+ const title = await page.locator("#article-preview-title").textContent();
+ assert.ok(title.trim());
+ assert.equal(
+ await page.getByRole("button", { name: "Close preview", exact: true }).isEnabled(),
+ true,
+ );
+ await checkBrowserIcons(page, apple);
+ if (screenshot) await page.screenshot({ path: screenshot, animations: "disabled" });
+ if (gate.fail) {
+ gate.release();
+ await page.getByRole("heading", { name: "Couldn’t load the feed", exact: true }).waitFor();
+ assert.equal(await page.locator("#article-preview-title").textContent(), title);
+ assert.equal(await page.locator("dialog.article-modal[open]").count(), 1);
+ } else {
+ await page.getByRole("button", { name: "Close preview", exact: true }).click();
+ await page
+ .locator("dialog.article-modal[open]")
+ .waitFor({ state: "detached", timeout: 1000 });
+ assert.equal(gate.released, false, "dismissal does not wait for the background feed");
+ gate.release();
+ await page.locator(".article-card").first().waitFor();
+ }
+ } finally {
+ clearTimeout(timer);
+ gate.release();
+ }
+}
diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs
index 5ce8aa524..a1a4083c0 100644
--- a/apps/web/tests/browser/feed.mjs
+++ b/apps/web/tests/browser/feed.mjs
@@ -19,6 +19,7 @@ import {
promoPublicProfile,
} from "../../../../scripts/testing/dev-card-promo.mjs";
import { checkPreviewBackground } from "../../../../scripts/testing/preview-background.mjs";
+import { blockedFeed, checkArticleFirst } from "./article-first.mjs";
import { checkFeedPreparation } from "../../../../scripts/testing/feed-preparation.mjs";
import {
checkLanguagePreferences,
@@ -64,6 +65,7 @@ const { article, topic, source } = await import(
withManagedImage(article);
const mustReadsFixture = dailyFixture(article);
let mode = "ready";
+let articleFeedGate;
let feedSettings = {
view: "cards",
content_types: ["news", "article", "tutorial", "release", "comparison", "opinion"],
@@ -79,6 +81,12 @@ let savedTopicIds = [topic.id];
const fixture = createServer(async (req, res) => {
const requestUrl = new URL(req.url, "http://localhost");
const path = requestUrl.pathname;
+ const pendingFeed = articleFeedGate;
+ if (pendingFeed && (await pendingFeed.wait(path)) && pendingFeed.fail) {
+ res.writeHead(503, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ detail: "Feed unavailable" }));
+ return;
+ }
if (path.startsWith("/v1/user/must-reads")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(await mustReadsFixture.response(path.endsWith("/presentation"))));
@@ -655,6 +663,26 @@ try {
const preparationPage = await context.newPage();
await checkFeedPreparation(preparationPage, origin, article, `${output}/preparation`);
await preparationPage.close();
+ for (const fail of [false, true]) {
+ articleFeedGate = blockedFeed(fail);
+ const directContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
+ const directPage = await directContext.newPage();
+ try {
+ await checkArticleFirst(directPage, `${origin}/articles/${article.slug}`, articleFeedGate, {
+ apple: true,
+ screenshot: `${root}/reports/article-first-web-${fail ? "failed" : "slow"}-feed.png`,
+ });
+ if (!fail) await directPage.waitForURL(`${origin}/latest`);
+ assert.equal(
+ await directPage.locator('link[rel="canonical"]').getAttribute("href"),
+ fail ? `${origin}/articles/${article.slug}` : `${origin}/latest`,
+ );
+ } finally {
+ articleFeedGate.release();
+ articleFeedGate = undefined;
+ await directContext.close();
+ }
+ }
const retryContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
const retryPage = await retryContext.newPage();
const retryResponse = await retryPage.goto(`${origin}/articles/retry-article`);
diff --git a/apps/web/tests/extension-icons.test.ts b/apps/web/tests/extension-icons.test.ts
new file mode 100644
index 000000000..5724dece9
--- /dev/null
+++ b/apps/web/tests/extension-icons.test.ts
@@ -0,0 +1,32 @@
+import { expect, it } from "vitest";
+import { readFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import sharp from "sharp";
+import { buildExtension } from "../../extensions/build.mjs";
+
+const extensions = fileURLToPath(new URL("../../extensions/dist/", import.meta.url));
+it.each(["chrome", "edge"])("packages compact, loadable %s icons", async (browser) => {
+ await buildExtension(browser);
+ for (const [name, size, budget] of [
+ ["icon.png", 128, 6144],
+ ["favicon.png", 32, 2048],
+ ] as const) {
+ const image = await readFile(`${extensions}${browser}/${name}`);
+ expect(await sharp(image).metadata()).toMatchObject({
+ format: "png",
+ width: size,
+ height: size,
+ });
+ expect(image.length).toBeLessThanOrEqual(budget);
+ }
+ const document = await readFile(`${extensions}${browser}/newtab.html`, "utf8");
+ expect(document).toContain('href="favicon.png"');
+ expect(document).toContain('src="newtab.js"');
+ const manifest = JSON.parse(await readFile(`${extensions}${browser}/manifest.json`, "utf8"));
+ expect(manifest.icons[128]).toBe("icon.png");
+ expect((await readFile(`${extensions}${browser}/newtab.js`)).length).toBeGreaterThan(0);
+});
+
+it("rejects unsupported browser packages", async () => {
+ await expect(buildExtension("firefox")).rejects.toThrow("Unsupported browser: firefox");
+});
diff --git a/apps/web/tests/layout-metadata.test.ts b/apps/web/tests/layout-metadata.test.ts
new file mode 100644
index 000000000..8bfe93b0f
--- /dev/null
+++ b/apps/web/tests/layout-metadata.test.ts
@@ -0,0 +1,28 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { connection } from "next/server";
+import { generateMetadata } from "@/app/layout";
+
+vi.mock("next/server", () => ({ connection: vi.fn() }));
+vi.mock("@devfeed/theme/assets/devfeed-icon-32.png", () => ({
+ default: { src: "/assets/favicon-32.png" },
+}));
+vi.mock("@devfeed/theme/assets/devfeed-icon-180.png", () => ({
+ default: { src: "/assets/apple-180.png" },
+}));
+
+afterEach(() => vi.unstubAllEnvs());
+it("uses compact icons and request-specific deployment metadata", async () => {
+ vi.stubEnv("DEVFEED_USER_BASE_URL", "https://reader.example");
+ const metadata = await generateMetadata();
+ expect(connection).toHaveBeenCalled();
+ expect(metadata.metadataBase).toEqual(new URL("https://reader.example/"));
+ expect(metadata.icons).toEqual({
+ icon: { url: "/assets/favicon-32.png", type: "image/png", sizes: "32x32" },
+ apple: { url: "/assets/apple-180.png", type: "image/png", sizes: "180x180" },
+ });
+ expect(metadata.title).toEqual({
+ default: "DevFeed — Developer news",
+ template: "%s · DevFeed",
+ });
+ expect(metadata.robots).toEqual({ "max-image-preview": "large" });
+});
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
index a932bf6ff..759bf7d8c 100644
--- a/apps/web/vitest.config.ts
+++ b/apps/web/vitest.config.ts
@@ -24,6 +24,8 @@ export default defineConfig({
"src/**/*.{ts,tsx}",
`${path.resolve(import.meta.dirname, "../../packages")}/*/src/**/*.{ts,tsx}`,
`${path.resolve(import.meta.dirname, "../../packages/theme")}/*.ts`,
+ path.resolve(import.meta.dirname, "../../scripts/assets/browser-icons.mjs"),
+ path.resolve(import.meta.dirname, "../extensions/build.mjs"),
],
exclude: ["**/*.d.ts"],
},
diff --git a/packages/theme/README.md b/packages/theme/README.md
index ddacaea48..1191043ab 100644
--- a/packages/theme/README.md
+++ b/packages/theme/README.md
@@ -20,7 +20,11 @@ check light/dark desktop and mobile screens, including menus, previews and setti
Both frontend Dockerfiles include this package, and Compose watch tracks its files.
`assets/` owns the original public-app logo files. Both apps statically import the
-mark for their headers and browser icons. `brand.css` shares the logo sizing,
+mark for their headers. Browser icons use dedicated 32-pixel PNGs and Apple touch
+icons use 180-pixel PNGs. Chrome and Edge use the same 32-pixel favicon and a
+128-pixel packaged icon/reader mark. Regenerate these compact variants from the
+original with `node scripts/assets/browser-icons.mjs`; the original remains available
+for artwork. `brand.css` shares the logo sizing,
wordmark typography and dark-theme treatment; the supplied raster wordmark remains
available for light-background artwork. See `docs/brand-assets.md` in the repo root.
diff --git a/packages/theme/assets/devfeed-icon-128.png b/packages/theme/assets/devfeed-icon-128.png
new file mode 100644
index 000000000..202636afd
Binary files /dev/null and b/packages/theme/assets/devfeed-icon-128.png differ
diff --git a/packages/theme/assets/devfeed-icon-180.png b/packages/theme/assets/devfeed-icon-180.png
new file mode 100644
index 000000000..4740a005e
Binary files /dev/null and b/packages/theme/assets/devfeed-icon-180.png differ
diff --git a/packages/theme/assets/devfeed-icon-32.png b/packages/theme/assets/devfeed-icon-32.png
new file mode 100644
index 000000000..a04b554af
Binary files /dev/null and b/packages/theme/assets/devfeed-icon-32.png differ
diff --git a/scripts/assets/README.md b/scripts/assets/README.md
index 47bfaafd8..d32bf65e5 100644
--- a/scripts/assets/README.md
+++ b/scripts/assets/README.md
@@ -12,3 +12,12 @@ nor a font download. Card text disables kerning to match the additive advances.
use conservative widths; actual glyph appearance still depends on the viewer's system fonts.
If the theme's Arial/Helvetica font family changes, update the metrics and validate the web app,
standalone SVG/PNG exports, and both extensions together.
+
+## Browser icons
+
+Run `node scripts/assets/browser-icons.mjs` to regenerate the compact PNG variants
+from `packages/theme/assets/devfeed-mark.png`: 32 pixels for browser favicons,
+128 pixels for the Chrome/Edge package and reader mark, and 180 pixels for Apple
+touch icons. Commit the generated assets with changes to the original mark.
+Web/admin metadata and extension builds use these precomputed assets so icon
+requests need no image transformation at runtime.
diff --git a/scripts/assets/browser-icons.mjs b/scripts/assets/browser-icons.mjs
new file mode 100644
index 000000000..ba9298199
--- /dev/null
+++ b/scripts/assets/browser-icons.mjs
@@ -0,0 +1,17 @@
+import { fileURLToPath, pathToFileURL } from "node:url";
+import sharp from "sharp";
+
+export async function generateBrowserIcons(
+ assets = fileURLToPath(new URL("../../packages/theme/assets/", import.meta.url)),
+) {
+ for (const size of [32, 128, 180]) {
+ await sharp(`${assets}/devfeed-mark.png`)
+ .resize(size, size)
+ .png({ compressionLevel: 9, palette: true, colours: 128 })
+ .toFile(`${assets}/devfeed-icon-${size}.png`);
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await generateBrowserIcons();
+}