| 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 | /** The launch line: what g1t is, on the brand card and under every page card. */ |
| 16 | export const TAGLINE = "Where people and agents ship software together."; |
| 17 | |
| 18 | /** An element as satori reads it. */ |
| 19 | export type Node = { type: string; props: Record<string, unknown> & { style?: Style; children?: Child } }; |
| 20 | type Child = Node | string | (Node | string | null | false)[] | null; |
| 21 | type Style = Record<string, string | number>; |
| 22 | |
| 23 | function h(type: string, style: Style, ...children: (Node | string | null | false)[]): Node { |
| 24 | const kids = children.filter((child): child is Node | string => child !== null && child !== false); |
| 25 | if (kids.length === 0) return { type, props: { style } }; |
| 26 | return { type, props: { style, children: kids.length === 1 ? kids[0] : kids } }; |
| 27 | } |
| 28 | |
| 29 | function img(src: string, width: number, height: number, style: Style = {}): Node { |
| 30 | return { type: "img", props: { src, width, height, style: { width, height, ...style } } }; |
| 31 | } |
| 32 | |
| 33 | function icon(shapes: string, color: string, size: number): Node { |
| 34 | return img(dataUri(iconSvg(shapes, color)), size, size, { flexShrink: 0 }); |
| 35 | } |
| 36 | |
| 37 | const SANS = "Hanken Grotesk"; |
| 38 | /** Headlines: a card's title and the brand line. */ |
| 39 | const DISPLAY = "Bricolage Grotesque"; |
| 40 | const MONO = "IBM Plex Mono"; |
| 41 | |
| 42 | /** Long text is cut before layout, so a huge title costs nothing to lay out. */ |
| 43 | function bound(text: string, max: number): string { |
| 44 | const single = text.replace(/\s+/g, " ").trim(); |
| 45 | return single.length > max ? `${single.slice(0, max - 1).trimEnd()}…` : single; |
| 46 | } |
| 47 | |
| 48 | /** A block of text that stops after `lines`, with an ellipsis. */ |
| 49 | function clamp(text: string, lines: number, style: Style): Node { |
| 50 | return h("div", { display: "block", lineClamp: lines, overflow: "hidden", ...style }, text); |
| 51 | } |
| 52 | |
| 53 | /** Bigger type for shorter titles, so a two-word title fills the card and a long one fits. */ |
| 54 | function titleSize(text: string): number { |
| 55 | if (text.length <= 28) return 76; |
| 56 | if (text.length <= 56) return 64; |
| 57 | if (text.length <= 96) return 54; |
| 58 | return 46; |
| 59 | } |
| 60 | |
| 61 | // --- Pieces -------------------------------------------------------------- |
| 62 | |
| 63 | /** The backdrop: dot grid, lavender light and, on most cards, the pixel 1. */ |
| 64 | function background(withOne: boolean): Node { |
| 65 | return img(dataUri(backdropSvg(WIDTH, HEIGHT, withOne)), WIDTH, HEIGHT, { position: "absolute", top: 0, left: 0 }); |
| 66 | } |
| 67 | |
| 68 | /** The lavender rule along the bottom edge. */ |
| 69 | function rule(): Node { |
| 70 | return h("div", { |
| 71 | position: "absolute", |
| 72 | left: 0, |
| 73 | bottom: 0, |
| 74 | width: WIDTH, |
| 75 | height: 6, |
| 76 | backgroundImage: `linear-gradient(to right, ${COLOR.lavenderDeep}, ${COLOR.lavender} 45%, rgba(182, 168, 255, 0.15))`, |
| 77 | }); |
| 78 | } |
| 79 | |
| 80 | function lockup(height: number, suffix?: string): Node { |
| 81 | const width = Math.round((height * LOCKUP_BOX.width) / LOCKUP_BOX.height); |
| 82 | return h( |
| 83 | "div", |
| 84 | { display: "flex", alignItems: "flex-end" }, |
| 85 | img(dataUri(lockupSvg()), width, height), |
| 86 | suffix |
| 87 | ? h( |
| 88 | "div", |
| 89 | { |
| 90 | display: "flex", |
| 91 | fontFamily: SANS, |
| 92 | fontWeight: 500, |
| 93 | fontSize: Math.round(height * 0.62), |
| 94 | color: COLOR.muted, |
| 95 | marginLeft: Math.round(height * 0.22), |
| 96 | // Sits on the wordmark's baseline. |
| 97 | marginBottom: Math.round(height * (1 - LOCKUP_BOX.baseline / LOCKUP_BOX.height) - height * 0.1), |
| 98 | letterSpacing: -0.5, |
| 99 | }, |
| 100 | suffix, |
| 101 | ) |
| 102 | : null, |
| 103 | ); |
| 104 | } |
| 105 | |
| 106 | function topBar(address: string, suffix?: string): Node { |
| 107 | return h( |
| 108 | "div", |
| 109 | { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }, |
| 110 | lockup(46, suffix), |
| 111 | h( |
| 112 | "div", |
| 113 | { display: "flex", fontFamily: MONO, fontSize: 22, color: COLOR.faint, whiteSpace: "nowrap" }, |
| 114 | bound(address, 46), |
| 115 | ), |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | type Tone = { color: string; bg: string; border: string }; |
| 120 | |
| 121 | const TONE = { |
| 122 | open: { color: COLOR.mint, bg: "rgba(134, 239, 196, 0.10)", border: "rgba(134, 239, 196, 0.38)" }, |
| 123 | merged: { color: COLOR.lavender, bg: "rgba(182, 168, 255, 0.12)", border: "rgba(182, 168, 255, 0.42)" }, |
| 124 | closed: { color: COLOR.danger, bg: "rgba(255, 131, 148, 0.10)", border: "rgba(255, 131, 148, 0.38)" }, |
| 125 | quiet: { color: COLOR.muted, bg: "rgba(160, 160, 168, 0.08)", border: "rgba(160, 160, 168, 0.30)" }, |
| 126 | } satisfies Record<string, Tone>; |
| 127 | |
| 128 | function badge(label: string, shapes: string, tone: Tone): Node { |
| 129 | return h( |
| 130 | "div", |
| 131 | { |
| 132 | display: "flex", |
| 133 | alignItems: "center", |
| 134 | gap: 12, |
| 135 | padding: "10px 22px 10px 18px", |
| 136 | borderRadius: 999, |
| 137 | backgroundColor: tone.bg, |
| 138 | border: `2px solid ${tone.border}`, |
| 139 | color: tone.color, |
| 140 | fontFamily: SANS, |
| 141 | fontWeight: 600, |
| 142 | fontSize: 26, |
| 143 | }, |
| 144 | icon(shapes, tone.color, 28), |
| 145 | label, |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | /** A fact along the bottom: an icon, a number or a word, and what it counts. */ |
| 150 | function fact(shapes: string, strong: string, rest: string): Node { |
| 151 | return h( |
| 152 | "div", |
| 153 | { display: "flex", alignItems: "center", gap: 12, fontFamily: SANS, fontSize: 28, color: COLOR.muted }, |
| 154 | icon(shapes, COLOR.faint, 28), |
| 155 | h("div", { display: "flex", color: COLOR.fg, fontWeight: 600 }, strong), |
| 156 | rest ? h("div", { display: "flex" }, rest) : null, |
| 157 | ); |
| 158 | } |
| 159 | |
| 160 | function plain(text: string, mono = false): Node { |
| 161 | return h( |
| 162 | "div", |
| 163 | { display: "flex", fontFamily: mono ? MONO : SANS, fontSize: mono ? 25 : 28, color: COLOR.muted }, |
| 164 | text, |
| 165 | ); |
| 166 | } |
| 167 | |
| 168 | const dot = () => h("div", { display: "flex", fontSize: 28, color: COLOR.faint }, "·"); |
| 169 | |
| 170 | /** The frame every card but the brand card shares. */ |
| 171 | function frame(parts: { |
| 172 | address: string; |
| 173 | suffix?: string; |
| 174 | eyebrow: Node; |
| 175 | title: Node; |
| 176 | description: string | null; |
| 177 | facts: Node[]; |
| 178 | descriptionLines?: number; |
| 179 | }): Node { |
| 180 | return h( |
| 181 | "div", |
| 182 | { |
| 183 | display: "flex", |
| 184 | position: "relative", |
| 185 | width: WIDTH, |
| 186 | height: HEIGHT, |
| 187 | backgroundColor: COLOR.bg, |
| 188 | overflow: "hidden", |
| 189 | }, |
| 190 | background(true), |
| 191 | h( |
| 192 | "div", |
| 193 | { |
| 194 | display: "flex", |
| 195 | flexDirection: "column", |
| 196 | position: "relative", |
| 197 | width: WIDTH, |
| 198 | height: HEIGHT, |
| 199 | padding: `64px ${PAD}px 72px`, |
| 200 | }, |
| 201 | topBar(parts.address, parts.suffix), |
| 202 | h( |
| 203 | "div", |
| 204 | { display: "flex", flexDirection: "column", flexGrow: 1, justifyContent: "center", width: 940 }, |
| 205 | parts.eyebrow, |
| 206 | h("div", { display: "flex", marginTop: 18 }, parts.title), |
| 207 | parts.description |
| 208 | ? clamp(bound(parts.description, 260), parts.descriptionLines ?? 2, { |
| 209 | marginTop: 22, |
| 210 | fontFamily: SANS, |
| 211 | fontSize: 30, |
| 212 | lineHeight: 1.4, |
| 213 | color: COLOR.muted, |
| 214 | }) |
| 215 | : null, |
| 216 | ), |
| 217 | h("div", { display: "flex", alignItems: "center", gap: 24, height: 56 }, ...parts.facts), |
| 218 | ), |
| 219 | rule(), |
| 220 | ); |
| 221 | } |
| 222 | |
| 223 | function eyebrow(text: string, mono: boolean, color: string = COLOR.lavender): Node { |
| 224 | return h( |
| 225 | "div", |
| 226 | { |
| 227 | display: "flex", |
| 228 | fontFamily: mono ? MONO : SANS, |
| 229 | fontWeight: 500, |
| 230 | fontSize: mono ? 28 : 26, |
| 231 | color, |
| 232 | letterSpacing: mono ? 0 : 2, |
| 233 | textTransform: mono ? "none" : "uppercase", |
| 234 | }, |
| 235 | bound(text, 60), |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | function title(text: string, lines = 3): Node { |
| 240 | const bounded = bound(text, 180); |
| 241 | const size = titleSize(bounded); |
| 242 | return clamp(bounded, lines, { |
| 243 | fontFamily: DISPLAY, |
| 244 | fontWeight: 600, |
| 245 | fontSize: size, |
| 246 | lineHeight: 1.12, |
| 247 | letterSpacing: size >= 64 ? -1.3 : -0.8, |
| 248 | color: COLOR.fg, |
| 249 | width: 940, |
| 250 | }); |
| 251 | } |
| 252 | |
| 253 | // --- Cards --------------------------------------------------------------- |
| 254 | |
| 255 | function brandCard(): Node { |
| 256 | const height = 190; |
| 257 | const width = Math.round((height * LOCKUP_BOX.width) / LOCKUP_BOX.height); |
| 258 | return h( |
| 259 | "div", |
| 260 | { |
| 261 | display: "flex", |
| 262 | position: "relative", |
| 263 | width: WIDTH, |
| 264 | height: HEIGHT, |
| 265 | backgroundColor: COLOR.bg, |
| 266 | overflow: "hidden", |
| 267 | }, |
| 268 | background(false), |
| 269 | h( |
| 270 | "div", |
| 271 | { |
| 272 | display: "flex", |
| 273 | flexDirection: "column", |
| 274 | alignItems: "center", |
| 275 | justifyContent: "center", |
| 276 | position: "relative", |
| 277 | width: WIDTH, |
| 278 | height: HEIGHT, |
| 279 | }, |
| 280 | img(dataUri(lockupSvg()), width, height), |
| 281 | h( |
| 282 | "div", |
| 283 | { |
| 284 | display: "flex", |
| 285 | marginTop: 56, |
| 286 | fontFamily: DISPLAY, |
| 287 | fontWeight: 500, |
| 288 | fontSize: 44, |
| 289 | letterSpacing: -0.5, |
| 290 | color: COLOR.fgSoft, |
| 291 | }, |
| 292 | TAGLINE, |
| 293 | ), |
| 294 | h( |
| 295 | "div", |
| 296 | { display: "flex", marginTop: 40, fontFamily: MONO, fontSize: 26, color: COLOR.lavender }, |
| 297 | "g1t.sh", |
| 298 | ), |
| 299 | ), |
| 300 | rule(), |
| 301 | ); |
| 302 | } |
| 303 | |
| 304 | const PULL_BADGE: Record<PullStatus, [string, string, Tone]> = { |
| 305 | open: ["Open", ICON.pull, TONE.open], |
| 306 | draft: ["Draft", ICON.pullDraft, TONE.quiet], |
| 307 | merged: ["Merged", ICON.merged, TONE.merged], |
| 308 | closed: ["Closed", ICON.pullClosed, TONE.closed], |
| 309 | }; |
| 310 | |
| 311 | const ISSUE_BADGE: Record<IssueState, [string, string, Tone]> = { |
| 312 | open: ["Open", ICON.issue, TONE.open], |
| 313 | completed: ["Closed", ICON.issueDone, TONE.merged], |
| 314 | not_planned: ["Not planned", ICON.issueNotPlanned, TONE.quiet], |
| 315 | }; |
| 316 | |
| 317 | function count(n: number, one: string, many: string): [string, string] { |
| 318 | return [n.toLocaleString("en-US"), n === 1 ? one : many]; |
| 319 | } |
| 320 | |
| 321 | export function cardTree(card: Card): Node { |
| 322 | switch (card.kind) { |
| 323 | case "brand": |
| 324 | return brandCard(); |
| 325 | |
| 326 | case "page": |
| 327 | return frame({ |
| 328 | address: card.address, |
| 329 | eyebrow: eyebrow(card.eyebrow, false), |
| 330 | title: title(card.title), |
| 331 | description: card.description, |
| 332 | facts: [plain(TAGLINE)], |
| 333 | }); |
| 334 | |
| 335 | case "workspace": { |
| 336 | const [n, noun] = count(card.projects, "public project", "public projects"); |
| 337 | return frame({ |
| 338 | address: `g1t.sh/${card.slug}`, |
| 339 | // The slug above the name, unless they are the same. |
| 340 | eyebrow: withIcon( |
| 341 | card.icon, |
| 342 | card.name.toLowerCase() === card.slug ? eyebrow("Workspace", false) : eyebrow(card.slug, true), |
| 343 | ), |
| 344 | title: title(card.name, 2), |
| 345 | description: card.description, |
| 346 | facts: [fact(ICON.box, n, noun)], |
| 347 | }); |
| 348 | } |
| 349 | |
| 350 | case "person": { |
| 351 | const [merged, mergedNoun] = count(card.pullsMerged, "pull request merged", "pull requests merged"); |
| 352 | const [pulls, pullNoun] = count(card.pullsOpen, "open pull request", "open pull requests"); |
| 353 | const [issues, issueNoun] = count(card.issues, "issue opened", "issues opened"); |
| 354 | return frame({ |
| 355 | address: `g1t.sh/u/${card.username}`, |
| 356 | // The username above the name they go by; "Profile" when there is none. |
| 357 | eyebrow: withIcon(card.icon, card.name ? eyebrow(`@${card.username}`, true) : eyebrow("Profile", false), true), |
| 358 | title: card.name ? title(card.name, 2) : repoTitle(card.username), |
| 359 | description: card.bio, |
| 360 | facts: [ |
| 361 | fact(ICON.merged, merged, mergedNoun), |
| 362 | fact(ICON.pull, pulls, pullNoun), |
| 363 | fact(ICON.issue, issues, issueNoun), |
| 364 | ], |
| 365 | }); |
| 366 | } |
| 367 | |
| 368 | case "project": { |
| 369 | const [issues, issueNoun] = count(card.issues, "open issue", "open issues"); |
| 370 | const [pulls, pullNoun] = count(card.pulls, "open pull request", "open pull requests"); |
| 371 | const named = card.name.toLowerCase() !== card.repo.toLowerCase(); |
| 372 | return frame({ |
| 373 | address: `g1t.sh/${card.owner}/${card.repo}`, |
| 374 | eyebrow: eyebrow(named ? `${card.owner}/${card.repo}` : `${card.owner} /`, true), |
| 375 | title: named ? title(card.name, 2) : repoTitle(card.repo), |
| 376 | description: card.description, |
| 377 | facts: [fact(ICON.issue, issues, issueNoun), fact(ICON.pull, pulls, pullNoun)], |
| 378 | }); |
| 379 | } |
| 380 | |
| 381 | case "issue": { |
| 382 | const [label, shapes, tone] = ISSUE_BADGE[card.state]; |
| 383 | return frame({ |
| 384 | address: `g1t.sh/${card.owner}/${card.repo}/issues/${card.number}`, |
| 385 | eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number), |
| 386 | title: title(card.title), |
| 387 | description: null, |
| 388 | facts: [badge(label, shapes, tone), plain("Issue"), dot(), byline(card.author, card.requestedBy)], |
| 389 | }); |
| 390 | } |
| 391 | |
| 392 | case "pull": { |
| 393 | const [label, shapes, tone] = PULL_BADGE[card.state]; |
| 394 | return frame({ |
| 395 | address: `g1t.sh/${card.owner}/${card.repo}/pull/${card.number}`, |
| 396 | eyebrow: eyebrowWithNumber(`${card.owner}/${card.repo}`, card.number), |
| 397 | title: title(card.title), |
| 398 | description: null, |
| 399 | facts: [badge(label, shapes, tone), plain("Pull request"), dot(), byline(card.author, card.requestedBy)], |
| 400 | }); |
| 401 | } |
| 402 | |
| 403 | case "soon": |
| 404 | return frame({ |
| 405 | address: `g1t.sh/${card.owner}/${card.repo}`, |
| 406 | eyebrow: eyebrow(`${card.owner}/${card.repo}`, true), |
| 407 | title: title(card.title, 2), |
| 408 | description: card.summary, |
| 409 | facts: [badge("Soon", ICON.sparkles, TONE.open), plain(card.section)], |
| 410 | }); |
| 411 | |
| 412 | case "docs": |
| 413 | return frame({ |
| 414 | address: "docs.g1t.sh", |
| 415 | suffix: "docs", |
| 416 | eyebrow: eyebrow(card.section ?? "Documentation", false), |
| 417 | title: title(card.title, 2), |
| 418 | description: card.description, |
| 419 | facts: [fact(ICON.book, "g1t docs", "Guides and API reference")], |
| 420 | }); |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | /** The uploaded icon beside an eyebrow, when there is one: round for a person. */ |
| 425 | function withIcon(icon: string | undefined, beside: Node, round = false): Node { |
| 426 | if (!icon) return beside; |
| 427 | return h( |
| 428 | "div", |
| 429 | { display: "flex", alignItems: "center", gap: 22 }, |
| 430 | img(icon, 64, 64, { borderRadius: round ? 32 : 14, objectFit: "cover", flexShrink: 0 }), |
| 431 | beside, |
| 432 | ); |
| 433 | } |
| 434 | |
| 435 | /** A repository's name in mono, large: the project's own address. */ |
| 436 | function repoTitle(name: string): Node { |
| 437 | const bounded = bound(name, 60); |
| 438 | const size = bounded.length <= 16 ? 84 : bounded.length <= 26 ? 68 : 52; |
| 439 | return clamp(bounded, 2, { |
| 440 | fontFamily: MONO, |
| 441 | fontWeight: 500, |
| 442 | fontSize: size, |
| 443 | lineHeight: 1.12, |
| 444 | letterSpacing: -1, |
| 445 | color: COLOR.fg, |
| 446 | width: 940, |
| 447 | }); |
| 448 | } |
| 449 | |
| 450 | function eyebrowWithNumber(repo: string, number: number): Node { |
| 451 | return h( |
| 452 | "div", |
| 453 | { display: "flex", alignItems: "baseline", fontFamily: MONO, fontWeight: 500, fontSize: 28 }, |
| 454 | h("div", { display: "flex", color: COLOR.lavender }, bound(repo, 48)), |
| 455 | h("div", { display: "flex", color: COLOR.faint, marginLeft: 14 }, `#${number}`), |
| 456 | ); |
| 457 | } |
| 458 | |
| 459 | /** "by ana", or "by g1t for ana" for what g1t opened for someone. */ |
| 460 | function byline(author: string, requestedBy: string | null): Node { |
| 461 | const name = (text: string) => h("div", { display: "flex", fontFamily: MONO, fontSize: 26, color: COLOR.fgSoft }, bound(text, 32)); |
| 462 | return h( |
| 463 | "div", |
| 464 | { display: "flex", alignItems: "center", gap: 10, fontFamily: SANS, fontSize: 28, color: COLOR.muted }, |
| 465 | "by", |
| 466 | name(author), |
| 467 | ...(requestedBy ? ["for", name(requestedBy)] : []), |
| 468 | ); |
| 469 | } |