flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Social cards for every page: og.g1t.sh

A new Worker draws a 1200x630 card for every page on g1t.sh and the docs: the home and pricing pages, workspaces, projects with their open issues and pull requests, issues and pull requests with their state, Soon pages, and each docs page. Lookups are anonymous, so anything private gets the generic card. Cards are cached by what they show. Every page on g1t.sh and the docs carries og and Twitter tags pointing at its card.

syntaqxcommitted Parentef10f3bBrowse files
75 files+1421−1160/75 viewed
+8−0
4646 - `g1t-mark.svg`, `g1t-mark.png` (256px): the mark on a rounded dark tile, readable on any background
4747 - `g1t-logo.svg`, `g1t-logo.png`: the lockup for light backgrounds, used in g1t's emails
4848 - `g1t-logo-on-dark.svg`, `g1t-logo-on-dark.png`: the lockup for dark backgrounds
49+- `g1t-og.png`: the brand social card as `services/og` draws it, which that service falls back to if drawing fails
50+
51+## Social cards
52+
53+Every page of g1t.sh and docs.g1t.sh links to its own card at `https://og.g1t.sh`,
54+drawn by `services/og` in the same colours, type and mark: the lockup and the
55+page's address along the top, its subject in the middle, its facts along the
56+bottom, the fleet standing large and faded off the right edge.
+1−0
9797 | `services/work` | Issues, pull requests, reviews, check runs and sessions. Rust. |
9898 | `services/events` | The event bus and its log. Rust. |
9999 | `services/runner` | Starts sandboxes: for g1t agents, and for acceptance checks. |
100+| `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. |
100101 | `crates/runner` | The program inside a sandbox: runs an agent, or a set of checks, and reports back. Rust. |
101102 | `crates/contracts` | Types and service interfaces for the Rust services. |
102103 | `crates/kit` | Plumbing shared by Rust services on Workers. |
+46−1
22
33 import { tabFor } from './tabs';
44
5−type Entry = { type: string; href?: string; entries?: Entry[] };
5+type Entry = { type: string; label?: string; href?: string; isCurrent?: boolean; entries?: Entry[] };
6+
7+/** The social cards' service; see services/og. */
8+const OG = 'https://og.g1t.sh';
69
710 /** The slug of a group's first page on this site, such as `reference/api`. */
811 function firstSlug(entries: Entry[]): string | undefined {
1720 return undefined;
1821 }
1922
23+/** The label of the sidebar group holding the current page, the innermost if they nest. */
24+function sectionOf(entries: Entry[], group?: string): string | undefined {
25+ for (const entry of entries) {
26+ if (entry.type === 'group') {
27+ const found = sectionOf(entry.entries ?? [], entry.label);
28+ if (found !== undefined) return found;
29+ } else if (entry.isCurrent) {
30+ return group;
31+ }
32+ }
33+ return undefined;
34+}
35+
2036 /**
2137 * Each page's sidebar holds only its tab's groups: those whose pages
2238 * belong to the same tab. Groups are told apart by their pages, not their
2339 * labels, since a guide group and an API group can share a name.
40+ *
41+ * Each page also gets its own social card: its title, its section and its
42+ * description, drawn by the og service.
2443 */
2544 export const onRequest = defineRouteMiddleware((context) => {
2645 const route = context.locals.starlightRoute;
46+ const section = sectionOf(route.sidebar as Entry[]);
2747 const tab = tabFor(route.id);
2848 route.sidebar = route.sidebar.filter((entry) => {
2949 if (entry.type !== 'group') return false;
3050 const slug = firstSlug(entry.entries as Entry[]);
3151 return slug !== undefined && tabFor(slug) === tab;
3252 });
53+
54+ const { title, description } = route.entry.data;
55+ const card = new URL('/docs', OG);
56+ card.searchParams.set('title', title);
57+ if (section) card.searchParams.set('section', section);
58+ if (description) card.searchParams.set('description', description);
59+ const image = card.toString();
60+ const alt = `${title} · g1t docs`;
61+ const meta = (key: 'property' | 'name', name: string, content: string) => ({
62+ tag: 'meta' as const,
63+ attrs: { [key]: name, content },
64+ });
65+ // Starlight already writes og:title, og:description, og:url and
66+ // twitter:card; these finish the card.
67+ route.head.push(
68+ meta('property', 'og:image', image),
69+ meta('property', 'og:image:type', 'image/png'),
70+ meta('property', 'og:image:width', '1200'),
71+ meta('property', 'og:image:height', '630'),
72+ meta('property', 'og:image:alt', alt),
73+ meta('name', 'twitter:title', title),
74+ meta('name', 'twitter:image', image),
75+ meta('name', 'twitter:image:alt', alt),
76+ );
77+ if (description) route.head.push(meta('name', 'twitter:description', description));
3378 });
+55−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { DESCRIPTION, cardUrl, excerpt, fingerprint, page } from "./meta.ts";
5+
6+type Tag = Record<string, string>;
7+
8+function tags(list: unknown[]): Map<string, string> {
9+ const found = new Map<string, string>();
10+ for (const tag of list as Tag[]) {
11+ if ("title" in tag) found.set("title", tag.title);
12+ else found.set(tag.property ?? tag.name, tag.content);
13+ }
14+ return found;
15+}
16+
17+test("a page gets its title, description, Open Graph and Twitter tags", () => {
18+ const meta = tags(page({ location: { pathname: "/pricing" }, matches: [] }, { title: "Pricing · g1t", description: "What it costs." }));
19+ assert.equal(meta.get("title"), "Pricing · g1t");
20+ assert.equal(meta.get("description"), "What it costs.");
21+ assert.equal(meta.get("og:title"), "Pricing");
22+ assert.equal(meta.get("og:url"), "https://g1t.sh/pricing");
23+ assert.equal(meta.get("og:site_name"), "g1t");
24+ assert.equal(meta.get("og:image"), "https://og.g1t.sh/image?path=%2Fpricing");
25+ assert.equal(meta.get("twitter:card"), "summary_large_image");
26+ assert.equal(meta.get("twitter:image"), meta.get("og:image"));
27+});
28+
29+test("pages under a project describe the project, and their card follows its details", () => {
30+ const project = {
31+ id: "routes/repo/layout",
32+ loaderData: { repo: { description: "Repo words" }, project: { name: "Web", description: "The storefront" }, open: { issues: 2, pulls: 1 } },
33+ };
34+ const meta = tags(page({ location: { pathname: "/acme/web/commits/" }, matches: [project] }, { title: "Commits · acme/web · g1t" }));
35+ assert.equal(meta.get("description"), "The storefront");
36+ assert.equal(meta.get("og:url"), "https://g1t.sh/acme/web/commits");
37+ assert.match(meta.get("og:image") ?? "", /\?path=%2Facme%2Fweb%2Fcommits&v=[0-9a-z]+$/);
38+});
39+
40+test("with nothing particular to say, a page says what g1t is", () => {
41+ const meta = tags(page({ location: { pathname: "/login" }, matches: [] }, { title: "Sign in · g1t" }));
42+ assert.equal(meta.get("description"), DESCRIPTION);
43+});
44+
45+test("a card's version changes with what it shows", () => {
46+ assert.equal(fingerprint(["a", "open"]), fingerprint(["a", "open"]));
47+ assert.notEqual(fingerprint(["a", "open"]), fingerprint(["a", "merged"]));
48+ assert.equal(cardUrl("/", undefined), "https://og.g1t.sh/image?path=%2F");
49+});
50+
51+test("an excerpt is plain text from the start of the Markdown", () => {
52+ assert.equal(excerpt("## Why\n\nThe [queue](https://x) is **slow**.\n\n```ts\ncode()\n```"), "Why The queue is slow.");
53+ assert.equal(excerpt("x".repeat(300), 10).length, 10);
54+ assert.equal(excerpt(null), "");
55+});
+129−0
1+/**
2+ * Every page's <title> and the tags a link preview reads: its description,
3+ * Open Graph and Twitter's card. The image is the page's own card from the
4+ * og service (services/og), which draws it as an anonymous visitor would
5+ * see the page, so a private page's card never names anything.
6+ *
7+ * React Router renders only the deepest route's `meta`, so each route
8+ * returns `page(args, …)` rather than a bare title.
9+ */
10+import type { MetaDescriptor } from "react-router";
11+
12+export const SITE = "https://g1t.sh";
13+export const OG = "https://og.g1t.sh";
14+
15+/** What g1t is, for pages with nothing more particular to say. */
16+export const DESCRIPTION =
17+ "A git forge for teams of agents: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.";
18+
19+/** As much of `MetaArgs` as the tags need. */
20+export type PageArgs = {
21+ location: { pathname: string };
22+ matches: ReadonlyArray<{ id: string; loaderData?: unknown } | undefined>;
23+};
24+
25+export type PageMeta = {
26+ /** The whole <title>, ending in "· g1t". */
27+ title: string;
28+ /** Defaults to the project's or workspace's description, then g1t's. */
29+ description?: string | null;
30+ /**
31+ * Whatever the page's card shows, so that the card is drawn again when it
32+ * changes. Defaults to the project's details on project pages.
33+ */
34+ version?: unknown;
35+ type?: "website" | "article";
36+};
37+
38+type ProjectData = {
39+ repo?: { description?: string | null };
40+ project?: { name?: string; description?: string | null } | null;
41+ open?: { issues?: number; pulls?: number };
42+};
43+type WorkspaceData = { workspace?: { name?: string; description?: string | null } };
44+
45+function loaded<T>(args: PageArgs, id: string): T | undefined {
46+ return args.matches.find((match) => match?.id === id)?.loaderData as T | undefined;
47+}
48+
49+/** A short, stable fingerprint, for the card's `v`. */
50+export function fingerprint(value: unknown): string {
51+ const text = JSON.stringify(value) ?? "";
52+ // FNV-1a, 32 bits.
53+ let hash = 0x811c9dc5;
54+ for (let i = 0; i < text.length; i++) {
55+ hash ^= text.charCodeAt(i);
56+ hash = Math.imul(hash, 0x01000193);
57+ }
58+ return (hash >>> 0).toString(36);
59+}
60+
61+/** A description fit for a preview: one line, at most 200 characters. */
62+function summary(text: string): string {
63+ const line = text.replace(/\s+/g, " ").trim();
64+ return line.length > 200 ? `${line.slice(0, 199).trimEnd()}…` : line;
65+}
66+
67+/** The opening of some Markdown as plain text, for a description. */
68+export function excerpt(markdown: string | null | undefined, max = 160): string {
69+ const text = (markdown ?? "")
70+ .replace(/```[\s\S]*?(```|$)/g, " ")
71+ .replace(/<[^>]+>/g, " ")
72+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
73+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
74+ .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
75+ .replace(/[`*_~|]/g, "")
76+ .replace(/\s+/g, " ")
77+ .trim();
78+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
79+}
80+
81+/** The address of a page's card. */
82+export function cardUrl(pathname: string, version?: unknown): string {
83+ const url = new URL("/image", OG);
84+ url.searchParams.set("path", pathname);
85+ if (version !== undefined) url.searchParams.set("v", fingerprint(version));
86+ return url.toString();
87+}
88+
89+export function page(args: PageArgs, meta: PageMeta): MetaDescriptor[] {
90+ const project = loaded<ProjectData>(args, "routes/repo/layout");
91+ const workspace = loaded<WorkspaceData>(args, "routes/workspace/layout");
92+ const description = summary(
93+ meta.description ||
94+ project?.project?.description ||
95+ project?.repo?.description ||
96+ workspace?.workspace?.description ||
97+ DESCRIPTION,
98+ );
99+ const version =
100+ meta.version ??
101+ (project
102+ ? [project.project?.name, project.project?.description ?? project.repo?.description, project.open]
103+ : workspace
104+ ? [workspace.workspace?.name, workspace.workspace?.description]
105+ : undefined);
106+ const pathname = args.location.pathname.replace(/\/+$/, "") || "/";
107+ // The title a preview shows needs no "· g1t": the site name is beside it.
108+ const shareTitle = meta.title.replace(/ · g1t$/, "");
109+ const image = cardUrl(pathname, version);
110+ return [
111+ { title: meta.title },
112+ { name: "description", content: description },
113+ { property: "og:site_name", content: "g1t" },
114+ { property: "og:type", content: meta.type ?? "website" },
115+ { property: "og:url", content: `${SITE}${pathname === "/" ? "/" : pathname}` },
116+ { property: "og:title", content: shareTitle },
117+ { property: "og:description", content: description },
118+ { property: "og:image", content: image },
119+ { property: "og:image:type", content: "image/png" },
120+ { property: "og:image:width", content: "1200" },
121+ { property: "og:image:height", content: "630" },
122+ { property: "og:image:alt", content: shareTitle },
123+ { name: "twitter:card", content: "summary_large_image" },
124+ { name: "twitter:title", content: shareTitle },
125+ { name: "twitter:description", content: description },
126+ { name: "twitter:image", content: image },
127+ { name: "twitter:image:alt", content: shareTitle },
128+ ];
129+}
+3−2
22 import { Form } from "react-router";
33
44 import type { Route } from "./+types/device";
5+import { page } from "../lib/meta";
56 import { Button, ErrorText, Field, Input } from "../components/ui";
67 import { identity } from "../lib/services.server";
78 import { assertSameOrigin, requireUser } from "../lib/session.server";
89
9−export function meta({}: Route.MetaArgs) {
10− return [{ title: "Connect an application · g1t" }];
10+export function meta(args: Route.MetaArgs) {
11+ return page(args, { title: "Connect an application · g1t" });
1112 }
1213
1314 /** Where a tool sends a person to approve its sign-in. */
+8−4
22 import { Form } from "react-router";
33
44 import type { Route } from "./+types/explore";
5+import { page } from "../lib/meta";
56 import { RepoList } from "../components/repo-list";
67 import { notACredential } from "../components/ui";
78 import { repos } from "../lib/services.server";
89 import { getViewer } from "../lib/session.server";
910
10−export function meta({ loaderData }: Route.MetaArgs) {
11− return [
12− { title: loaderData?.query ? `${loaderData.query} · Search · g1t` : "Explore · g1t" },
13− ];
11+export function meta({ loaderData, ...args }: Route.MetaArgs) {
12+ return page(args, {
13+ title: loaderData?.query ? `${loaderData.query} · Search · g1t` : "Explore · g1t",
14+ description: loaderData?.query
15+ ? `Public repositories on g1t matching "${loaderData.query}".`
16+ : "Public repositories on g1t, and the agents at work on them.",
17+ });
1418 }
1519
1620 /** Serves both /explore and /search?q=. */
+3−2
11 import { Form, Link } from "react-router";
22
33 import type { Route } from "./+types/forgot";
4+import { page } from "../lib/meta";
45 import { AuthCard } from "../components/auth-card";
56 import { Button, Field, Input } from "../components/ui";
67 import { identity } from "../lib/services.server";
78 import { assertSameOrigin } from "../lib/session.server";
89
9−export function meta({}: Route.MetaArgs) {
10− return [{ title: "Reset your password · g1t" }];
10+export function meta(args: Route.MetaArgs) {
11+ return page(args, { title: "Reset your password · g1t" });
1112 }
1213
1314 export async function action({ request }: Route.ActionArgs) {
+7−9
66 import type { Lifecycle, Pull, Repo } from "@g1t/contracts";
77
88 import type { Route } from "./+types/home";
9+import { page } from "../lib/meta";
910 import { Landing } from "../components/landing";
1011 import type { ShellData } from "../components/shell";
1112 import { STAGE_LABEL, StageDots } from "../components/lifecycle";
2526
2627 const dollars = (micros: number) => `$${(Math.max(0, micros) / 1_000_000).toFixed(2)}`;
2728
28−export function meta({}: Route.MetaArgs) {
29− return [
30− { title: "g1t — Git for AI scale" },
31− {
32− name: "description",
33− content:
34− "A git forge for thousands of agents working on the same code at once: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.",
35− },
36− ];
29+export function meta(args: Route.MetaArgs) {
30+ return page(args, {
31+ title: "g1t — Git for AI scale",
32+ description:
33+ "A git forge for thousands of agents working on the same code at once: every change isolated, every decision recorded, every change landed in order. Open source, built on Cloudflare.",
34+ });
3735 }
3836
3937 export function headers({ loaderHeaders }: Route.HeadersArgs) {
+3−2
11 import { Form, Link, redirect } from "react-router";
22
33 import type { Route } from "./+types/login";
4+import { page } from "../lib/meta";
45 import { AuthCard } from "../components/auth-card";
56 import { Button, ErrorText, Field, Input } from "../components/ui";
67 import { identity } from "../lib/services.server";
1112 startSession,
1213 } from "../lib/session.server";
1314
14−export function meta({}: Route.MetaArgs) {
15− return [{ title: "Sign in · g1t" }];
15+export function meta(args: Route.MetaArgs) {
16+ return page(args, { title: "Sign in · g1t" });
1617 }
1718
1819 export function loader({ request, context }: Route.LoaderArgs) {
+3−2
33 import { Form, redirect, useNavigation } from "react-router";
44
55 import type { Route } from "./+types/new";
6+import { page } from "../lib/meta";
67 import { Button, ErrorText, Field, Input } from "../components/ui";
78 import { repos } from "../lib/services.server";
89 import { assertSameOrigin, requireUser } from "../lib/session.server";
910
10−export function meta({}: Route.MetaArgs) {
11− return [{ title: "New project · g1t" }];
11+export function meta(args: Route.MetaArgs) {
12+ return page(args, { title: "New project · g1t" });
1213 }
1314
1415 export function loader({ request, context }: Route.LoaderArgs) {
+3−2
44 import { decodeOAuthClient, isRegisteredRedirect } from "@g1t/contracts";
55
66 import type { Route } from "./+types/oauth-authorize";
7+import { page } from "../lib/meta";
78 import { Button } from "../components/ui";
89 import { identity } from "../lib/services.server";
910 import { assertSameOrigin, requireUser } from "../lib/session.server";
1011
11−export function meta({}: Route.MetaArgs) {
12− return [{ title: "Sign in to an application · g1t" }];
12+export function meta(args: Route.MetaArgs) {
13+ return page(args, { title: "Sign in to an application · g1t" });
1314 }
1415
1516 type Checked =
+6−8
33 import { DEPLOYMENTS_ALLOWANCE, MICROS_PER_DOLLAR, type Price } from "@g1t/contracts";
44
55 import type { Route } from "./+types/pricing";
6+import { page } from "../lib/meta";
67 import { TimeAgo } from "../components/ui";
78 import { billing } from "../lib/services.server";
89
9−export function meta() {
10− return [
11− { title: "Pricing · g1t" },
12− {
13− name: "description",
14− content: "g1t passes its costs through: what Cloudflare and model providers charge g1t, plus a set markup. No seats.",
15− },
16− ];
10+export function meta(args: Route.MetaArgs) {
11+ return page(args, {
12+ title: "Pricing · g1t",
13+ description: "g1t passes its costs through: what Cloudflare and model providers charge g1t, plus a set markup. No seats.",
14+ });
1715 }
1816
1917 export async function loader() {
+3−2
11 import { Form, Link, redirect } from "react-router";
22
33 import type { Route } from "./+types/register";
4+import { page } from "../lib/meta";
45 import { AuthCard } from "../components/auth-card";
56 import { Button, ErrorText, Field, Input } from "../components/ui";
67 import { identity } from "../lib/services.server";
1112 startSession,
1213 } from "../lib/session.server";
1314
14−export function meta({}: Route.MetaArgs) {
15− return [{ title: "Create an account · g1t" }];
15+export function meta(args: Route.MetaArgs) {
16+ return page(args, { title: "Create an account · g1t" });
1617 }
1718
1819 export function loader({ request, context }: Route.LoaderArgs) {
+3−2
55 import type { Annotation, Job, StepState } from "@g1t/contracts";
66
77 import type { Route } from "./+types/actions-run";
8+import { page } from "../../lib/meta";
89 import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions";
910 import { Button, ErrorText, TimeAgo } from "../../components/ui";
1011 import { listArtifacts } from "../../lib/artifacts.server";
1112 import { actions } from "../../lib/services.server";
1213 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1314
14−export function meta({ loaderData, params }: Route.MetaArgs) {
15+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
1516 const run = loaderData?.detail.run;
16− return [{ title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` }];
17+ return page(args, { title: `${run ? `${run.title || run.name} #${run.number}` : "Run"} · ${params.owner}/${params.repo} · g1t` });
1718 }
1819
1920 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
66 import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts";
77
88 import type { Route } from "./+types/actions";
9+import { page } from "../../lib/meta";
910 import { Notes, StatusIcon, duration, shortRef } from "../../components/actions";
1011 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
1112 import { actions } from "../../lib/services.server";
1213 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1314
14−export function meta({ params }: Route.MetaArgs) {
15− return [{ title: `Workflows · ${params.owner}/${params.repo} · g1t` }];
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Workflows · ${params.owner}/${params.repo} · g1t` });
1617 }
1718
1819 export async function loader({ params, context, request }: Route.LoaderArgs) {
+3−2
11 import type { Route } from "./+types/blob";
2+import { page } from "../../lib/meta";
23 import { BlobView } from "../../components/repo-view";
34 import { highlight, highlightLines } from "../../lib/highlight.server";
45 import { repos } from "../../lib/services.server";
56 import { getViewer, unwrap } from "../../lib/session.server";
67
7−export function meta({ params }: Route.MetaArgs) {
8− return [{ title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` }];
8+export function meta({ params, ...args }: Route.MetaArgs) {
9+ return page(args, { title: `${params["*"]} · ${params.owner}/${params.repo} · g1t` });
910 }
1011
1112 export async function loader({ params, context, request }: Route.LoaderArgs) {
+5−4
66
77 import { parseCommitMessage } from "../../lib/commit-message";
88 import type { Route } from "./+types/commit";
9+import { page } from "../../lib/meta";
910 import { DiffView } from "../../components/diff-view";
1011 import { Avatar, TimeAgo } from "../../components/ui";
1112 import { immutable } from "../../lib/immutable.server";
1718 return { "Server-Timing": loaderHeaders.get("Server-Timing") ?? "" };
1819 }
1920
20−export function meta({ loaderData, params }: Route.MetaArgs) {
21+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
2122 const subject = loaderData?.commit.message.split("\n")[0];
22− return [
23− { title: `${subject ? `${subject} · ` : ""}${params.hash.slice(0, 7)} · ${params.owner}/${params.repo} · g1t` },
24− ];
23+ return page(args, {
24+ title: `${subject ? `${subject} · ` : ""}${params.hash.slice(0, 7)} · ${params.owner}/${params.repo} · g1t`,
25+ });
2526 }
2627
2728 /** How far back a short hash is looked for. */
+3−2
44 import type { Commit } from "@g1t/contracts";
55
66 import type { Route } from "./+types/commits";
7+import { page } from "../../lib/meta";
78 import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
89 import { repos } from "../../lib/services.server";
910 import { getViewer, unwrap } from "../../lib/session.server";
1011
1112 const PAGE_SIZE = 50;
1213
13−export function meta({ params }: Route.MetaArgs) {
14− return [{ title: `Commits · ${params.owner}/${params.repo} · g1t` }];
14+export function meta({ params, ...args }: Route.MetaArgs) {
15+ return page(args, { title: `Commits · ${params.owner}/${params.repo} · g1t` });
1516 }
1617
1718 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
22 import { Link, data } from "react-router";
33
44 import type { Route } from "./+types/deployment";
5+import { page } from "../../lib/meta";
56 import { TimeAgo } from "../../components/ui";
67 import { deployments } from "../../lib/services.server";
78 import { getViewer, roleIn } from "../../lib/session.server";
89
9−export function meta({ params }: Route.MetaArgs) {
10− return [{ title: `Deployment · ${params.owner}/${params.repo} · g1t` }];
10+export function meta({ params, ...args }: Route.MetaArgs) {
11+ return page(args, { title: `Deployment · ${params.owner}/${params.repo} · g1t` });
1112 }
1213
1314 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
77 import { host, StatusDot } from "../../components/deploy";
88
99 import type { Route } from "./+types/deployments";
10+import { page } from "../../lib/meta";
1011 import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
1112 import { billing, deployments } from "../../lib/services.server";
1213 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1314
14−export function meta({ params }: Route.MetaArgs) {
15− return [{ title: `Deployments · ${params.owner}/${params.repo} · g1t` }];
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Deployments · ${params.owner}/${params.repo} · g1t` });
1617 }
1718
1819 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
44 import { PROVIDERS } from "@g1t/contracts";
55
66 import type { Route } from "./+types/issue-new";
7+import { page } from "../../lib/meta";
78 import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui";
89 import { Label } from "../../components/work";
910 import { integrations, work } from "../../lib/services.server";
1011 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
1112
12−export function meta({ params }: Route.MetaArgs) {
13− return [{ title: `New issue · ${params.owner}/${params.repo} · g1t` }];
13+export function meta({ params, ...args }: Route.MetaArgs) {
14+ return page(args, { title: `New issue · ${params.owner}/${params.repo} · g1t` });
1415 }
1516
1617 export async function loader({ request, params, context }: Route.LoaderArgs) {
+15−3
66 import { type Pull, PROVIDERS } from "@g1t/contracts";
77
88 import type { Route } from "./+types/issue";
9+import { excerpt, page } from "../../lib/meta";
910 import { Markdown } from "../../components/markdown";
1011 import {
1112 Avatar,
3536
3637 const REFRESH_MS = 4000;
3738
38−export function meta({ loaderData, params }: Route.MetaArgs) {
39− const title = loaderData ? `${loaderData.issue.title} · Issue #${loaderData.issue.number} · ` : "";
40− return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
39+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
40+ const issue = loaderData?.issue;
41+ const title = issue ? `${issue.title} · Issue #${issue.number} · ` : "";
42+ const state = issue?.state === "open" ? "Open" : issue?.reason === "not_planned" ? "Closed as not planned" : "Closed";
43+ const body = excerpt(issue?.body);
44+ return page(args, {
45+ title: `${title}${params.owner}/${params.repo} · g1t`,
46+ description: issue
47+ ? `${state} issue #${issue.number} on ${params.owner}/${params.repo}, opened by ${issue.author.username}.${body ? ` ${body}` : ""}`
48+ : null,
49+ // The card shows the title and the state.
50+ version: issue ? [issue.title, issue.state, issue.reason] : undefined,
51+ type: "article",
52+ });
4153 }
4254
4355 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
33 import { Form, Link, useNavigation } from "react-router";
44
55 import type { Route } from "./+types/issues";
6+import { page } from "../../lib/meta";
67 import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
78 import {
89 Assignee,
1718 /** As many sandboxes as one request may start. */
1819 const MAX_ASSIGNED_AT_ONCE = 10;
1920
20−export function meta({ params }: Route.MetaArgs) {
21− return [{ title: `Issues · ${params.owner}/${params.repo} · g1t` }];
21+export function meta({ params, ...args }: Route.MetaArgs) {
22+ return page(args, { title: `Issues · ${params.owner}/${params.repo} · g1t` });
2223 }
2324
2425 export async function loader({ params, context, request }: Route.LoaderArgs) {
+3−2
44 import type { Project } from "@g1t/contracts";
55
66 import type { Route } from "./+types/layout";
7+import { page } from "../../lib/meta";
78 import { Pill, TabLink as Tab } from "../../components/ui";
89 import { projects, repos, work } from "../../lib/services.server";
910 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
1011
11−export function meta({ loaderData: loaded, params }: Route.MetaArgs) {
12− return [{ title: `${loaded?.project?.name ?? params.repo} · ${params.owner} · g1t` }];
12+export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
13+ return page(args, { title: `${loaded?.project?.name ?? params.repo} · ${params.owner} · g1t` });
1314 }
1415
1516 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
55 import { Form, Link, data, useNavigation, useRevalidator } from "react-router";
66
77 import type { Route } from "./+types/plan";
8+import { page } from "../../lib/meta";
89 import { Markdown } from "../../components/markdown";
910 import { Activity, Exchanges } from "../../components/activity";
1011 import { Outcome } from "../../components/outcome";
2122
2223 const REFRESH_MS = 4000;
2324
24−export function meta({ params }: Route.MetaArgs) {
25− return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }];
25+export function meta({ params, ...args }: Route.MetaArgs) {
26+ return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
2627 }
2728
2829 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
55 import type { Plan } from "@g1t/contracts";
66
77 import type { Route } from "./+types/plans";
8+import { page } from "../../lib/meta";
89 import { Button, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui";
910 import { work } from "../../lib/services.server";
1011 import {
1516 unwrap,
1617 } from "../../lib/session.server";
1718
18−export function meta({ params }: Route.MetaArgs) {
19− return [{ title: `Plan · ${params.owner}/${params.repo} · g1t` }];
19+export function meta({ params, ...args }: Route.MetaArgs) {
20+ return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
2021 }
2122
2223 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
22 import { Form, redirect } from "react-router";
33
44 import type { Route } from "./+types/pull-new";
5+import { page } from "../../lib/meta";
56 import {
67 Button,
78 CopyLine,
1415 import { repos, work } from "../../lib/services.server";
1516 import { assertSameOrigin, requireUser, unwrap } from "../../lib/session.server";
1617
17−export function meta({ params }: Route.MetaArgs) {
18− return [{ title: `New pull request · ${params.owner}/${params.repo} · g1t` }];
18+export function meta({ params, ...args }: Route.MetaArgs) {
19+ return page(args, { title: `New pull request · ${params.owner}/${params.repo} · g1t` });
1920 }
2021
2122 export async function loader({ request, params, context }: Route.LoaderArgs) {
+15−5
3636 } from "@g1t/contracts";
3737
3838 import type { Route } from "./+types/pull";
39+import { excerpt, page } from "../../lib/meta";
3940 import { DiffView } from "../../components/diff-view";
4041 import { LifecyclePanel } from "../../components/lifecycle";
4142 import { Markdown } from "../../components/markdown";
6869 const TABS = ["conversation", "session", "changes"] as const;
6970 type Tab = (typeof TABS)[number];
7071
71−export function meta({ loaderData, params }: Route.MetaArgs) {
72− const title = loaderData
73− ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · `
74− : "";
75− return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
72+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
73+ const pull = loaderData?.pull;
74+ const title = pull ? `${pull.title} · Pull request #${pull.number} · ` : "";
75+ const state = { draft: "Draft", open: "Open", merged: "Merged", closed: "Closed" }[pull?.status ?? "open"];
76+ const body = excerpt(pull?.body);
77+ return page(args, {
78+ title: `${title}${params.owner}/${params.repo} · g1t`,
79+ description: pull
80+ ? `${state} pull request #${pull.number} on ${params.owner}/${params.repo} by ${pull.author.username}.${body ? ` ${body}` : ""}`
81+ : null,
82+ // The card shows the title and the state.
83+ version: pull ? [pull.title, pull.status] : undefined,
84+ type: "article",
85+ });
7686 }
7787
7888 export async function loader({ params, context, request }: Route.LoaderArgs) {
+3−2
22 import { Link } from "react-router";
33
44 import type { Route } from "./+types/pulls";
5+import { page } from "../../lib/meta";
56 import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
67 import { CheckBadge } from "../../components/checks";
78 import { ChangeSize, PullIcon, StateTabs } from "../../components/work";
89 import { work } from "../../lib/services.server";
910 import { getViewer, unwrap } from "../../lib/session.server";
1011
11−export function meta({ params }: Route.MetaArgs) {
12− return [{ title: `Pull requests · ${params.owner}/${params.repo} · g1t` }];
12+export function meta({ params, ...args }: Route.MetaArgs) {
13+ return page(args, { title: `Pull requests · ${params.owner}/${params.repo} · g1t` });
1314 }
1415
1516 export async function loader({ params, context, request }: Route.LoaderArgs) {
+3−2
1414 import type { QueueEntry, QueueState } from "@g1t/contracts";
1515
1616 import type { Route } from "./+types/queue";
17+import { page } from "../../lib/meta";
1718 import { Avatar, ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
1819 import { repos, work } from "../../lib/services.server";
1920 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
2021
2122 const REFRESH_MS = 4000;
2223
23−export function meta({ params }: Route.MetaArgs) {
24− return [{ title: `Merge queue · ${params.owner}/${params.repo} · g1t` }];
24+export function meta({ params, ...args }: Route.MetaArgs) {
25+ return page(args, { title: `Merge queue · ${params.owner}/${params.repo} · g1t` });
2526 }
2627
2728 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
11 import type { Route } from "./+types/secrets";
2+import { page } from "../../lib/meta";
23 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
34 import { SecretsPanel } from "../../components/secrets";
45 import { actOnSecrets, loadSecrets } from "../../lib/secrets.server";
56 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
67
7−export function meta({ params }: Route.MetaArgs) {
8− return [{ title: `Secrets and variables · ${params.owner}/${params.repo} · g1t` }];
8+export function meta({ params, ...args }: Route.MetaArgs) {
9+ return page(args, { title: `Secrets and variables · ${params.owner}/${params.repo} · g1t` });
910 }
1011
1112 const ownerOf = (params: { owner: string; repo: string }) => ({ repo: { namespace: params.owner, name: params.repo } });
+3−2
44 import type { DependencyLink } from "@g1t/contracts";
55
66 import type { Route } from "./+types/settings-dependencies";
7+import { page } from "../../lib/meta";
78 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
89 import { Button, EmptyState, ErrorText, Field, Input } from "../../components/ui";
910 import { projects } from "../../lib/services.server";
1011 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
1112
12−export function meta({ params }: Route.MetaArgs) {
13− return [{ title: `Dependencies · ${params.owner}/${params.repo} · g1t` }];
13+export function meta({ params, ...args }: Route.MetaArgs) {
14+ return page(args, { title: `Dependencies · ${params.owner}/${params.repo} · g1t` });
1415 }
1516
1617 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
11 import { Form, Link, data, useNavigation } from "react-router";
22
33 import type { Route } from "./+types/settings-deployments";
4+import { page } from "../../lib/meta";
45 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
56 import { Button, ErrorText, Field, Input } from "../../components/ui";
67 import { deployments } from "../../lib/services.server";
78 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
89
9−export function meta({ params }: Route.MetaArgs) {
10− return [{ title: `Deployment settings · ${params.owner}/${params.repo} · g1t` }];
10+export function meta({ params, ...args }: Route.MetaArgs) {
11+ return page(args, { title: `Deployment settings · ${params.owner}/${params.repo} · g1t` });
1112 }
1213
1314 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
33
44 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
55 import type { Route } from "./+types/settings-repository";
6+import { page } from "../../lib/meta";
67 import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
78 import { repos, work } from "../../lib/services.server";
89 import {
1314 unwrap,
1415 } from "../../lib/session.server";
1516
16−export function meta({ params }: Route.MetaArgs) {
17− return [{ title: `Repository settings · ${params.owner}/${params.repo} · g1t` }];
17+export function meta({ params, ...args }: Route.MetaArgs) {
18+ return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` });
1819 }
1920
2021 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
22 import { Form, Link, data, useNavigation } from "react-router";
33
44 import type { Route } from "./+types/settings";
5+import { page } from "../../lib/meta";
56 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
67 import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
78 import { projects } from "../../lib/services.server";
89 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
910
10−export function meta({ params }: Route.MetaArgs) {
11− return [{ title: `Settings · ${params.owner}/${params.repo} · g1t` }];
11+export function meta({ params, ...args }: Route.MetaArgs) {
12+ return page(args, { title: `Settings · ${params.owner}/${params.repo} · g1t` });
1213 }
1314
1415 export async function loader({ params, context }: Route.LoaderArgs) {
+7−2
22 import { Link, data } from "react-router";
33
44 import type { Route } from "./+types/soon";
5+import { page } from "../../lib/meta";
56 import { ROADMAP, roadmapIn, roadmapItem } from "../../lib/roadmap";
67
7−export function meta({ params }: Route.MetaArgs) {
8+export function meta({ params, ...args }: Route.MetaArgs) {
89 const item = roadmapItem(params.feature);
9− return [{ title: `${item?.title ?? "Soon"} · ${params.owner}/${params.repo} · g1t` }];
10+ return page(args, {
11+ title: `${item?.title ?? "Soon"} · ${params.owner}/${params.repo} · g1t`,
12+ description: item ? `Soon on g1t: ${item.summary}` : null,
13+ version: item ? [item.title, item.summary] : undefined,
14+ });
1015 }
1116
1217 export function loader({ params }: Route.LoaderArgs) {
+3−2
11 import type { Route } from "./+types/tree";
2+import { page } from "../../lib/meta";
23 import { TreeView } from "../../components/repo-view";
34 import { repos } from "../../lib/services.server";
45 import { getViewer, unwrap } from "../../lib/session.server";
56
6−export function meta({ params }: Route.MetaArgs) {
7+export function meta({ params, ...args }: Route.MetaArgs) {
78 const path = params["*"] ? `${params["*"]} · ` : "";
8− return [{ title: `${path}${params.owner}/${params.repo} · g1t` }];
9+ return page(args, { title: `${path}${params.owner}/${params.repo} · g1t` });
910 }
1011
1112 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
11 import type { Route } from "./+types/webhooks";
2+import { page } from "../../lib/meta";
23 import { RepoSettingsTabs } from "../../components/repo-settings-tabs";
34 import { WebhooksPanel } from "../../components/webhooks";
45 import { actOnWebhooks, loadWebhooks } from "../../lib/webhooks.server";
56 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
67
7−export function meta({ params }: Route.MetaArgs) {
8− return [{ title: `Webhooks · ${params.owner}/${params.repo} · g1t` }];
8+export function meta({ params, ...args }: Route.MetaArgs) {
9+ return page(args, { title: `Webhooks · ${params.owner}/${params.repo} · g1t` });
910 }
1011
1112 function ownerOf(params: { owner: string; repo: string }) {
+3−2
11 import { Form, Link, redirect } from "react-router";
22
33 import type { Route } from "./+types/reset";
4+import { page } from "../lib/meta";
45 import { AuthCard } from "../components/auth-card";
56 import { Button, ErrorText, Field, Input } from "../components/ui";
67 import { identity } from "../lib/services.server";
78 import { assertSameOrigin } from "../lib/session.server";
89
9−export function meta({}: Route.MetaArgs) {
10− return [{ title: "Choose a new password · g1t" }];
10+export function meta(args: Route.MetaArgs) {
11+ return page(args, { title: "Choose a new password · g1t" });
1112 }
1213
1314 export function loader({ request }: Route.LoaderArgs) {
+3−2
22 import { Form, Link } from "react-router";
33
44 import type { Route } from "./+types/settings";
5+import { page } from "../lib/meta";
56 import { Button, ErrorText, Field, Input, TimeAgo } from "../components/ui";
67 import { assertSameOrigin, requireUser } from "../lib/session.server";
78
8−export function meta({}: Route.MetaArgs) {
9− return [{ title: "Settings · g1t" }];
9+export function meta(args: Route.MetaArgs) {
10+ return page(args, { title: "Settings · g1t" });
1011 }
1112
1213 export async function loader({ request, context }: Route.LoaderArgs) {
+3−2
22 import { redirect } from "react-router";
33
44 import type { Route } from "./+types/verify";
5+import { page } from "../lib/meta";
56 import { ButtonLink } from "../components/ui";
67 import { identity } from "../lib/services.server";
78 import { assertSameOrigin, requireUser } from "../lib/session.server";
89
9−export function meta({}: Route.MetaArgs) {
10− return [{ title: "Confirm your email · g1t" }];
10+export function meta(args: Route.MetaArgs) {
11+ return page(args, { title: "Confirm your email · g1t" });
1112 }
1213
1314 /** Follows the link from the confirmation email. */
+3−2
1212 } from "@g1t/contracts";
1313
1414 import type { Route } from "./+types/billing";
15+import { page } from "../../lib/meta";
1516 import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
1617 import { billing, deployments } from "../../lib/services.server";
1718 import {
2526 /** What can be added in one payment, in dollars. */
2627 const AMOUNTS = [10, 25, 50, 100];
2728
28−export function meta({ params }: Route.MetaArgs) {
29− return [{ title: `Billing · ${params.owner} · g1t` }];
29+export function meta({ params, ...args }: Route.MetaArgs) {
30+ return page(args, { title: `Billing · ${params.owner} · g1t` });
3031 }
3132
3233 export async function loader({ params, context, request }: Route.LoaderArgs) {
+3−2
2323 } from "@g1t/contracts";
2424
2525 import type { Route } from "./+types/integrations";
26+import { page } from "../../lib/meta";
2627 import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
2728 import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
2829 import { billing, integrations, repos } from "../../lib/services.server";
2930 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
3031
31−export function meta({ params }: Route.MetaArgs) {
32− return [{ title: `Integrations · ${params.owner} · g1t` }];
32+export function meta({ params, ...args }: Route.MetaArgs) {
33+ return page(args, { title: `Integrations · ${params.owner} · g1t` });
3334 }
3435
3536 const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
+3−2
22 import { Outlet, data, useLocation, useRouteLoaderData } from "react-router";
33
44 import type { Route } from "./+types/layout";
5+import { page } from "../../lib/meta";
56 import { Avatar, ButtonLink, Pill, TabLink } from "../../components/ui";
67 import { identity } from "../../lib/services.server";
78 import { getViewer, roleIn } from "../../lib/session.server";
89
9−export function meta({ loaderData, params }: Route.MetaArgs) {
10− return [{ title: `${loaderData?.workspace.name ?? params.owner} · g1t` }];
10+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
11+ return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` });
1112 }
1213
1314 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
11 import { Form, redirect } from "react-router";
22
33 import type { Route } from "./+types/new";
4+import { page } from "../../lib/meta";
45 import { Button, ErrorText, Field, Input } from "../../components/ui";
56 import { identity } from "../../lib/services.server";
67 import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server";
78
8−export function meta({}: Route.MetaArgs) {
9− return [{ title: "New workspace · g1t" }];
9+export function meta(args: Route.MetaArgs) {
10+ return page(args, { title: "New workspace · g1t" });
1011 }
1112
1213 export function loader({ request, context }: Route.LoaderArgs) {
+3−2
11 import { Form } from "react-router";
22
33 import type { Route } from "./+types/people";
4+import { page } from "../../lib/meta";
45 import { Avatar, Button, ErrorText, Field, Input, Pill } from "../../components/ui";
56 import { identity } from "../../lib/services.server";
67 import {
1112 unwrap,
1213 } from "../../lib/session.server";
1314
14−export function meta({ params }: Route.MetaArgs) {
15− return [{ title: `People · ${params.owner} · g1t` }];
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `People · ${params.owner} · g1t` });
1617 }
1718
1819 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
11 import type { Route } from "./+types/secrets";
2+import { page } from "../../lib/meta";
23 import { SecretsPanel } from "../../components/secrets";
34 import { actOnSecrets, loadSecrets } from "../../lib/secrets.server";
45 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
56
6−export function meta({ params }: Route.MetaArgs) {
7− return [{ title: `Secrets and variables · ${params.owner} · g1t` }];
7+export function meta({ params, ...args }: Route.MetaArgs) {
8+ return page(args, { title: `Secrets and variables · ${params.owner} · g1t` });
89 }
910
1011 export async function loader({ params, context, request }: Route.LoaderArgs) {
+3−2
11 import { Form, data, redirect } from "react-router";
22
33 import type { Route } from "./+types/settings";
4+import { page } from "../../lib/meta";
45 import { Button, ErrorText, Field, Input } from "../../components/ui";
56 import { identity } from "../../lib/services.server";
67 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
78
8−export function meta({ params }: Route.MetaArgs) {
9− return [{ title: `Settings · ${params.owner} · g1t` }];
9+export function meta({ params, ...args }: Route.MetaArgs) {
10+ return page(args, { title: `Settings · ${params.owner} · g1t` });
1011 }
1112
1213 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
11 import { Form } from "react-router";
22
33 import type { Route } from "./+types/tokens";
4+import { page } from "../../lib/meta";
45 import {
56 Button,
67 CopyLine,
1920 unwrap,
2021 } from "../../lib/session.server";
2122
22−export function meta({ params }: Route.MetaArgs) {
23− return [{ title: `Access tokens · ${params.owner} · g1t` }];
23+export function meta({ params, ...args }: Route.MetaArgs) {
24+ return page(args, { title: `Access tokens · ${params.owner} · g1t` });
2425 }
2526
2627 export async function loader({ params, context }: Route.LoaderArgs) {
+3−2
44 import { MICROS_PER_DOLLAR, type UsageSlice } from "@g1t/contracts";
55
66 import type { Route } from "./+types/usage";
7+import { page } from "../../lib/meta";
78 import { ButtonLink } from "../../components/ui";
89 import { billing } from "../../lib/services.server";
910 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
3435 return new Date(day.getTime() - (days - 1) * 86_400_000);
3536 }
3637
37−export function meta({ params }: Route.MetaArgs) {
38− return [{ title: `Usage · ${params.owner} · g1t` }];
38+export function meta({ params, ...args }: Route.MetaArgs) {
39+ return page(args, { title: `Usage · ${params.owner} · g1t` });
3940 }
4041
4142 export async function loader({ params, context, request }: Route.LoaderArgs) {
+3−2
11 import type { Route } from "./+types/webhooks";
2+import { page } from "../../lib/meta";
23 import { WebhooksPanel } from "../../components/webhooks";
34 import { actOnWebhooks, loadWebhooks } from "../../lib/webhooks.server";
45 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
56
6−export function meta({ params }: Route.MetaArgs) {
7− return [{ title: `Webhooks · ${params.owner} · g1t` }];
7+export function meta({ params, ...args }: Route.MetaArgs) {
8+ return page(args, { title: `Webhooks · ${params.owner} · g1t` });
89 }
910
1011 export async function loader({ params, context, request }: Route.LoaderArgs) {
+0−0

Binary or large file; its contents are not shown.

+1−0
10461046 | `services/billing` | Rust | Worker + D1 + Stripe | Each workspace's agent credit: payments, the ledger of every run, and the gate on starting one |
10471047 | `services/events` | Rust | Worker + Queues + D1 | The event bus: durable log, and one queue per subscribing service |
10481048 | `services/runner`, `crates/runner` | TypeScript, Rust | Worker + Containers | Starts a sandbox per g1t agent; the program inside runs the agent harness and reports through the public API |
1049+| `services/og` | TypeScript | Worker + Cache API | Social cards at `og.g1t.sh`: one PNG per page of the site and the docs (satori and resvg), looked up as an anonymous visitor, so nothing private appears on one |
10491050 | `apps/web` | TypeScript | Worker | Server-rendered site. Holds no data; calls services over RPC. |
10501051 | `apps/docs` | TypeScript | Worker (static) | Documentation and the API explorer |
10511052 | `apps/api` | Rust | Worker | REST API (`api.g1t.sh`), MCP server (`mcp.g1t.sh`) and OpenAPI document, all generated from one list of operations; the OAuth endpoints |
+213−0
16171617 "resolved": "services/models",
16181618 "link": true
16191619 },
1620+ "node_modules/@g1t/og": {
1621+ "resolved": "services/og",
1622+ "link": true
1623+ },
16201624 "node_modules/@g1t/pages": {
16211625 "resolved": "services/pages",
16221626 "link": true
40174021 "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==",
40184022 "license": "MIT"
40194023 },
4024+ "node_modules/@resvg/resvg-wasm": {
4025+ "version": "2.6.2",
4026+ "resolved": "https://registry.npmjs.org/@resvg/resvg-wasm/-/resvg-wasm-2.6.2.tgz",
4027+ "integrity": "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==",
4028+ "license": "MPL-2.0",
4029+ "engines": {
4030+ "node": ">= 10"
4031+ }
4032+ },
40204033 "node_modules/@rolldown/binding-android-arm-eabi": {
40214034 "version": "1.2.12",
40224035 "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
43814394 "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
43824395 "license": "MIT"
43834396 },
4397+ "node_modules/@shuding/opentype.js": {
4398+ "version": "1.4.0-beta.0",
4399+ "resolved": "https://registry.npmjs.org/@shuding/opentype.js/-/opentype.js-1.4.0-beta.0.tgz",
4400+ "integrity": "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==",
4401+ "license": "MIT",
4402+ "dependencies": {
4403+ "fflate": "^0.7.3",
4404+ "string.prototype.codepointat": "^0.2.1"
4405+ },
4406+ "bin": {
4407+ "ot": "bin/ot"
4408+ },
4409+ "engines": {
4410+ "node": ">= 8.0.0"
4411+ }
4412+ },
43844413 "node_modules/@sindresorhus/is": {
43854414 "version": "7.2.0",
43864415 "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
50285057 "url": "https://github.com/sponsors/wooorm"
50295058 }
50305059 },
5060+ "node_modules/base64-js": {
5061+ "version": "0.0.8",
5062+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
5063+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
5064+ "license": "MIT",
5065+ "engines": {
5066+ "node": ">= 0.4"
5067+ }
5068+ },
50315069 "node_modules/baseline-browser-mapping": {
50325070 "version": "2.11.27",
50335071 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
51135151 "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
51145152 }
51155153 },
5154+ "node_modules/camelize": {
5155+ "version": "1.0.1",
5156+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
5157+ "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
5158+ "license": "MIT",
5159+ "funding": {
5160+ "url": "https://github.com/sponsors/ljharb"
5161+ }
5162+ },
51165163 "node_modules/caniuse-lite": {
51175164 "version": "1.0.30001814",
51185165 "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz",
52355282 "node": ">=6"
52365283 }
52375284 },
5285+ "node_modules/color-name": {
5286+ "version": "1.1.4",
5287+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
5288+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
5289+ "license": "MIT"
5290+ },
52385291 "node_modules/comma-separated-tokens": {
52395292 "version": "2.0.3",
52405293 "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
53065359 "uncrypto": "^0.1.3"
53075360 }
53085361 },
5362+ "node_modules/css-background-parser": {
5363+ "version": "0.1.0",
5364+ "resolved": "https://registry.npmjs.org/css-background-parser/-/css-background-parser-0.1.0.tgz",
5365+ "integrity": "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==",
5366+ "license": "MIT"
5367+ },
5368+ "node_modules/css-box-shadow": {
5369+ "version": "1.0.0-3",
5370+ "resolved": "https://registry.npmjs.org/css-box-shadow/-/css-box-shadow-1.0.0-3.tgz",
5371+ "integrity": "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==",
5372+ "license": "MIT"
5373+ },
5374+ "node_modules/css-color-keywords": {
5375+ "version": "1.0.0",
5376+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
5377+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
5378+ "license": "ISC",
5379+ "engines": {
5380+ "node": ">=4"
5381+ }
5382+ },
5383+ "node_modules/css-gradient-parser": {
5384+ "version": "0.0.17",
5385+ "resolved": "https://registry.npmjs.org/css-gradient-parser/-/css-gradient-parser-0.0.17.tgz",
5386+ "integrity": "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==",
5387+ "license": "MIT",
5388+ "engines": {
5389+ "node": ">=16"
5390+ }
5391+ },
53095392 "node_modules/css-select": {
53105393 "version": "6.0.0",
53115394 "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz",
53385421 ],
53395422 "license": "MIT"
53405423 },
5424+ "node_modules/css-to-react-native": {
5425+ "version": "3.2.0",
5426+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
5427+ "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
5428+ "license": "MIT",
5429+ "dependencies": {
5430+ "camelize": "^1.0.0",
5431+ "css-color-keywords": "^1.0.0",
5432+ "postcss-value-parser": "^4.0.2"
5433+ }
5434+ },
53415435 "node_modules/css-tree": {
53425436 "version": "3.2.1",
53435437 "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
56075701 "dev": true,
56085702 "license": "ISC"
56095703 },
5704+ "node_modules/emoji-regex-xs": {
5705+ "version": "2.0.1",
5706+ "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-2.0.1.tgz",
5707+ "integrity": "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==",
5708+ "license": "MIT",
5709+ "engines": {
5710+ "node": ">=10.0.0"
5711+ }
5712+ },
56105713 "node_modules/enhanced-resolve": {
56115714 "version": "5.26.0",
56125715 "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
57005803 "node": ">=6"
57015804 }
57025805 },
5806+ "node_modules/escape-html": {
5807+ "version": "1.0.3",
5808+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
5809+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
5810+ "license": "MIT"
5811+ },
57035812 "node_modules/escape-string-regexp": {
57045813 "version": "5.0.0",
57055814 "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
58075916 }
58085917 }
58095918 },
5919+ "node_modules/fflate": {
5920+ "version": "0.7.3",
5921+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.3.tgz",
5922+ "integrity": "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw==",
5923+ "license": "MIT"
5924+ },
58105925 "node_modules/find-proc": {
58115926 "version": "0.2.0",
58125927 "resolved": "https://registry.npmjs.org/find-proc/-/find-proc-0.2.0.tgz",
62646379 "url": "https://opencollective.com/unified"
62656380 }
62666381 },
6382+ "node_modules/hex-rgb": {
6383+ "version": "4.3.0",
6384+ "resolved": "https://registry.npmjs.org/hex-rgb/-/hex-rgb-4.3.0.tgz",
6385+ "integrity": "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==",
6386+ "license": "MIT",
6387+ "engines": {
6388+ "node": ">=6"
6389+ },
6390+ "funding": {
6391+ "url": "https://github.com/sponsors/sindresorhus"
6392+ }
6393+ },
62676394 "node_modules/html-escaper": {
62686395 "version": "3.0.3",
62696396 "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
67926919 "url": "https://opencollective.com/parcel"
67936920 }
67946921 },
6922+ "node_modules/linebreak": {
6923+ "version": "1.1.0",
6924+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
6925+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
6926+ "license": "MIT",
6927+ "dependencies": {
6928+ "base64-js": "0.0.8",
6929+ "unicode-trie": "^2.0.0"
6930+ }
6931+ },
67956932 "node_modules/lodash": {
67966933 "version": "4.18.1",
67976934 "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
80108147 "@pagefind/windows-x64": "1.5.2"
80118148 }
80128149 },
8150+ "node_modules/pako": {
8151+ "version": "0.2.9",
8152+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
8153+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
8154+ "license": "MIT"
8155+ },
8156+ "node_modules/parse-css-color": {
8157+ "version": "0.2.1",
8158+ "resolved": "https://registry.npmjs.org/parse-css-color/-/parse-css-color-0.2.1.tgz",
8159+ "integrity": "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==",
8160+ "license": "MIT",
8161+ "dependencies": {
8162+ "color-name": "^1.1.4",
8163+ "hex-rgb": "^4.1.0"
8164+ }
8165+ },
80138166 "node_modules/parse-entities": {
80148167 "version": "4.0.2",
80158168 "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
81758328 "node": ">=4"
81768329 }
81778330 },
8331+ "node_modules/postcss-value-parser": {
8332+ "version": "4.2.0",
8333+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
8334+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
8335+ "license": "MIT"
8336+ },
81788337 "node_modules/prismjs": {
81798338 "version": "1.30.0",
81808339 "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
88388997 "url": "https://github.com/sponsors/wooorm"
88398998 }
88408999 },
9000+ "node_modules/string.prototype.codepointat": {
9001+ "version": "0.2.1",
9002+ "resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
9003+ "integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
9004+ "license": "MIT"
9005+ },
88419006 "node_modules/stringify-entities": {
88429007 "version": "4.0.4",
88439008 "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
90639228 "pathe": "^2.0.3"
90649229 }
90659230 },
9231+ "node_modules/unicode-trie": {
9232+ "version": "2.0.0",
9233+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
9234+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
9235+ "license": "MIT",
9236+ "dependencies": {
9237+ "pako": "^0.2.5",
9238+ "tiny-inflate": "^1.0.0"
9239+ }
9240+ },
90669241 "node_modules/unified": {
90679242 "version": "11.0.5",
90689243 "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
994510120 "url": "https://github.com/sponsors/sindresorhus"
994610121 }
994710122 },
10123+ "node_modules/yoga-layout": {
10124+ "version": "3.2.1",
10125+ "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
10126+ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
10127+ "license": "MIT"
10128+ },
994810129 "node_modules/youch": {
994910130 "version": "4.1.0-beta.10",
995010131 "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
1001510196 "@g1t/contracts": "*"
1001610197 }
1001710198 },
10199+ "services/og": {
10200+ "name": "@g1t/og",
10201+ "version": "0.1.0",
10202+ "license": "MIT",
10203+ "dependencies": {
10204+ "@g1t/contracts": "*",
10205+ "@resvg/resvg-wasm": "^2.6.2",
10206+ "satori": "0.32.0"
10207+ }
10208+ },
10209+ "services/og/node_modules/satori": {
10210+ "version": "0.32.0",
10211+ "resolved": "https://registry.npmjs.org/satori/-/satori-0.32.0.tgz",
10212+ "integrity": "sha512-WwYeCWKjxfXvk7SsLSLTJhcPOrB2HCxbptqGIiYor0yqaPu3xA5uMTQ/4Dan5mxClhhk2Wn9ZE1pLGIlGdUxFQ==",
10213+ "license": "MPL-2.0",
10214+ "dependencies": {
10215+ "@shuding/opentype.js": "1.4.0-beta.0",
10216+ "css-background-parser": "^0.1.0",
10217+ "css-box-shadow": "1.0.0-3",
10218+ "css-gradient-parser": "^0.0.17",
10219+ "css-to-react-native": "^3.0.0",
10220+ "emoji-regex-xs": "^2.0.1",
10221+ "escape-html": "^1.0.3",
10222+ "linebreak": "^1.1.0",
10223+ "parse-css-color": "^0.2.1",
10224+ "postcss-value-parser": "^4.2.0",
10225+ "yoga-layout": "^3.2.1"
10226+ },
10227+ "engines": {
10228+ "node": ">=16"
10229+ }
10230+ },
1001810231 "services/pages": {
1001910232 "name": "@g1t/pages",
1002010233 "version": "0.1.0",
+1−0
2929 services/runner
3030 apps/api
3131 services/pages
32+ services/og
3233 apps/web
3334 apps/sudo
3435 apps/docs
+17−0
1+{
2+ "name": "@g1t/og",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "license": "MIT",
7+ "scripts": {
8+ "test": "node --test src/*.test.ts",
9+ "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
10+ "deploy": "wrangler deploy"
11+ },
12+ "dependencies": {
13+ "@g1t/contracts": "*",
14+ "@resvg/resvg-wasm": "^2.6.2",
15+ "satori": "0.32.0"
16+ }
17+}
+132−0
1+/**
2+ * The brand, as SVG for satori: the Fleet mark and the "g1t" wordmark, the
3+ * same shapes as `.g1t/brand/svg/logo.svg`. The wordmark is outlined, so
4+ * the card needs no ExtraBold font.
5+ */
6+
7+export const COLOR = {
8+ bg: "#0f0f11",
9+ surface: "#161618",
10+ raised: "#1e1e21",
11+ line: "#28282c",
12+ lineStrong: "#38383e",
13+ fg: "#ededef",
14+ fgSoft: "#dcdce0",
15+ muted: "#a0a0a8",
16+ faint: "#6a6a72",
17+ /** Lavender: the accent. */
18+ lavender: "#b6a8ff",
19+ lavenderDeep: "#6b56e8",
20+ /** Status colours, from the site's tokens. */
21+ mint: "#86efc4",
22+ danger: "#ff8394",
23+} as const;
24+
25+/** The mark's shapes, in its 32-unit box. */
26+function markShapes(front: string, behind: string): string {
27+ return (
28+ `<g transform="translate(0.7 0.5)">` +
29+ `<rect x="6.2" y="9.5" width="4.4" height="17" rx="2.2" fill="${behind}" fill-opacity="0.35"/>` +
30+ `<rect x="12.4" y="7" width="4.8" height="19.5" rx="2.4" fill="${behind}" fill-opacity="0.65"/>` +
31+ `<rect x="19" y="4.5" width="5.4" height="22" rx="2.7" fill="${front}"/>` +
32+ `<path d="M21.7 7.2 17.6 10.9" fill="none" stroke="${front}" stroke-width="4.6" stroke-linecap="round"/>` +
33+ `</g>`
34+ );
35+}
36+
37+const WORDMARK =
38+ "M30.42 21.29Q22.8 21.29 17.5 19.26Q12.21 17.24 9.16 13.67Q6.1 10.11 5.22 5.42L20.75 4.1Q21.19 5.66 22.46 6.86Q23.73 8.06 25.71 8.74Q27.69 9.42 30.37 9.42Q35.3 9.42 37.89 6.93Q40.48 4.44 40.48 -0.59V-9.28H39.89Q38.72 -6.54 36.65 -4.57Q34.57 -2.59 31.67 -1.56Q28.76 -0.54 25.1 -0.54Q18.7 -0.54 13.7 -3.64Q8.69 -6.74 5.83 -12.6Q2.98 -18.46 2.98 -26.76Q2.98 -35.3 5.91 -41.36Q8.84 -47.41 13.82 -50.63Q18.8 -53.86 25 -53.86Q28.81 -53.86 31.71 -52.66Q34.62 -51.46 36.77 -49.41Q38.92 -47.36 40.23 -44.73H40.48V-52.93H57.23V-2.1Q57.23 5.91 53.91 11.08Q50.59 16.26 44.56 18.77Q38.53 21.29 30.42 21.29ZM30.32 -13.38Q33.54 -13.38 35.89 -15.01Q38.23 -16.65 39.53 -19.7Q40.82 -22.75 40.82 -26.95Q40.82 -31.15 39.53 -34.23Q38.23 -37.3 35.89 -38.99Q33.54 -40.67 30.32 -40.67Q27.15 -40.67 24.88 -38.99Q22.61 -37.3 21.41 -34.23Q20.21 -31.15 20.21 -26.95Q20.21 -22.71 21.41 -19.65Q22.61 -16.6 24.88 -14.99Q27.15 -13.38 30.32 -13.38ZM94.12 -72.75V0H77.08V-57.37H76.84L60.33 -46.53V-61.57L77.47 -72.75ZM130.48 -52.93V-40.09H95.18V-52.93ZM103.28 -66.5H120.22V-17.04Q120.22 -14.75 121.23 -13.75Q122.23 -12.74 124.81 -12.74Q125.89 -12.74 127.62 -12.84Q129.36 -12.94 130.14 -13.04L131.11 -0.29Q129.11 0.1 126.43 0.22Q123.74 0.34 121.1 0.34Q112.07 0.34 107.68 -3.42Q103.28 -7.18 103.28 -14.79Z";
39+
40+/**
41+ * The lockup: the mark as tall as the wordmark's capitals, standing on its
42+ * baseline. Its box is 233 by 95; the baseline is at 72.75.
43+ */
44+export const LOCKUP_BOX = { width: 233, height: 95, baseline: 72.75 } as const;
45+
46+export function lockupSvg(front: string = COLOR.fg, behind: string = COLOR.lavender): string {
47+ return (
48+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${LOCKUP_BOX.width} ${LOCKUP_BOX.height}">` +
49+ `<g transform="translate(-22.77 -16.35) scale(3.3)">${markShapes(front, behind)}</g>` +
50+ `<path transform="translate(101.08 72.75)" fill="${front}" d="${WORDMARK}"/>` +
51+ `</svg>`
52+ );
53+}
54+
55+/** The mark alone, cropped to the figure: 18.2 by 22. */
56+export function markSvg(front: string = COLOR.fg, behind: string = COLOR.lavender): string {
57+ return (
58+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="6.9 5 18.2 22">${markShapes(front, behind)}</svg>`
59+ );
60+}
61+
62+/** As an image source; base64, which both satori and resvg read. The SVG is ASCII. */
63+/**
64+ * The card's backdrop: the brand card's dot grid, lavender light from the
65+ * top right, and the fleet standing large off the right edge, its 1s
66+ * fading downwards into the dark. Darker towards the bottom, where the
67+ * facts sit.
68+ */
69+export function backdropSvg(width: number, height: number, withFleet: boolean): string {
70+ // The fleet at 24 times its 32-unit size, its front 1 cut by the edge.
71+ const scale = 24;
72+ // Drawn solid and faded as a whole, so the flag and the bar it joins
73+ // read as one shape.
74+ const fleet = withFleet
75+ ? `<g mask="url(#fleetFade)" opacity="0.34">` +
76+ `<g transform="translate(${width - 22.4 * scale} ${height - 27.5 * scale + 40}) scale(${scale})">` +
77+ markShapes(COLOR.lavender, COLOR.lavender) +
78+ `</g></g>`
79+ : "";
80+ return (
81+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
82+ `<defs>` +
83+ `<pattern id="dots" width="40" height="40" patternUnits="userSpaceOnUse">` +
84+ `<circle cx="20" cy="15" r="1.4" fill="#2a2a30"/></pattern>` +
85+ `<radialGradient id="light" cx="${width - 160}" cy="-40" r="760" gradientUnits="userSpaceOnUse">` +
86+ `<stop offset="0" stop-color="${COLOR.lavenderDeep}" stop-opacity="0.30"/>` +
87+ `<stop offset="0.45" stop-color="${COLOR.lavenderDeep}" stop-opacity="0.09"/>` +
88+ `<stop offset="1" stop-color="${COLOR.lavenderDeep}" stop-opacity="0"/></radialGradient>` +
89+ `<linearGradient id="dusk" x1="0" y1="0" x2="0" y2="1">` +
90+ `<stop offset="0.5" stop-color="${COLOR.bg}" stop-opacity="0"/>` +
91+ `<stop offset="1" stop-color="${COLOR.bg}" stop-opacity="0.9"/></linearGradient>` +
92+ `<linearGradient id="fadeMask" x1="0" y1="0" x2="0" y2="1">` +
93+ `<stop offset="0.1" stop-color="#fff"/>` +
94+ `<stop offset="0.95" stop-color="#000"/></linearGradient>` +
95+ `<mask id="fleetFade" maskUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}">` +
96+ `<rect width="${width}" height="${height}" fill="url(#fadeMask)"/></mask>` +
97+ `</defs>` +
98+ `<rect width="${width}" height="${height}" fill="${COLOR.bg}"/>` +
99+ `<rect width="${width}" height="${height}" fill="url(#dots)"/>` +
100+ `<rect width="${width}" height="${height}" fill="url(#light)"/>` +
101+ fleet +
102+ `<rect width="${width}" height="${height}" fill="url(#dusk)"/>` +
103+ `</svg>`
104+ );
105+}
106+
107+export function dataUri(svg: string): string {
108+ return `data:image/svg+xml;base64,${btoa(svg)}`;
109+}
110+
111+/** Lucide's icons, as the site uses them, at 24 units. */
112+export const ICON = {
113+ issue: `<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>`,
114+ issueDone: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`,
115+ issueNotPlanned: `<circle cx="12" cy="12" r="10"/><line x1="9" x2="15" y1="15" y2="9"/>`,
116+ pull: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" x2="6" y1="9" y2="21"/>`,
117+ pullDraft: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M18 6V5"/><path d="M18 11v-1"/><line x1="6" x2="6" y1="9" y2="21"/>`,
118+ merged: `<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>`,
119+ pullClosed: `<circle cx="6" cy="6" r="3"/><path d="M6 9v12"/><path d="m21 3-6 6"/><path d="m21 9-6-6"/><path d="M18 11.5V15"/><circle cx="18" cy="18" r="3"/>`,
120+ box: `<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>`,
121+ sparkles: `<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/>`,
122+ book: `<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>`,
123+ user: `<circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>`,
124+ globe: `<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>`,
125+} as const;
126+
127+export function iconSvg(shapes: string, color: string): string {
128+ return (
129+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" ` +
130+ `stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${shapes}</svg>`
131+ );
132+}
+21−0
1+/**
2+ * Part of every cache key: bump it when the cards' design changes, so the
3+ * cards already cached are drawn again.
4+ */
5+export const DESIGN = "1";
6+
7+/**
8+ * The address a card is cached under: only the parameters that change it,
9+ * in order, so stray ones (trackers, cache busters) cannot fill the cache.
10+ */
11+export function cacheKey(url: URL): string {
12+ const wanted =
13+ url.pathname === "/docs" ? ["title", "section", "description"] : url.pathname === "/image" ? ["path", "v"] : [];
14+ const key = new URL(url.pathname, url.origin);
15+ key.searchParams.set("design", DESIGN);
16+ for (const name of wanted) {
17+ const value = url.searchParams.get(name);
18+ if (value !== null) key.searchParams.set(name, value);
19+ }
20+ return key.toString();
21+}
+429−0
1+/**
2+ * The cards' layouts, as the element trees satori lays out. One frame for
3+ * every kind: the lockup and the address along the top, the subject in the
4+ * middle, the facts along the bottom, on the site's very dark gray with a
5+ * dot grid and lavender kept for accents.
6+ */
7+import { COLOR, ICON, LOCKUP_BOX, backdropSvg, dataUri, iconSvg, lockupSvg } from "./brand.ts";
8+import type { Card, IssueState } from "./resolve.ts";
9+import type { PullStatus } from "@g1t/contracts";
10+
11+export const WIDTH = 1200;
12+export const HEIGHT = 630;
13+const PAD = 80;
14+
15+/** An element as satori reads it. */
16+export type Node = { type: string; props: Record<string, unknown> & { style?: Style; children?: Child } };
17+type Child = Node | string | (Node | string | null | false)[] | null;
18+type Style = Record<string, string | number>;
19+
20+function h(type: string, style: Style, ...children: (Node | string | null | false)[]): Node {
21+ const kids = children.filter((child): child is Node | string => child !== null && child !== false);
22+ if (kids.length === 0) return { type, props: { style } };
23+ return { type, props: { style, children: kids.length === 1 ? kids[0] : kids } };
24+}
25+
26+function img(src: string, width: number, height: number, style: Style = {}): Node {
27+ return { type: "img", props: { src, width, height, style: { width, height, ...style } } };
28+}
29+
30+function icon(shapes: string, color: string, size: number): Node {
31+ return img(dataUri(iconSvg(shapes, color)), size, size, { flexShrink: 0 });
32+}
33+
34+const SANS = "Inter";
35+const MONO = "JetBrains Mono";
36+
37+/** Long text is cut before layout, so a huge title costs nothing to lay out. */
38+function bound(text: string, max: number): string {
39+ const single = text.replace(/\s+/g, " ").trim();
40+ return single.length > max ? `${single.slice(0, max - 1).trimEnd()}…` : single;
41+}
42+
43+/** A block of text that stops after `lines`, with an ellipsis. */
44+function clamp(text: string, lines: number, style: Style): Node {
45+ return h("div", { display: "block", lineClamp: lines, overflow: "hidden", ...style }, text);
46+}
47+
48+/** Bigger type for shorter titles, so a two-word title fills the card and a long one fits. */
49+function titleSize(text: string): number {
50+ if (text.length <= 28) return 76;
51+ if (text.length <= 56) return 64;
52+ if (text.length <= 96) return 54;
53+ return 46;
54+}
55+
56+// --- Pieces --------------------------------------------------------------
57+
58+/** The backdrop: dot grid, lavender light and, on most cards, the fleet. */
59+function background(withFleet: boolean): Node {
60+ return img(dataUri(backdropSvg(WIDTH, HEIGHT, withFleet)), WIDTH, HEIGHT, { position: "absolute", top: 0, left: 0 });
61+}
62+
63+/** The lavender rule along the bottom edge. */
64+function rule(): Node {
65+ return h("div", {
66+ position: "absolute",
67+ left: 0,
68+ bottom: 0,
69+ width: WIDTH,
70+ height: 6,
71+ backgroundImage: `linear-gradient(to right, ${COLOR.lavenderDeep}, ${COLOR.lavender} 45%, rgba(182, 168, 255, 0.15))`,
72+ });
73+}
74+
75+function lockup(height: number, suffix?: string): Node {
76+ const width = Math.round((height * LOCKUP_BOX.width) / LOCKUP_BOX.height);
77+ return h(
78+ "div",
79+ { display: "flex", alignItems: "flex-end" },
80+ img(dataUri(lockupSvg()), width, height),
81+ suffix
82+ ? h(
83+ "div",
84+ {
85+ display: "flex",
86+ fontFamily: SANS,
87+ fontWeight: 500,
88+ fontSize: Math.round(height * 0.62),
89+ color: COLOR.muted,
90+ marginLeft: Math.round(height * 0.22),
91+ // Sits on the wordmark's baseline.
92+ marginBottom: Math.round(height * (1 - LOCKUP_BOX.baseline / LOCKUP_BOX.height) - height * 0.1),
93+ letterSpacing: -0.5,
94+ },
95+ suffix,
96+ )
97+ : null,
98+ );
99+}
100+
101+function topBar(address: string, suffix?: string): Node {
102+ return h(
103+ "div",
104+ { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" },
105+ lockup(46, suffix),
106+ h(
107+ "div",
108+ { display: "flex", fontFamily: MONO, fontSize: 22, color: COLOR.faint, whiteSpace: "nowrap" },
109+ bound(address, 46),
110+ ),
111+ );
112+}
113+
114+type Tone = { color: string; bg: string; border: string };
115+
116+const TONE = {
117+ open: { color: COLOR.mint, bg: "rgba(134, 239, 196, 0.10)", border: "rgba(134, 239, 196, 0.38)" },
118+ merged: { color: COLOR.lavender, bg: "rgba(182, 168, 255, 0.12)", border: "rgba(182, 168, 255, 0.42)" },
119+ closed: { color: COLOR.danger, bg: "rgba(255, 131, 148, 0.10)", border: "rgba(255, 131, 148, 0.38)" },
120+ quiet: { color: COLOR.muted, bg: "rgba(160, 160, 168, 0.08)", border: "rgba(160, 160, 168, 0.30)" },
121+} satisfies Record<string, Tone>;
122+
123+function badge(label: string, shapes: string, tone: Tone): Node {
124+ return h(
125+ "div",
126+ {
127+ display: "flex",
128+ alignItems: "center",
129+ gap: 12,
130+ padding: "10px 22px 10px 18px",
131+ borderRadius: 999,
132+ backgroundColor: tone.bg,
133+ border: `2px solid ${tone.border}`,
134+ color: tone.color,
135+ fontFamily: SANS,
136+ fontWeight: 600,
137+ fontSize: 26,
138+ },
139+ icon(shapes, tone.color, 28),
140+ label,
141+ );
142+}
143+
144+/** A fact along the bottom: an icon, a number or a word, and what it counts. */
145+function fact(shapes: string, strong: string, rest: string): Node {
146+ return h(
147+ "div",
148+ { display: "flex", alignItems: "center", gap: 12, fontFamily: SANS, fontSize: 28, color: COLOR.muted },
149+ icon(shapes, COLOR.faint, 28),
150+ h("div", { display: "flex", color: COLOR.fg, fontWeight: 600 }, strong),
151+ rest ? h("div", { display: "flex" }, rest) : null,
152+ );
153+}
154+
155+function plain(text: string, mono = false): Node {
156+ return h(
157+ "div",
158+ { display: "flex", fontFamily: mono ? MONO : SANS, fontSize: mono ? 25 : 28, color: COLOR.muted },
159+ text,
160+ );
161+}
162+
163+const dot = () => h("div", { display: "flex", fontSize: 28, color: COLOR.faint }, "·");
164+
165+/** The frame every card but the brand card shares. */
166+function frame(parts: {
167+ address: string;
168+ suffix?: string;
169+ eyebrow: Node;
170+ title: Node;
171+ description: string | null;
172+ facts: Node[];
173+ descriptionLines?: number;
174+}): Node {
175+ return h(
176+ "div",
177+ {
178+ display: "flex",
179+ position: "relative",
180+ width: WIDTH,
181+ height: HEIGHT,
182+ backgroundColor: COLOR.bg,
183+ overflow: "hidden",
184+ },
185+ background(true),
186+ h(
187+ "div",
188+ {
189+ display: "flex",
190+ flexDirection: "column",
191+ position: "relative",
192+ width: WIDTH,
193+ height: HEIGHT,
194+ padding: `64px ${PAD}px 72px`,
195+ },
196+ topBar(parts.address, parts.suffix),
197+ h(
198+ "div",
199+ { display: "flex", flexDirection: "column", flexGrow: 1, justifyContent: "center", width: 940 },
200+ parts.eyebrow,
201+ h("div", { display: "flex", marginTop: 18 }, parts.title),
202+ parts.description
203+ ? clamp(bound(parts.description, 260), parts.descriptionLines ?? 2, {
204+ marginTop: 22,
205+ fontFamily: SANS,
206+ fontSize: 30,
207+ lineHeight: 1.4,
208+ color: COLOR.muted,
209+ })
210+ : null,
211+ ),
212+ h("div", { display: "flex", alignItems: "center", gap: 24, height: 56 }, ...parts.facts),
213+ ),
214+ rule(),
215+ );
216+}
217+
218+function eyebrow(text: string, mono: boolean, color: string = COLOR.lavender): Node {
219+ return h(
220+ "div",
221+ {
222+ display: "flex",
223+ fontFamily: mono ? MONO : SANS,
224+ fontWeight: 500,
225+ fontSize: mono ? 28 : 26,
226+ color,
227+ letterSpacing: mono ? 0 : 2,
228+ textTransform: mono ? "none" : "uppercase",
229+ },
230+ bound(text, 60),
231+ );
232+}
233+
234+function title(text: string, lines = 3): Node {
235+ const bounded = bound(text, 180);
236+ const size = titleSize(bounded);
237+ return clamp(bounded, lines, {
238+ fontFamily: SANS,
239+ fontWeight: 600,
240+ fontSize: size,
241+ lineHeight: 1.12,
242+ letterSpacing: size >= 64 ? -2 : -1.4,
243+ color: COLOR.fg,
244+ width: 940,
245+ });
246+}
247+
248+// --- Cards ---------------------------------------------------------------
249+
250+function brandCard(): Node {
251+ const height = 190;
252+ const width = Math.round((height * LOCKUP_BOX.width) / LOCKUP_BOX.height);
253+ return h(
254+ "div",
255+ {
256+ display: "flex",
257+ position: "relative",
258+ width: WIDTH,
259+ height: HEIGHT,
260+ backgroundColor: COLOR.bg,
261+ overflow: "hidden",
262+ },
263+ background(false),
264+ h(
265+ "div",
266+ {
267+ display: "flex",
268+ flexDirection: "column",
269+ alignItems: "center",
270+ justifyContent: "center",
271+ position: "relative",
272+ width: WIDTH,
273+ height: HEIGHT,
274+ },
275+ img(dataUri(lockupSvg()), width, height),
276+ h(
277+ "div",
278+ {
279+ display: "flex",
280+ marginTop: 56,
281+ fontFamily: SANS,
282+ fontWeight: 500,
283+ fontSize: 44,
284+ letterSpacing: -0.8,
285+ color: COLOR.fgSoft,
286+ },
287+ "A git forge for teams of agents",
288+ ),
289+ h(
290+ "div",
291+ { display: "flex", marginTop: 40, fontFamily: MONO, fontSize: 26, color: COLOR.lavender },
292+ "g1t.sh",
293+ ),
294+ ),
295+ rule(),
296+ );
297+}
298+
299+const PULL_BADGE: Record<PullStatus, [string, string, Tone]> = {
300+ open: ["Open", ICON.pull, TONE.open],
301+ draft: ["Draft", ICON.pullDraft, TONE.quiet],
302+ merged: ["Merged", ICON.merged, TONE.merged],
303+ closed: ["Closed", ICON.pullClosed, TONE.closed],
304+};
305+
306+const ISSUE_BADGE: Record<IssueState, [string, string, Tone]> = {
307+ open: ["Open", ICON.issue, TONE.open],
308+ completed: ["Closed", ICON.issueDone, TONE.merged],
309+ not_planned: ["Not planned", ICON.issueNotPlanned, TONE.quiet],
310+};
311+
312+function count(n: number, one: string, many: string): [string, string] {
313+ return [n.toLocaleString("en-US"), n === 1 ? one : many];
314+}
315+
316+export function cardTree(card: Card): Node {
317+ switch (card.kind) {
318+ case "brand":
319+ return brandCard();
320+
321+ case "page":
322+ return frame({
323+ address: card.address,
324+ eyebrow: eyebrow(card.eyebrow, false),
325+ title: title(card.title),
326+ description: card.description,
327+ facts: [plain("A git forge for teams of agents")],
328+ });
329+
330+ case "workspace": {
331+ const [n, noun] = count(card.projects, "public project", "public projects");
332+ return frame({
333+ address: `g1t.sh/${card.slug}`,
334+ // The slug above the name, unless they are the same.
335+ eyebrow: card.name.toLowerCase() === card.slug ? eyebrow("Workspace", false) : eyebrow(card.slug, true),
336+ title: title(card.name, 2),
337+ description: card.description,
338+ facts: [fact(ICON.box, n, noun)],
339+ });
340+ }
341+
342+ case "project": {
343+ const [issues, issueNoun] = count(card.issues, "open issue", "open issues");
344+ const [pulls, pullNoun] = count(card.pulls, "open pull request", "open pull requests");
345+ const named = card.name.toLowerCase() !== card.repo.toLowerCase();
346+ return frame({
347+ address: `g1t.sh/${card.owner}/${card.repo}`,
348+ eyebrow: eyebrow(named ? `${card.owner}/${card.repo}` : `${card.owner} /`, true),
349+ title: named ? title(card.name, 2) : repoTitle(card.repo),
350+ description: card.description,
351+ facts: [fact(ICON.issue, issues, issueNoun), fact(ICON.pull, pulls, pullNoun)],
352+ });
353+ }
354+
355+ case "issue": {
356+ const [label, shapes, tone] = ISSUE_BADGE[card.state];
357+ return frame({
358+ address: `g1t.sh/${card.owner}/${card.repo}/issues/${card.number}`,
359+ eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number),
360+ title: title(card.title),
361+ description: null,
362+ facts: [badge(label, shapes, tone), plain("Issue"), dot(), byline(card.author)],
363+ });
364+ }
365+
366+ case "pull": {
367+ const [label, shapes, tone] = PULL_BADGE[card.state];
368+ return frame({
369+ address: `g1t.sh/${card.owner}/${card.repo}/pull/${card.number}`,
370+ eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number),
371+ title: title(card.title),
372+ description: null,
373+ facts: [badge(label, shapes, tone), plain("Pull request"), dot(), byline(card.author)],
374+ });
375+ }
376+
377+ case "soon":
378+ return frame({
379+ address: `g1t.sh/${card.owner}/${card.repo}`,
380+ eyebrow: eyebrow(`${card.owner}/${card.repo}`, true),
381+ title: title(card.title, 2),
382+ description: card.summary,
383+ facts: [badge("Soon", ICON.sparkles, TONE.open), plain(card.section)],
384+ });
385+
386+ case "docs":
387+ return frame({
388+ address: "docs.g1t.sh",
389+ suffix: "docs",
390+ eyebrow: eyebrow(card.section ?? "Documentation", false),
391+ title: title(card.title, 2),
392+ description: card.description,
393+ facts: [fact(ICON.book, "g1t docs", "Guides and API reference")],
394+ });
395+ }
396+}
397+
398+/** A repository's name in mono, large: the project's own address. */
399+function repoTitle(name: string): Node {
400+ const bounded = bound(name, 60);
401+ const size = bounded.length <= 16 ? 84 : bounded.length <= 26 ? 68 : 52;
402+ return clamp(bounded, 2, {
403+ fontFamily: MONO,
404+ fontWeight: 500,
405+ fontSize: size,
406+ lineHeight: 1.12,
407+ letterSpacing: -1,
408+ color: COLOR.fg,
409+ width: 940,
410+ });
411+}
412+
413+function eyebrowWithNumber(repo: string, number: number): Node {
414+ return h(
415+ "div",
416+ { display: "flex", alignItems: "baseline", fontFamily: MONO, fontWeight: 500, fontSize: 28 },
417+ h("div", { display: "flex", color: COLOR.lavender }, bound(repo, 48)),
418+ h("div", { display: "flex", color: COLOR.faint, marginLeft: 14 }, `#${number}`),
419+ );
420+}
421+
422+function byline(author: string): Node {
423+ return h(
424+ "div",
425+ { display: "flex", alignItems: "center", gap: 10, fontFamily: SANS, fontSize: 28, color: COLOR.muted },
426+ "by",
427+ h("div", { display: "flex", fontFamily: MONO, fontSize: 26, color: COLOR.fgSoft }, bound(author, 32)),
428+ );
429+}
+92−0
1+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+http://scripts.sil.org/OFL
6+
7+-----------------------------------------------------------
8+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
9+-----------------------------------------------------------
10+
11+PREAMBLE
12+The goals of the Open Font License (OFL) are to stimulate worldwide
13+development of collaborative font projects, to support the font creation
14+efforts of academic and linguistic communities, and to provide a free and
15+open framework in which fonts may be shared and improved in partnership
16+with others.
17+
18+The OFL allows the licensed fonts to be used, studied, modified and
19+redistributed freely as long as they are not sold by themselves. The
20+fonts, including any derivative works, can be bundled, embedded,
21+redistributed and/or sold with any software provided that any reserved
22+names are not used by derivative works. The fonts and derivatives,
23+however, cannot be released under any other type of license. The
24+requirement for fonts to remain under this license does not apply
25+to any document created using the fonts or their derivatives.
26+
27+DEFINITIONS
28+"Font Software" refers to the set of files released by the Copyright
29+Holder(s) under this license and clearly marked as such. This may
30+include source files, build scripts and documentation.
31+
32+"Reserved Font Name" refers to any names specified as such after the
33+copyright statement(s).
34+
35+"Original Version" refers to the collection of Font Software components as
36+distributed by the Copyright Holder(s).
37+
38+"Modified Version" refers to any derivative made by adding to, deleting,
39+or substituting -- in part or in whole -- any of the components of the
40+Original Version, by changing formats or by porting the Font Software to a
41+new environment.
42+
43+"Author" refers to any designer, engineer, programmer, technical
44+writer or other person who contributed to the Font Software.
45+
46+PERMISSION AND CONDITIONS
47+Permission is hereby granted, free of charge, to any person obtaining
48+a copy of the Font Software, to use, study, copy, merge, embed, modify,
49+redistribute, and sell modified and unmodified copies of the Font
50+Software, subject to the following conditions:
51+
52+1) Neither the Font Software nor any of its individual components,
53+in Original or Modified Versions, may be sold by itself.
54+
55+2) Original or Modified Versions of the Font Software may be bundled,
56+redistributed and/or sold with any software, provided that each copy
57+contains the above copyright notice and this license. These can be
58+included either as stand-alone text files, human-readable headers or
59+in the appropriate machine-readable metadata fields within text or
60+binary files as long as those fields can be easily viewed by the user.
61+
62+3) No Modified Version of the Font Software may use the Reserved Font
63+Name(s) unless explicit written permission is granted by the corresponding
64+Copyright Holder. This restriction only applies to the primary font name as
65+presented to the users.
66+
67+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
68+Software shall not be used to promote, endorse or advertise any
69+Modified Version, except to acknowledge the contribution(s) of the
70+Copyright Holder(s) and the Author(s) or with their explicit written
71+permission.
72+
73+5) The Font Software, modified or unmodified, in part or in whole,
74+must be distributed entirely under this license, and must not be
75+distributed under any other license. The requirement for fonts to
76+remain under this license does not apply to any document created
77+using the Font Software.
78+
79+TERMINATION
80+This license becomes null and void if any of the above conditions are
81+not met.
82+
83+DISCLAIMER
84+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
85+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
86+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
87+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
88+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
89+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
90+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
91+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
92+OTHER DEALINGS IN THE FONT SOFTWARE.
+93−0
1+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
2+
3+This Font Software is licensed under the SIL Open Font License, Version 1.1.
4+This license is copied below, and is also available with a FAQ at:
5+https://openfontlicense.org
6+
7+
8+-----------------------------------------------------------
9+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10+-----------------------------------------------------------
11+
12+PREAMBLE
13+The goals of the Open Font License (OFL) are to stimulate worldwide
14+development of collaborative font projects, to support the font creation
15+efforts of academic and linguistic communities, and to provide a free and
16+open framework in which fonts may be shared and improved in partnership
17+with others.
18+
19+The OFL allows the licensed fonts to be used, studied, modified and
20+redistributed freely as long as they are not sold by themselves. The
21+fonts, including any derivative works, can be bundled, embedded,
22+redistributed and/or sold with any software provided that any reserved
23+names are not used by derivative works. The fonts and derivatives,
24+however, cannot be released under any other type of license. The
25+requirement for fonts to remain under this license does not apply
26+to any document created using the fonts or their derivatives.
27+
28+DEFINITIONS
29+"Font Software" refers to the set of files released by the Copyright
30+Holder(s) under this license and clearly marked as such. This may
31+include source files, build scripts and documentation.
32+
33+"Reserved Font Name" refers to any names specified as such after the
34+copyright statement(s).
35+
36+"Original Version" refers to the collection of Font Software components as
37+distributed by the Copyright Holder(s).
38+
39+"Modified Version" refers to any derivative made by adding to, deleting,
40+or substituting -- in part or in whole -- any of the components of the
41+Original Version, by changing formats or by porting the Font Software to a
42+new environment.
43+
44+"Author" refers to any designer, engineer, programmer, technical
45+writer or other person who contributed to the Font Software.
46+
47+PERMISSION & CONDITIONS
48+Permission is hereby granted, free of charge, to any person obtaining
49+a copy of the Font Software, to use, study, copy, merge, embed, modify,
50+redistribute, and sell modified and unmodified copies of the Font
51+Software, subject to the following conditions:
52+
53+1) Neither the Font Software nor any of its individual components,
54+in Original or Modified Versions, may be sold by itself.
55+
56+2) Original or Modified Versions of the Font Software may be bundled,
57+redistributed and/or sold with any software, provided that each copy
58+contains the above copyright notice and this license. These can be
59+included either as stand-alone text files, human-readable headers or
60+in the appropriate machine-readable metadata fields within text or
61+binary files as long as those fields can be easily viewed by the user.
62+
63+3) No Modified Version of the Font Software may use the Reserved Font
64+Name(s) unless explicit written permission is granted by the corresponding
65+Copyright Holder. This restriction only applies to the primary font name as
66+presented to the users.
67+
68+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69+Software shall not be used to promote, endorse or advertise any
70+Modified Version, except to acknowledge the contribution(s) of the
71+Copyright Holder(s) and the Author(s) or with their explicit written
72+permission.
73+
74+5) The Font Software, modified or unmodified, in part or in whole,
75+must be distributed entirely under this license, and must not be
76+distributed under any other license. The requirement for fonts to
77+remain under this license does not apply to any document created
78+using the Font Software.
79+
80+TERMINATION
81+This license becomes null and void if any of the above conditions are
82+not met.
83+
84+DISCLAIMER
85+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93+OTHER DEALINGS IN THE FONT SOFTWARE.
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.