Commit

Packages lists like a registry: Type, search, Visibility and Sort in one bar, richer rows, and a registry to choose from to start

- One bar: Type, a search, Visibility (all, public, private) and Sort (recently updated, most downloads, name), each choice a link so the address says the view; a count, and Clear filters. - Rows: the registry's mark, private or not, updated when, the linked repository, description, latest version, size and downloads. - With no packages: every registry, what it is for, its first command and its guide (Cargo marked Soon). - The package page uses the same mark; the page's line names every registry, not containers only.

syntaqxcommitted Parent0df7c3dBrowse files
6 files+341−980/6 viewed
+33−0
1+import { Container, Layers, Package } from "lucide-react";
2+
3+import type { Ecosystem } from "@g1t/contracts";
4+
5+import { cn } from "../lib/cn";
6+
7+/** Each registry's colour, as its own tools use it, so a list reads at a glance. */
8+const TINT: Record<Ecosystem, string> = {
9+ container: "#2496ed",
10+ npm: "#cb3837",
11+ composer: "#c08a5b",
12+ go: "#00add8",
13+ cargo: "#dea584",
14+};
15+
16+/** A small tile marking which registry a package is in. */
17+export function PackageIcon({ ecosystem, size = 28, className }: { ecosystem: Ecosystem; size?: number; className?: string }) {
18+ const tint = TINT[ecosystem];
19+ const glyph = Math.round(size * 0.55);
20+ return (
21+ <span
22+ aria-hidden
23+ className={cn("inline-grid shrink-0 place-items-center rounded-md font-bold", className)}
24+ style={{ width: size, height: size, color: tint, background: `color-mix(in oklab, ${tint} 16%, transparent)` }}
25+ >
26+ {ecosystem === "container" && <Container size={glyph} />}
27+ {ecosystem === "composer" && <Layers size={glyph} />}
28+ {ecosystem === "cargo" && <Package size={glyph} />}
29+ {ecosystem === "npm" && <span style={{ fontSize: glyph }}>n</span>}
30+ {ecosystem === "go" && <span style={{ fontSize: Math.round(size * 0.38) }}>GO</span>}
31+ </span>
32+ );
33+}
+20−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { formatBytes, installCommands, shortDigest } from "./packages.ts";
4+import { arrange, formatBytes, installCommands, shortCount, shortDigest } from "./packages.ts";
55
66 test("sizes read as registries show them", () => {
77 assert.equal(formatBytes(0), "0 B");
4242 });
4343 assert.equal(installCommands(pkg, null, "ada").install, "docker pull g1t.sh/acme/web");
4444 });
45+
46+test("packages are listed by the visibility and order asked for", () => {
47+ const list = [
48+ { name: "web", visibility: "private" as const, updated_at: "2026-10-01T00:00:00Z", downloads: 5 },
49+ { name: "api", visibility: "public" as const, updated_at: "2026-10-03T00:00:00Z", downloads: 9 },
50+ { name: "cli", visibility: "public" as const, updated_at: "2026-10-02T00:00:00Z", downloads: 9 },
51+ ];
52+ assert.deepEqual(arrange(list, "all", "updated").map((p) => p.name), ["api", "cli", "web"]);
53+ assert.deepEqual(arrange(list, "all", "downloads").map((p) => p.name), ["api", "cli", "web"]);
54+ assert.deepEqual(arrange(list, "all", "name").map((p) => p.name), ["api", "cli", "web"]);
55+ assert.deepEqual(arrange(list, "private", "updated").map((p) => p.name), ["web"]);
56+ assert.deepEqual(arrange(list, "public", "name").map((p) => p.name), ["api", "cli"]);
57+});
58+
59+test("download counts read short", () => {
60+ assert.equal(shortCount(940), "940");
61+ assert.equal(shortCount(16_100), "16.1k");
62+ assert.equal(shortCount(2_000_000), "2M");
63+});
+73−0
1313 go: "Go",
1414 };
1515
16+/**
17+ * Each registry as the Packages page introduces it: what it is for, the
18+ * command that starts one, its guide, and whether it is open yet.
19+ */
20+export const REGISTRIES: { ecosystem: Ecosystem; blurb: string; start: string; guide: string; ready: boolean }[] = [
21+ {
22+ ecosystem: "container",
23+ blurb: "Docker and OCI images, signatures and attestations, pulled by anyone the image's repository lets read it.",
24+ start: "docker push g1t.sh/<workspace>/<name>:<tag>",
25+ guide: "/guides/containers/",
26+ ready: true,
27+ },
28+ {
29+ ecosystem: "npm",
30+ blurb: "JavaScript and TypeScript packages under the workspace's scope, installed with npm, pnpm or yarn.",
31+ start: "npm publish",
32+ guide: "/guides/npm/",
33+ ready: true,
34+ },
35+ {
36+ ecosystem: "composer",
37+ blurb: "PHP packages straight from the workspace's repositories: each tag is a version, nothing to upload.",
38+ start: "git tag v1.0.0 && git push --tags",
39+ guide: "/guides/composer/",
40+ ready: true,
41+ },
42+ {
43+ ecosystem: "go",
44+ blurb: "Go modules fetched from the repositories themselves with go get, private ones with a token.",
45+ start: "go get g1t.sh/<workspace>/<repo>",
46+ guide: "/guides/go/",
47+ ready: true,
48+ },
49+ {
50+ ecosystem: "cargo",
51+ blurb: "Rust crates in a registry of the workspace's own, published with cargo publish.",
52+ start: "cargo publish --registry <workspace>",
53+ guide: "/guides/packages/",
54+ ready: false,
55+ },
56+];
57+
58+export type PackageSort = "updated" | "downloads" | "name";
59+export type VisibilityFilter = "all" | "public" | "private";
60+
61+export const SORT_LABEL: Record<PackageSort, string> = {
62+ updated: "Recently updated",
63+ downloads: "Most downloads",
64+ name: "Name",
65+};
66+
67+export const VISIBILITY_LABEL: Record<VisibilityFilter, string> = { all: "All", public: "Public", private: "Private" };
68+
69+/** The packages to list: those of the visibility asked for, in the order asked for. */
70+export function arrange<T extends Pick<PackageSummary, "visibility" | "updated_at" | "downloads" | "name">>(
71+ list: T[],
72+ visibility: VisibilityFilter,
73+ sort: PackageSort,
74+): T[] {
75+ const shown = visibility === "all" ? [...list] : list.filter((pkg) => pkg.visibility === visibility);
76+ const byName = (a: T, b: T) => a.name.localeCompare(b.name);
77+ if (sort === "name") return shown.sort(byName);
78+ if (sort === "downloads") return shown.sort((a, b) => b.downloads - a.downloads || byName(a, b));
79+ return shown.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || byName(a, b));
80+}
81+
82+/** "940", "16.1k", "2.3M": download counts, short. */
83+export function shortCount(n: number): string {
84+ if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`;
85+ if (n >= 1_000) return `${Number((n / 1_000).toFixed(1))}k`;
86+ return String(Math.max(0, Math.round(n)));
87+}
88+
1689 /** "0 B", "812 KB", "12.3 MB", "1.4 GB", in powers of 1,000 as registries show them. */
1790 export function formatBytes(bytes: number): string {
1891 if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
+1−1
5151 },
5252 packages: {
5353 title: "Packages",
54− about: "What the workspace publishes and installs: container images today, with the same people, tokens and access as its code.",
54+ about: "What the workspace publishes and installs, beside its code: container images, npm, Composer and Go, with the same members, roles and tokens.",
5555 },
5656 usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
5757 billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." },
+3−2
1−import { BookOpen, Box, Lock, Package, Trash2 } from "lucide-react";
1+import { BookOpen, Box, Trash2 } from "lucide-react";
22 import { Form, Link, data, redirect, useNavigation } from "react-router";
33
44 import { ECOSYSTEMS, type Ecosystem, type PackageVersion } from "@g1t/contracts";
55
66 import type { Route } from "./+types/package";
77 import { Markdown } from "../../components/markdown";
8+import { PackageIcon } from "../../components/package-icon";
89 import { ConfirmDialog } from "../../components/repo-lifecycle";
910 import { Button, CopyLine, ErrorText, TimeAgo } from "../../components/ui";
1011 import { Badge } from "../../components/ui/badge";
8889 Packages
8990 </Link>
9091 <div className="flex flex-wrap items-center gap-3">
91− <span className="text-faint">{pkg.visibility === "private" ? <Lock size={18} /> : <Package size={18} />}</span>
92+ <PackageIcon ecosystem={pkg.ecosystem} size={32} />
9293 <h1 className="text-2xl font-semibold tracking-tight">{pkg.name}</h1>
9394 <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge>
9495 <Badge>{pkg.visibility === "private" ? "Private" : "Public"}</Badge>
+211−94
1−import { Box, Lock, Package, Search } from "lucide-react";
1+import { ArrowUpRight, Box, Check, ChevronDown, Download, Lock, Search } from "lucide-react";
22 import { Form, Link, data } from "react-router";
33
44 import { ECOSYSTEMS, type Ecosystem, type PackageSummary } from "@g1t/contracts";
55
66 import type { Route } from "./+types/packages";
7+import { PackageIcon } from "../../components/package-icon";
78 import { CopyLine, EmptyState, TimeAgo } from "../../components/ui";
89 import { Badge } from "../../components/ui/badge";
10+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
911 import { cn } from "../../lib/cn";
1012 import { page } from "../../lib/meta";
11−import { ECOSYSTEM_LABEL, formatBytes } from "../../lib/packages";
13+import {
14+ ECOSYSTEM_LABEL,
15+ type PackageSort,
16+ REGISTRIES,
17+ SORT_LABEL,
18+ VISIBILITY_LABEL,
19+ type VisibilityFilter,
20+ arrange,
21+ formatBytes,
22+ shortCount,
23+} from "../../lib/packages";
1224 import { packages } from "../../lib/services.server";
1325 import { getViewer, roleIn, unwrap } from "../../lib/session.server";
1426
27+const DOCS = "https://docs.g1t.sh";
28+
1529 export function meta({ params, ...args }: Route.MetaArgs) {
1630 return page(args, { title: `Packages · ${params.owner} · g1t` });
1731 }
1832
33+function pick<T extends string>(value: string | null, options: readonly T[], fallback: T): T {
34+ return value && (options as readonly string[]).includes(value) ? (value as T) : fallback;
35+}
36+
1937 export async function loader({ params, context, request }: Route.LoaderArgs) {
2038 const viewer = getViewer(context);
2139 // Members only for now; a workspace's public packages are found through
2240 // their repositories and search.
2341 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2442 const url = new URL(request.url);
25− const asked = url.searchParams.get("type");
26− const ecosystem = asked && (ECOSYSTEMS as readonly string[]).includes(asked) ? (asked as Ecosystem) : null;
43+ const type = url.searchParams.get("type");
44+ const ecosystem = type && (ECOSYSTEMS as readonly string[]).includes(type) ? (type as Ecosystem) : null;
2745 const query = url.searchParams.get("q")?.trim() || null;
28− const list = unwrap(await packages.list(params.owner, viewer, { ecosystem, query }));
29− return { list, ecosystem, query, workspace: params.owner.toLowerCase(), username: viewer?.username ?? "you" };
46+ const visibility = pick<VisibilityFilter>(url.searchParams.get("visibility"), ["all", "public", "private"], "all");
47+ const sort = pick<PackageSort>(url.searchParams.get("sort"), ["updated", "downloads", "name"], "updated");
48+ const found = unwrap(await packages.list(params.owner, viewer, { ecosystem, query }));
49+ // Whether the workspace has any at all decides between a filtered-out
50+ // list and the first-package page.
51+ const any = found.length > 0 || ecosystem != null || query != null;
52+ return {
53+ list: arrange(found, visibility, sort),
54+ any,
55+ ecosystem,
56+ query,
57+ visibility,
58+ sort,
59+ workspace: params.owner.toLowerCase(),
60+ };
3061 }
3162
32−/** The packages a workspace publishes, by registry, with how to start one. */
63+type Filters = { type: string | null; q: string | null; visibility: VisibilityFilter; sort: PackageSort };
64+
65+/** The page's address with one filter changed, defaults left out. */
66+function hrefWith(workspace: string, current: Filters, change: Partial<Filters>): string {
67+ const next = { ...current, ...change };
68+ const params = new URLSearchParams();
69+ if (next.type) params.set("type", next.type);
70+ if (next.q) params.set("q", next.q);
71+ if (next.visibility !== "all") params.set("visibility", next.visibility);
72+ if (next.sort !== "updated") params.set("sort", next.sort);
73+ return `/${workspace}/-/packages${params.size ? `?${params}` : ""}`;
74+}
75+
76+/** The packages a workspace publishes: filtered, sorted, or, with none yet, how to start. */
3377 export default function Packages({ loaderData }: Route.ComponentProps) {
34− const { list, ecosystem, query, workspace, username } = loaderData;
35− const filtered = ecosystem != null || query != null;
78+ const { list, any, ecosystem, query, visibility, sort, workspace } = loaderData;
79+ if (!any) return <ChooseRegistry workspace={workspace} />;
80+ const filters: Filters = { type: ecosystem, q: query, visibility, sort };
81+ const filtered = ecosystem != null || query != null || visibility !== "all";
3682 return (
37− <div className="space-y-6">
38− <div className="flex justify-end">
39− <a href="https://docs.g1t.sh/guides/packages/" className="text-sm text-muted hover:text-fg">
40− How packages work
41− </a>
42− </div>
43−
44− <Form method="get" className="flex flex-wrap items-center gap-2">
45− <label className="relative min-w-0 grow">
46− <span className="sr-only">Search packages</span>
83+ <div className="space-y-4">
84+ <div className="flex flex-wrap items-center gap-2">
85+ <FilterMenu
86+ label="Type"
87+ value={ecosystem ? ECOSYSTEM_LABEL[ecosystem] : "All"}
88+ options={[null, ...ECOSYSTEMS].map((option) => ({
89+ label: option ? ECOSYSTEM_LABEL[option] : "All",
90+ to: hrefWith(workspace, filters, { type: option }),
91+ current: ecosystem === option,
92+ }))}
93+ />
94+ <Form method="get" className="relative min-w-48 grow">
95+ <label className="sr-only" htmlFor="package-search">
96+ Search packages
97+ </label>
4798 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
4899 <input
100+ id="package-search"
49101 name="q"
50102 defaultValue={query ?? ""}
51− placeholder="Find a package"
103+ placeholder="Search packages"
52104 className="h-9 w-full rounded-lg border border-line bg-surface pr-3 pl-8 text-sm placeholder:text-faint focus:border-line-strong focus:outline-none"
53105 />
54− </label>
55− {ecosystem && <input type="hidden" name="type" value={ecosystem} />}
56− <nav aria-label="Registry" className="flex flex-wrap gap-1 text-sm">
57− {[null, ...ECOSYSTEMS].map((option) => {
58− const params = new URLSearchParams();
59− if (option) params.set("type", option);
60− if (query) params.set("q", query);
61− const href = `/${workspace}/-/packages${params.size ? `?${params}` : ""}`;
62− return (
63− <Link
64− key={option ?? "all"}
65− to={href}
66− aria-current={ecosystem === option ? "page" : undefined}
67− className={cn(
68− "rounded-md px-2.5 py-1.5 transition-colors",
69− ecosystem === option ? "bg-raised text-fg" : "text-muted hover:text-fg",
70− )}
71− >
72− {option ? ECOSYSTEM_LABEL[option] : "All"}
106+ {ecosystem && <input type="hidden" name="type" value={ecosystem} />}
107+ {visibility !== "all" && <input type="hidden" name="visibility" value={visibility} />}
108+ {sort !== "updated" && <input type="hidden" name="sort" value={sort} />}
109+ </Form>
110+ <FilterMenu
111+ label="Visibility"
112+ value={VISIBILITY_LABEL[visibility]}
113+ options={(["all", "public", "private"] as const).map((option) => ({
114+ label: VISIBILITY_LABEL[option],
115+ to: hrefWith(workspace, filters, { visibility: option }),
116+ current: visibility === option,
117+ }))}
118+ />
119+ <FilterMenu
120+ label="Sort"
121+ value={SORT_LABEL[sort]}
122+ options={(["updated", "downloads", "name"] as const).map((option) => ({
123+ label: SORT_LABEL[option],
124+ to: hrefWith(workspace, filters, { sort: option }),
125+ current: sort === option,
126+ }))}
127+ />
128+ </div>
129+
130+ <section className="overflow-hidden rounded-xl border border-line bg-surface">
131+ <header className="flex items-center justify-between gap-3 border-b border-line px-4 py-2.5 text-sm">
132+ <span className="font-medium">
133+ {list.length} {list.length === 1 ? "package" : "packages"}
134+ {filtered && (
135+ <Link to={`/${workspace}/-/packages`} className="ml-3 text-xs font-normal text-muted hover:text-fg">
136+ Clear filters
73137 </Link>
74− );
75− })}
76− </nav>
77− </Form>
78−
79− {list.length === 0 ? (
80− filtered ? (
81− <EmptyState title="No packages match">Try another registry, or clear the search.</EmptyState>
138+ )}
139+ </span>
140+ <a href={`${DOCS}/guides/packages/`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
141+ How packages work <ArrowUpRight size={12} />
142+ </a>
143+ </header>
144+ {list.length === 0 ? (
145+ <div className="p-4">
146+ <EmptyState title="No packages match">Try another type or visibility, or clear the search.</EmptyState>
147+ </div>
82148 ) : (
83− <FirstPackage workspace={workspace} username={username} />
84− )
85− ) : (
86− <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
87− {list.map((pkg) => (
88− <PackageRow key={pkg.id} pkg={pkg} />
89− ))}
90− </ul>
91− )}
149+ <ul className="divide-y divide-line">
150+ {list.map((pkg) => (
151+ <PackageRow key={pkg.id} pkg={pkg} />
152+ ))}
153+ </ul>
154+ )}
155+ </section>
92156 </div>
93157 );
94158 }
95159
160+/** A filter as a button that opens its choices; each choice is a link, so the address says the view. */
161+function FilterMenu({ label, value, options }: { label: string; value: string; options: { label: string; to: string; current: boolean }[] }) {
162+ return (
163+ <DropdownMenu>
164+ <DropdownMenuTrigger className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line bg-surface px-3 text-sm transition-colors hover:border-line-strong data-[state=open]:border-line-strong">
165+ <span className="text-muted">{label}:</span>
166+ <span className="font-medium">{value}</span>
167+ <ChevronDown size={13} className="text-faint" />
168+ </DropdownMenuTrigger>
169+ <DropdownMenuContent align="start" className="min-w-44">
170+ {options.map((option) => (
171+ <DropdownMenuItem key={option.label} asChild>
172+ <Link to={option.to} preventScrollReset>
173+ <Check size={14} className={cn(!option.current && "invisible")} />
174+ {option.label}
175+ </Link>
176+ </DropdownMenuItem>
177+ ))}
178+ </DropdownMenuContent>
179+ </DropdownMenu>
180+ );
181+}
182+
96183 function PackageRow({ pkg }: { pkg: PackageSummary }) {
97184 return (
98185 <li>
99186 <Link
100187 to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`}
101− className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 transition-colors hover:bg-raised/50"
188+ className="flex items-center gap-3.5 px-4 py-3.5 transition-colors hover:bg-raised/50"
102189 >
103− <span className="text-faint">{pkg.visibility === "private" ? <Lock size={15} /> : <Package size={15} />}</span>
190+ <PackageIcon ecosystem={pkg.ecosystem} />
104191 <span className="min-w-0 grow">
105192 <span className="flex flex-wrap items-center gap-2">
106193 <span className="truncate font-medium">{pkg.name}</span>
107− <Badge>{ECOSYSTEM_LABEL[pkg.ecosystem]}</Badge>
108− {pkg.visibility === "private" && <Badge>Private</Badge>}
194+ {pkg.visibility === "private" && (
195+ <Badge>
196+ <Lock size={10} />
197+ Private
198+ </Badge>
199+ )}
109200 </span>
110− <span className="mt-0.5 block truncate font-mono text-xs text-faint">{pkg.address}</span>
111− </span>
112− <span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted tabular-nums">
113− {pkg.repo && (
114− <span className="inline-flex items-center gap-1">
115− <Box size={12} />
116− {pkg.repo.name}
201+ <span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
202+ <span>{ECOSYSTEM_LABEL[pkg.ecosystem]}</span>
203+ <span className="text-faint">·</span>
204+ <span>
205+ Updated <TimeAgo at={pkg.updated_at} />
117206 </span>
118− )}
119− {pkg.latest && <span className="font-mono">{pkg.latest}</span>}
120− <span>
121− {pkg.versions} {pkg.versions === 1 ? "version" : "versions"}
207+ {pkg.repo && (
208+ <>
209+ <span className="text-faint">·</span>
210+ <span className="inline-flex items-center gap-1">
211+ <Box size={11} />
212+ {pkg.repo.name}
213+ </span>
214+ </>
215+ )}
216+ {pkg.description && (
217+ <>
218+ <span className="text-faint">·</span>
219+ <span className="max-w-md truncate">{pkg.description}</span>
220+ </>
221+ )}
122222 </span>
123− <span>{formatBytes(pkg.size)}</span>
124− <span>
125− Updated <TimeAgo at={pkg.updated_at} />
223+ </span>
224+ <span className="hidden shrink-0 items-center gap-5 text-xs text-muted tabular-nums sm:flex">
225+ {pkg.latest && <span className="max-w-32 truncate font-mono text-fg-soft">{pkg.latest}</span>}
226+ <span className="w-16 text-right">{formatBytes(pkg.size)}</span>
227+ <span className="inline-flex w-14 items-center justify-end gap-1" title={`${pkg.downloads.toLocaleString("en-US")} downloads`}>
228+ <Download size={12} />
229+ {shortCount(pkg.downloads)}
126230 </span>
127231 </span>
128232 </Link>
130234 );
131235 }
132236
133−/** With no packages yet: how to push the first image. */
134−function FirstPackage({ workspace, username }: { workspace: string; username: string }) {
237+/** With no packages yet: every registry, what it is for, and the first step. */
238+function ChooseRegistry({ workspace }: { workspace: string }) {
135239 return (
136− <section className="rounded-xl border border-line bg-surface p-6">
137− <h2 className="font-medium">Push the workspace's first image</h2>
138− <p className="mt-1 text-sm text-muted">
139− Log in with a token that may write packages, tag an image under the workspace, and push it. An image named after one of the
140− workspace's repositories is linked to it and has its access.
141− </p>
142− <div className="mt-4 space-y-2">
143− <CopyLine prompt text={`docker login g1t.sh -u ${username}`} />
144− <CopyLine prompt text={`docker tag web g1t.sh/${workspace}/web:latest`} />
145− <CopyLine prompt text={`docker push g1t.sh/${workspace}/web:latest`} />
240+ <div className="space-y-6">
241+ <div className="max-w-2xl">
242+ <h2 className="text-lg font-semibold tracking-tight">Choose a registry</h2>
243+ <p className="mt-1 text-sm text-muted">
244+ Packages live beside the code that makes them, with the same members, roles and tokens. Workflows publish with{" "}
245+ <code className="font-mono text-xs">G1T_TOKEN</code>.
246+ </p>
146247 </div>
147− <p className="mt-4 text-xs text-faint">
148− In a workflow, log in with <code className="font-mono">G1T_TOKEN</code>. See{" "}
149− <a href="https://docs.g1t.sh/guides/containers/" className="underline hover:text-fg">
150− Containers
151− </a>
152− .
153− </p>
154− </section>
248+ <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
249+ {REGISTRIES.map((registry) => (
250+ <li key={registry.ecosystem} className="flex flex-col rounded-xl border border-line bg-surface p-4">
251+ <div className="flex items-center gap-2.5">
252+ <PackageIcon ecosystem={registry.ecosystem} />
253+ <span className="font-medium">{ECOSYSTEM_LABEL[registry.ecosystem]}</span>
254+ {!registry.ready && <Badge>Soon</Badge>}
255+ </div>
256+ <p className="mt-2.5 grow text-sm text-muted">{registry.blurb}</p>
257+ {registry.ready && (
258+ <div className="mt-3">
259+ <CopyLine prompt text={registry.start.replaceAll("<workspace>", workspace)} />
260+ </div>
261+ )}
262+ <a
263+ href={`${DOCS}${registry.guide}`}
264+ className="mt-3 inline-flex items-center gap-1 self-start text-sm text-fg-soft hover:text-fg"
265+ >
266+ {registry.ready ? "Set it up" : "What's planned"} <ArrowUpRight size={13} />
267+ </a>
268+ </li>
269+ ))}
270+ </ul>
271+ </div>
155272 );
156273 }