diff --git a/.gitignore b/.gitignore
index 0dac126..487e02f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ apps/headless/Headless.icns
apps/headless/build/
apps/headless/.build/
apps/web/.next/
+apps/web/build/
*.tsbuildinfo
# Local QA evidence is generated on demand; reviewed evidence lives in docs/qa.
diff --git a/AGENTS.md b/AGENTS.md
index 3cf743c..8415ab2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -114,6 +114,7 @@ If a change brushes against any of these, stop and record a decision in
touch the string delimiters, fix the extractor.
- Media policy: never commit images/videos except regenerated evidence under
`docs/qa/evidence/`; local artifacts go to `build/qa-evidence/` (gitignored).
+ Brand images are generated from code, never committed — see `docs/brand.md`.
- Docs: feature docs live in the phase contracts (P0/P1/P2 style — contract,
deferrals, known limitations). Keep README claims backed by tests or
evidence.
diff --git a/apps/web/app/apple-icon.tsx b/apps/web/app/apple-icon.tsx
new file mode 100644
index 0000000..2583020
--- /dev/null
+++ b/apps/web/app/apple-icon.tsx
@@ -0,0 +1,59 @@
+import { ImageResponse } from "next/og";
+
+// Apple touch icons must be raster, so this is generated at build time rather
+// than committed as a binary. Same viewfinder geometry as app/icon.svg.
+// iOS applies its own corner mask, so the canvas stays square here.
+export const size = { width: 180, height: 180 };
+export const contentType = "image/png";
+
+const ARM = 52;
+const THICKNESS = 8;
+const FRAME = 118;
+const STROKE = "#f5f5f2";
+
+// Each bracket is two rounded bars rather than a bordered box. Bordered boxes
+// miter at the corner and leave a visible seam, and they cannot reproduce the
+// round line caps the mark is drawn with.
+export function bracketBars(arm: number, thickness: number) {
+ const edges = [
+ ["top", "left"],
+ ["top", "right"],
+ ["bottom", "left"],
+ ["bottom", "right"],
+ ] as const;
+ return edges.flatMap(([vertical, horizontal]) => {
+ const common = {
+ position: "absolute" as const,
+ background: STROKE,
+ borderRadius: thickness / 2,
+ };
+ return [
+ { ...common, width: arm, height: thickness, [vertical]: 0, [horizontal]: 0 },
+ { ...common, width: thickness, height: arm, [vertical]: 0, [horizontal]: 0 },
+ ];
+ });
+}
+
+export default function AppleIcon() {
+ return new ImageResponse(
+ (
+
+ ),
+ size,
+ );
+}
diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg
new file mode 100644
index 0000000..5afae87
--- /dev/null
+++ b/apps/web/app/icon.svg
@@ -0,0 +1,29 @@
+
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 3114b7e..038918e 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -19,12 +19,27 @@ const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
});
+const description =
+ "Persistent, secure browser control for AI agents. No screen coordinates. No browser scripts.";
+
+// icon.svg, apple-icon.tsx, and opengraph-image.tsx are picked up by the App
+// Router file conventions, so icons are not declared by hand here. The bare
+// mark in public/ stays where it is — the nav brand uses it as a CSS mask and
+// takes its colour from currentColor.
export const metadata: Metadata = {
+ metadataBase: new URL("https://headless-web-pi.vercel.app"),
title: "Headless — browser control for agents",
- description: "Persistent, secure browser control for AI agents. No screen coordinates. No browser scripts.",
- icons: {
- icon: [{ url: "/headless-mark.svg", type: "image/svg+xml" }],
- shortcut: "/headless-mark.svg",
+ description,
+ openGraph: {
+ title: "Headless — browser control for agents",
+ description,
+ siteName: "Headless",
+ type: "website",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: "Headless — browser control for agents",
+ description,
},
};
diff --git a/apps/web/app/opengraph-image.tsx b/apps/web/app/opengraph-image.tsx
new file mode 100644
index 0000000..30327c2
--- /dev/null
+++ b/apps/web/app/opengraph-image.tsx
@@ -0,0 +1,48 @@
+import { ImageResponse } from "next/og";
+import { bracketBars } from "./apple-icon";
+
+// Social card for links to the site. The GitHub social preview is a different
+// aspect ratio and is generated by scripts/render-brand.mjs, because GitHub has
+// no API for that upload.
+export const size = { width: 1200, height: 630 };
+export const contentType = "image/png";
+export const alt = "Headless — browser control for agents";
+
+const ARM = 74;
+const THICKNESS = 11;
+const FRAME = 168;
+
+export default function OpengraphImage() {
+ return new ImageResponse(
+ (
+
+ Semantic commands, not screen coordinates. macOS and Linux.
+
+
+
+ ),
+ size,
+ );
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index da87d28..4f5487e 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint ."
+ "lint": "eslint .",
+ "brand": "node scripts/render-brand.mjs"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
diff --git a/apps/web/scripts/render-brand.mjs b/apps/web/scripts/render-brand.mjs
new file mode 100644
index 0000000..c11ebdb
--- /dev/null
+++ b/apps/web/scripts/render-brand.mjs
@@ -0,0 +1,104 @@
+// Renders the square brand images GitHub needs — an organisation avatar and a
+// repository social preview — into build/brand/, which is gitignored.
+//
+// GitHub exposes no API for either upload, so these are generated on demand and
+// uploaded by hand. Keeping the generator in the repo means the images stay
+// reproducible without committing binaries.
+//
+// pnpm --filter @headless/web brand
+import { mkdir, writeFile } from "node:fs/promises";
+import { ImageResponse } from "next/og.js";
+
+const OUT = new URL("../build/brand/", import.meta.url);
+const STROKE = "#f5f5f2";
+
+// Each bracket is two rounded bars rather than a bordered box: bordered boxes
+// miter at the corner and leave a seam, and cannot reproduce the round line
+// caps the mark is drawn with.
+function viewfinder({ frame, arm, thickness }) {
+ const edges = [
+ ["top", "left"],
+ ["top", "right"],
+ ["bottom", "left"],
+ ["bottom", "right"],
+ ];
+ const bars = edges.flatMap(([vertical, horizontal]) => {
+ const common = { position: "absolute", background: STROKE, borderRadius: thickness / 2 };
+ return [
+ { ...common, width: arm, height: thickness, [vertical]: 0, [horizontal]: 0 },
+ { ...common, width: thickness, height: arm, [vertical]: 0, [horizontal]: 0 },
+ ];
+ });
+ return {
+ type: "div",
+ props: {
+ style: { display: "flex", position: "relative", width: frame, height: frame },
+ children: bars.map((style, key) => ({ type: "div", key, props: { style } })),
+ },
+ };
+}
+
+function canvas(children, extra = {}) {
+ return {
+ type: "div",
+ props: {
+ style: {
+ display: "flex",
+ width: "100%",
+ height: "100%",
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundImage: "linear-gradient(160deg, #1c1c24, #050508)",
+ color: STROKE,
+ ...extra,
+ },
+ children,
+ },
+ };
+}
+
+async function render(name, element, size) {
+ const response = new ImageResponse(element, size);
+ const buffer = Buffer.from(await response.arrayBuffer());
+ await writeFile(new URL(name, OUT), buffer);
+ console.log(`${name} ${size.width}x${size.height} ${buffer.length} bytes`);
+}
+
+await mkdir(OUT, { recursive: true });
+
+// Organisation avatar. GitHub wants a square image of at least 500px.
+await render(
+ "avatar-512.png",
+ canvas([viewfinder({ frame: 300, arm: 132, thickness: 26 })]),
+ { width: 512, height: 512 },
+);
+
+// Repository social preview. GitHub renders it at 1280x640.
+await render(
+ "social-preview-1280x640.png",
+ canvas(
+ [
+ {
+ type: "div",
+ props: {
+ style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 30 },
+ children: [
+ viewfinder({ frame: 170, arm: 74, thickness: 14 }),
+ { type: "div", props: { style: { display: "flex", fontSize: 78, letterSpacing: -2 }, children: "headless" } },
+ {
+ type: "div",
+ props: {
+ style: { display: "flex", fontSize: 34, color: "#a5a5ad" },
+ children: "Give your agent a real browser.",
+ },
+ },
+ ],
+ },
+ },
+ ],
+ { flexDirection: "column" },
+ ),
+ { width: 1280, height: 640 },
+);
+
+console.log(`\nWrote to ${new URL(".", OUT).pathname}`);
diff --git a/docs/brand.md b/docs/brand.md
new file mode 100644
index 0000000..1fab46e
--- /dev/null
+++ b/docs/brand.md
@@ -0,0 +1,62 @@
+# Brand assets
+
+The mark is a **viewfinder** — four corner brackets around empty space. It says
+what the product is: a frame with no chrome, pointed at a page. Everything below
+is the same geometry at different sizes, so there is one shape to keep right.
+
+Canonical path, on a 64-unit grid:
+
+```
+M18.875 26.75V18.875H26.75 M37.25 18.875H45.125V26.75
+M45.125 37.25V45.125H37.25 M26.75 45.125H18.875V37.25
+```
+
+Stroke width `2.875`, round caps and joins. On the 1024-unit macOS icon grid the
+same shape is a 420pt box inset at 302pt with 126pt arms and a 46pt stroke.
+
+## Where each asset lives
+
+| Asset | Source | Notes |
+| --- | --- | --- |
+| Nav brand mark | `apps/web/public/headless-mark.svg` | Stroke-only and transparent. The site uses it as a CSS mask, so its colour comes from `currentColor` — do not add a background to this file. |
+| Browser tab icon | `apps/web/app/icon.svg` | The mark on the dark squircle. A favicon renders against unknown backgrounds, so it carries its own. |
+| Apple touch icon | `apps/web/app/apple-icon.tsx` | Generated at build time. iOS applies its own corner mask, so the canvas is square. |
+| Social card | `apps/web/app/opengraph-image.tsx` | 1200×630, used for `og:image` and Twitter cards. |
+| GitHub avatar and preview | `apps/web/scripts/render-brand.mjs` | `pnpm --filter @headless/web brand` → `apps/web/build/brand/` (gitignored). |
+| macOS app icon | `apps/headless/tools/make-icon.swift` | Renders the `.icns` during `build.sh`. |
+
+Raster images are **generated, never committed** — the repository media policy
+allows binaries only under `docs/qa/evidence/`. Every raster asset above comes
+from code, so it can be regenerated at any size without a design tool.
+
+## Why the tab icon is not the bare mark
+
+`public/headless-mark.svg` is stroke-only and picks its colour from a
+`prefers-color-scheme` block inside the file. Browsers do not reliably
+re-evaluate that for tab icons, so the near-black stroke can disappear against a
+dark tab strip. `app/icon.svg` puts the same mark on the dark squircle the macOS
+app icon already uses, which reads on any surface and keeps the two platforms
+consistent.
+
+## Drawing the brackets in generated images
+
+The generated images draw each bracket as **two rounded bars**, not a div with
+two borders. Bordered boxes miter at the corner and leave a visible diagonal
+seam, and they cannot reproduce the round line caps of the source path.
+
+## Updating GitHub
+
+GitHub exposes no API for either of these, so both are manual uploads. Generate
+the images first:
+
+```sh
+pnpm --filter @headless/web brand
+```
+
+- **Organisation avatar** — `apps/web/build/brand/avatar-512.png`
+ →
+ Repositories have no icon of their own; they display the owner's avatar, so
+ this is what makes the mark show up next to the repo.
+- **Repository social preview** — `apps/web/build/brand/social-preview-1280x640.png`
+ → repository **Settings → General → Social preview → Upload an image**
+ This is the card shown when the repo is linked on Slack, X, or Discord.