Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 59 additions & 0 deletions apps/web/app/apple-icon.tsx
Original file line number Diff line number Diff line change
@@ -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(
(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
backgroundImage: "linear-gradient(160deg, #1c1c24, #050508)",
}}
>
<div style={{ display: "flex", position: "relative", width: FRAME, height: FRAME }}>
{bracketBars(ARM, THICKNESS).map((style, index) => (
<div key={index} style={style} />
))}
</div>
</div>
),
size,
);
}
29 changes: 29 additions & 0 deletions apps/web/app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 19 additions & 4 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};

Expand Down
48 changes: 48 additions & 0 deletions apps/web/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: "100%",
height: "100%",
padding: 80,
backgroundImage: "linear-gradient(160deg, #1c1c24, #050508)",
color: "#f5f5f2",
}}
>
<div style={{ display: "flex", position: "relative", width: FRAME, height: FRAME }}>
{bracketBars(ARM, THICKNESS).map((style, index) => (
<div key={index} style={style} />
))}
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ display: "flex", fontSize: 74, letterSpacing: -2 }}>headless</div>
<div style={{ display: "flex", fontSize: 34, color: "#a5a5ad", marginTop: 16 }}>
Give your agent a real browser.
</div>
<div style={{ display: "flex", fontSize: 26, color: "#7a7a86", marginTop: 28 }}>
Semantic commands, not screen coordinates. macOS and Linux.
</div>
</div>
</div>
),
size,
);
}
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions apps/web/scripts/render-brand.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
62 changes: 62 additions & 0 deletions docs/brand.md
Original file line number Diff line number Diff line change
@@ -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`
→ <https://github.com/organizations/LockInTime/settings/profile>
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.
Loading