From 975d58c2bb4dab2b401ce4e506803d6d794c5183 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:27:06 +0000 Subject: [PATCH] feat(web): give the tab icon a background and generate the brand rasters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The favicon pointed at public/headless-mark.svg, which is stroke-only and takes its colour from a prefers-color-scheme block inside the file. Browsers do not reliably re-evaluate that for tab icons, so a near-black #15171a stroke can disappear against a dark tab strip. The macOS app icon had already solved this by putting the mark on a dark squircle; app/icon.svg now does the same, so the two platforms match. The bare mark stays exactly where it is: the nav brand uses it as a CSS mask and takes its colour from currentColor, so it must remain background-free. Adds the raster sizes that were missing — an Apple touch icon and a 1200x630 social card — generated at build time through next/og rather than committed as binaries, plus the openGraph and twitter metadata and the metadataBase that were absent. scripts/render-brand.mjs renders the two square images GitHub needs, an organisation avatar and a repository social preview, into a gitignored build/brand/. GitHub has no API for either upload, so they are generated on demand and uploaded by hand. Generated images draw each bracket as two rounded bars rather than a bordered box: bordered boxes miter at the corner and leave a visible seam, and cannot reproduce the round line caps the mark is drawn with. docs/brand.md records the geometry, where each asset lives, and the upload steps. --- .gitignore | 1 + AGENTS.md | 1 + apps/web/app/apple-icon.tsx | 59 +++++++++++++++++ apps/web/app/icon.svg | 29 +++++++++ apps/web/app/layout.tsx | 23 +++++-- apps/web/app/opengraph-image.tsx | 48 ++++++++++++++ apps/web/package.json | 3 +- apps/web/scripts/render-brand.mjs | 104 ++++++++++++++++++++++++++++++ docs/brand.md | 62 ++++++++++++++++++ 9 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/apple-icon.tsx create mode 100644 apps/web/app/icon.svg create mode 100644 apps/web/app/opengraph-image.tsx create mode 100644 apps/web/scripts/render-brand.mjs create mode 100644 docs/brand.md 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( + ( +
+
+ {bracketBars(ARM, THICKNESS).map((style, index) => ( +
+ ))} +
+
+ ), + 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( + ( +
+
+ {bracketBars(ARM, THICKNESS).map((style, index) => ( +
+ ))} +
+
+
headless
+
+ Give your agent a real browser. +
+
+ 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.