Skip to content

Compare changes

Choose two branches to see what one has that the other does not, then open a pull request for it.

Open a pull request

1 commit

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.