Public: anyone can read this; the crew can change it.
Every page carries a tab icon, and /favicon.ico answers with the mark instead of a 404 #7919
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7914-every-page-carries-a-tab-icon-and-favico → main 4 commits fork 2 threads · 2 unresolved
6 files
changed +38
−3
src/core/reserved.ts +1
| @@ -68,6 +68,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 68 | 68 | "events", |
| 69 | 69 | "explore", |
| 70 | 70 | "family", |
| 71 | "favicon.ico", | |
| 71 | 72 | "fleet", |
| 72 | 73 | "flow", |
| 73 | 74 | "front", |
src/http/server.ts +2
| @@ -189,6 +189,7 @@ import { ceilingAct, grantAct, modelAdd, modelPage } from "../web/pages/model.ts | ||
| 189 | 189 | import { |
| 190 | 190 | changePassword, |
| 191 | 191 | iconPng, |
| 192 | favicon, | |
| 192 | 193 | llmsTxt, |
| 193 | 194 | releasesJson, |
| 194 | 195 | robotsTxt, |
| @@ -714,6 +715,7 @@ export function buildRouter(): Router<Handler> { | ||
| 714 | 715 | router.add("GET", "/manifest.webmanifest", webManifest); |
| 715 | 716 | router.add("GET", "/opensearch.xml", openSearch); |
| 716 | 717 | router.add("GET", "/icons/:spec.png", iconPng); |
| 718 | router.add("GET", "/favicon.ico", favicon); | |
| 717 | 719 | // The highlighter bundle, content-addressed. A stale hash is a 404 rather |
| 718 | 720 | // than a redirect: the page that asked for it names the hash it was built |
| 719 | 721 | // with, so a miss means an old tab, and an old tab should reload. |
src/web/standards.ts +16 −2
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | * report a problem without an account, how |
| 17 | 17 | * to offer a change (llmstxt.org) |
| 18 | 18 | * /icons/forge-<size>.png the mark, rastered for the manifest and iOS |
| 19 | * /favicon.ico the 32px mark, for a browser that asks by habit | |
| 19 | 20 | * |
| 20 | 21 | * Nothing here is configured; everything is derived from what the forge |
| 21 | 22 | * already knows — its URL, its first account, its public repositories, its |
| @@ -267,7 +268,7 @@ export function webManifest(ctx: Ctx): Response { | ||
| 267 | 268 | ); |
| 268 | 269 | } |
| 269 | 270 | |
| 270 | const SIZES = new Set([180, 192, 512]); | |
| 271 | const SIZES = new Set([32, 180, 192, 512]); | |
| 271 | 272 | |
| 272 | 273 | /** GET /icons/forge-:spec.png — `<size>` or `<size>-maskable`. */ |
| 273 | 274 | export function iconPng(ctx: Ctx): Response { |
| @@ -279,7 +280,20 @@ export function iconPng(ctx: Ctx): Response { | ||
| 279 | 280 | // iOS rounds its own corners, so the touch icon is painted edge to edge |
| 280 | 281 | // like a maskable one. |
| 281 | 282 | const shape: MarkShape = m[2] !== undefined || size === 180 ? "full" : "icon"; |
| 282 | const png = markPng(size, shape); | |
| 283 | return pngResponse(markPng(size, shape)); | |
| 284 | } | |
| 285 | ||
| 286 | /** | |
| 287 | * GET /favicon.ico — the mark at 32px (`/icons/forge-32.png`). Every page | |
| 288 | * carries its own inline SVG icon, but a browser asks here anyway on a page | |
| 289 | * that is not ours (a raw file, a JSON answer); a PNG under an .ico name is | |
| 290 | * read by every browser that asks, and answering spares the console a 404. | |
| 291 | */ | |
| 292 | export function favicon(): Response { | |
| 293 | return pngResponse(markPng(32, "icon")); | |
| 294 | } | |
| 295 | ||
| 296 | function pngResponse(png: Uint8Array): Response { | |
| 283 | 297 | return new Response(png, { |
| 284 | 298 | headers: { |
| 285 | 299 | "content-type": "image/png", |
test/design-audit.test.ts +1 −1
| @@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 11 | 11 | |
| 12 | 12 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 13 | 13 | const NOT_PAGES = |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/; | |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/; | |
| 15 | 15 | |
| 16 | 16 | const getRoutes = [ |
| 17 | 17 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
test/forge-looks.test.ts +1
| @@ -22,6 +22,7 @@ quietLogs(); | ||
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | 23 | "/llms.txt": "plain markdown for machines, not a page to picture", |
| 24 | 24 | "/robots.txt": "plain text for crawlers", |
| 25 | "/favicon.ico": "the tab icon, an image", | |
| 25 | 26 | "/releases.json": "JSON feed of /releases", |
| 26 | 27 | "/sitemap.xml": "XML for crawlers", |
| 27 | 28 | "/manifest.webmanifest": "the install manifest, JSON", |
test/standards.test.ts +17
| @@ -345,6 +345,23 @@ describe("what the open web expects", () => { | ||
| 345 | 345 | expect((await fetch(`${base}/icons/forge-64.png`)).status).toBe(404); |
| 346 | 346 | }); |
| 347 | 347 | |
| 348 | test("/favicon.ico answers with the 32px mark instead of a 404", async () => { | |
| 349 | const icon = await fetch(`${base}/icons/forge-32.png`); | |
| 350 | const fav = await fetch(`${base}/favicon.ico`); | |
| 351 | expect(fav.status).toBe(200); | |
| 352 | expect(fav.headers.get("content-type")).toBe("image/png"); | |
| 353 | expect(fav.headers.get("cache-control")).toBe(icon.headers.get("cache-control")); | |
| 354 | expect(fav.headers.get("cache-control")).toContain("immutable"); | |
| 355 | const bytes = new Uint8Array(await fav.arrayBuffer()); | |
| 356 | expect(bytes).toEqual(new Uint8Array(await icon.arrayBuffer())); | |
| 357 | const header = pngHeader(bytes); | |
| 358 | expect(header.ok && `${header.value.width}x${header.value.height}`).toBe("32x32"); | |
| 359 | // The security floor is on it like on every response. | |
| 360 | expect(fav.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 361 | expect(fav.headers.get("referrer-policy")).not.toBeNull(); | |
| 362 | expect(fav.headers.get("content-security-policy")).not.toBeNull(); | |
| 363 | }); | |
| 364 | ||
| 348 | 365 | test("the mark is painted, not pasted: gradient inside, nothing outside", () => { |
| 349 | 366 | const decoded = decodePng(markPng(64, "icon")); |
| 350 | 367 | expect(decoded.ok).toBe(true); |
new above
offline
Drop the head-link assertion along with the layout link. Keep the /favicon.ico checks.