| 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: withIcon( |
| 336 | card.icon, |
| 337 | card.name.toLowerCase() === card.slug ? eyebrow("Workspace", false) : eyebrow(card.slug, true), |
| 338 | ), |
| 339 | title: title(card.name, 2), |
| 340 | description: card.description, |
| 341 | facts: [fact(ICON.box, n, noun)], |
| 342 | }); |
| 343 | } |
| 344 | |
| 345 | case "project": { |
| 346 | const [issues, issueNoun] = count(card.issues, "open issue", "open issues"); |
| 347 | const [pulls, pullNoun] = count(card.pulls, "open pull request", "open pull requests"); |
| 348 | const named = card.name.toLowerCase() !== card.repo.toLowerCase(); |
| 349 | return frame({ |
| 350 | address: `g1t.sh/${card.owner}/${card.repo}`, |
| 351 | eyebrow: eyebrow(named ? `${card.owner}/${card.repo}` : `${card.owner} /`, true), |
| 352 | title: named ? title(card.name, 2) : repoTitle(card.repo), |
| 353 | description: card.description, |
| 354 | facts: [fact(ICON.issue, issues, issueNoun), fact(ICON.pull, pulls, pullNoun)], |
| 355 | }); |
| 356 | } |
| 357 | |
| 358 | case "issue": { |
| 359 | const [label, shapes, tone] = ISSUE_BADGE[card.state]; |
| 360 | return frame({ |
| 361 | address: `g1t.sh/${card.owner}/${card.repo}/issues/${card.number}`, |
| 362 | eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number), |
| 363 | title: title(card.title), |
| 364 | description: null, |
| 365 | facts: [badge(label, shapes, tone), plain("Issue"), dot(), byline(card.author)], |
| 366 | }); |
| 367 | } |
| 368 | |
| 369 | case "pull": { |
| 370 | const [label, shapes, tone] = PULL_BADGE[card.state]; |
| 371 | return frame({ |
| 372 | address: `g1t.sh/${card.owner}/${card.repo}/pull/${card.number}`, |
| 373 | eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number), |
| 374 | title: title(card.title), |
| 375 | description: null, |
| 376 | facts: [badge(label, shapes, tone), plain("Pull request"), dot(), byline(card.author)], |
| 377 | }); |
| 378 | } |
| 379 | |
| 380 | case "soon": |
| 381 | return frame({ |
| 382 | address: `g1t.sh/${card.owner}/${card.repo}`, |
| 383 | eyebrow: eyebrow(`${card.owner}/${card.repo}`, true), |
| 384 | title: title(card.title, 2), |
| 385 | description: card.summary, |
| 386 | facts: [badge("Soon", ICON.sparkles, TONE.open), plain(card.section)], |
| 387 | }); |
| 388 | |
| 389 | case "docs": |
| 390 | return frame({ |
| 391 | address: "docs.g1t.sh", |
| 392 | suffix: "docs", |
| 393 | eyebrow: eyebrow(card.section ?? "Documentation", false), |
| 394 | title: title(card.title, 2), |
| 395 | description: card.description, |
| 396 | facts: [fact(ICON.book, "g1t docs", "Guides and API reference")], |
| 397 | }); |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | /** The workspace's uploaded icon beside its eyebrow, when it has one. */ |
| 402 | function withIcon(icon: string | undefined, beside: Node): Node { |
| 403 | if (!icon) return beside; |
| 404 | return h( |
| 405 | "div", |
| 406 | { display: "flex", alignItems: "center", gap: 22 }, |
| 407 | img(icon, 64, 64, { borderRadius: 14, objectFit: "cover", flexShrink: 0 }), |
| 408 | beside, |
| 409 | ); |
| 410 | } |
| 411 | |
| 412 | /** A repository's name in mono, large: the project's own address. */ |
| 413 | function repoTitle(name: string): Node { |
| 414 | const bounded = bound(name, 60); |
| 415 | const size = bounded.length <= 16 ? 84 : bounded.length <= 26 ? 68 : 52; |
| 416 | return clamp(bounded, 2, { |
| 417 | fontFamily: MONO, |
| 418 | fontWeight: 500, |
| 419 | fontSize: size, |
| 420 | lineHeight: 1.12, |
| 421 | letterSpacing: -1, |
| 422 | color: COLOR.fg, |
| 423 | width: 940, |
| 424 | }); |
| 425 | } |
| 426 | |
| 427 | function eyebrowWithNumber(repo: string, number: number): Node { |
| 428 | return h( |
| 429 | "div", |
| 430 | { display: "flex", alignItems: "baseline", fontFamily: MONO, fontWeight: 500, fontSize: 28 }, |
| 431 | h("div", { display: "flex", color: COLOR.lavender }, bound(repo, 48)), |
| 432 | h("div", { display: "flex", color: COLOR.faint, marginLeft: 14 }, `#${number}`), |
| 433 | ); |
| 434 | } |
| 435 | |
| 436 | function byline(author: string): Node { |
| 437 | return h( |
| 438 | "div", |
| 439 | { display: "flex", alignItems: "center", gap: 10, fontFamily: SANS, fontSize: 28, color: COLOR.muted }, |
| 440 | "by", |
| 441 | h("div", { display: "flex", fontFamily: MONO, fontSize: 26, color: COLOR.fgSoft }, bound(author, 32)), |
| 442 | ); |
| 443 | } |