diff --git a/app/contributors/page.tsx b/app/contributors/page.tsx
index 301b35f..7dea599 100644
--- a/app/contributors/page.tsx
+++ b/app/contributors/page.tsx
@@ -1,11 +1,165 @@
-export default function Page() {
+import type { Metadata } from "next";
+import {
+ CODE_OF_CONDUCT_URL,
+ CONTRIBUTOR_REPOSITORIES,
+ getContributorIssues,
+ type ContributorIssue,
+ type RepositoryIssueResult,
+} from "@/lib/contributor-issues";
+
+export const metadata: Metadata = {
+ title: "Contributors",
+ description: "Find open ModelTrace issues and learn how to contribute across the project.",
+};
+
+function displayDifficulty(difficulty: string): string {
+ return difficulty.charAt(0).toLocaleUpperCase() + difficulty.slice(1);
+}
+
+function IssueCard({ issue }: { issue: ContributorIssue }) {
+ return (
+
+
+ {issue.repository}
+ Issue #{issue.number}
+ {issue.difficulty ? (
+ Difficulty: {displayDifficulty(issue.difficulty)}
+ ) : null}
+
+
+ {issue.labels.length > 0 ? (
+
+ {issue.labels.map((label) => - {label}
)}
+
+ ) : null}
+
+ );
+}
+
+function RepositoryIssues({ result }: { result: RepositoryIssueResult }) {
+ const { repository, issues, status } = result;
+
+ return (
+
+
+ {repository.description}
+
+ {status === "unavailable" ? (
+
+ Live issues for this repository are temporarily unavailable.
+
+ ) : issues.length === 0 ? (
+ No matching open issues right now.
+ ) : (
+ <>
+ {status === "partial" ? (
+
+ Some live issues may be temporarily unavailable.
+
+ ) : null}
+
+ {issues.map((issue) => )}
+
+ >
+ )}
+
+ );
+}
+
+export default async function Page() {
+ const repositoryIssues = await getContributorIssues();
+ const allUnavailable = repositoryIssues.every((result) => result.status === "unavailable");
+
return (
-
- Contributors
- Contributors surface — product definition TBD.
-
- Scaffold page — replace with production content, data loaders, and analytics.
-
-
+
+
+ Contributors
+ Help build verifiable AI infrastructure.
+
+ Pick a scoped issue across the ModelTrace frontend, backend, or smart contracts. Live
+ openings are grouped below so you can start where your experience fits best.
+
+
+
+
+
+
+ Live from GitHub
+
Good first issues and help wanted
+
+
Open issues refresh hourly.
+
+ {allUnavailable ? (
+
+ Live issues are temporarily unavailable. You can still visit each repository and use
+ the contribution guides below.
+
+ ) : null}
+
+ {repositoryIssues.map((result) => (
+
+ ))}
+
+
+
+
+ Contribution workflow
+ From issue to review
+
+ -
+ Choose an issue. Read its scope and the target repository's guide
+ before creating a focused branch.
+
+ -
+ Build and verify. Follow that repository's prerequisites and run its
+ complete local quality checks.
+
+ -
+ Prepare a scoped review. Use a Conventional Commit and explain any
+ architectural or interface impact in the pull request.
+
+
+
+
+
+
+ Every contribution must follow the{" "}
+
+ ModelTrace Code of Conduct
+ .
+
+
+
);
}
diff --git a/app/globals.css b/app/globals.css
index ae18896..44e354e 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -290,3 +290,263 @@ a { color: inherit; text-decoration: none; }
background: color-mix(in srgb, var(--surface) 65%, transparent);
letter-spacing: 0.04em;
}
+
+/* Contributors */
+.contributors-page {
+ padding-bottom: 28px;
+}
+
+.contributors-page a:focus-visible {
+ outline: 3px solid var(--accent-2);
+ outline-offset: 3px;
+ border-radius: 3px;
+}
+
+.contributors-hero {
+ padding-top: 52px;
+}
+
+.contributors-hero h1 {
+ max-width: 760px;
+ margin: 0 0 12px;
+ font-size: clamp(2rem, 5vw, 3.4rem);
+ line-height: 1.12;
+ letter-spacing: -0.035em;
+}
+
+.contributors-hero p {
+ max-width: 720px;
+ margin: 0;
+ color: var(--muted);
+ font-size: 1.05rem;
+}
+
+.live-issues,
+.contribution-workflow {
+ border-top: 1px solid color-mix(in srgb, var(--accent) 18%, transparent);
+ padding-top: 36px;
+}
+
+.section-heading,
+.repository-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+}
+
+.section-heading h2,
+.repository-heading h2 {
+ margin: 0;
+}
+
+.section-heading > p {
+ margin: 34px 0 0;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.repository-groups {
+ display: grid;
+ gap: 20px;
+ margin-top: 22px;
+}
+
+.repository-group,
+.contribution-guide {
+ padding: 22px;
+ border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent);
+ border-radius: 14px;
+ background: linear-gradient(
+ 165deg,
+ color-mix(in srgb, var(--surface) 90%, var(--bg)) 0%,
+ var(--surface) 100%
+ );
+}
+
+.repository-kicker {
+ margin: 0 0 2px;
+ color: var(--accent-2);
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.repository-heading > a,
+.contribution-guide > a,
+.conduct-note a {
+ color: var(--accent);
+ font-weight: 650;
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 3px;
+}
+
+.repository-description {
+ margin: 8px 0 18px;
+ color: var(--muted);
+}
+
+.issues-message {
+ margin: 14px 0 0;
+ padding: 14px 16px;
+ border-left: 3px solid var(--accent);
+ background: color-mix(in srgb, var(--accent) 9%, transparent);
+ color: var(--muted);
+}
+
+.issues-message-wide {
+ margin-bottom: 20px;
+}
+
+.contributor-issues,
+.issue-labels {
+ list-style: none;
+ padding: 0;
+}
+
+.contributor-issues {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
+ gap: 12px;
+ margin: 0;
+}
+
+.contributor-issue {
+ min-width: 0;
+ padding: 16px;
+ border: 1px solid color-mix(in srgb, var(--muted) 22%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--bg) 48%, transparent);
+}
+
+.contributor-issue h3 {
+ margin: 8px 0 12px;
+ font-size: 1rem;
+ line-height: 1.45;
+}
+
+.contributor-issue h3 a:hover {
+ color: var(--accent);
+}
+
+.contributor-issue-meta,
+.issue-labels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 10px;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.contributor-issue-meta .difficulty {
+ color: var(--accent-2);
+ font-weight: 650;
+}
+
+.issue-labels {
+ margin: 0;
+ gap: 6px;
+}
+
+.issue-labels li {
+ max-width: 100%;
+ padding: 3px 8px;
+ overflow-wrap: anywhere;
+ border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--accent) 8%, transparent);
+ color: var(--text);
+}
+
+.workflow-steps {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 14px;
+ margin: 20px 0 24px;
+ padding: 0;
+ list-style: none;
+ counter-reset: workflow;
+}
+
+.workflow-steps li {
+ counter-increment: workflow;
+ padding: 18px;
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--surface) 82%, transparent);
+ color: var(--muted);
+}
+
+.workflow-steps li::before {
+ content: counter(workflow, decimal-leading-zero);
+ display: block;
+ margin-bottom: 8px;
+ color: var(--accent-2);
+ font-weight: 800;
+}
+
+.workflow-steps strong {
+ color: var(--text);
+}
+
+.contribution-guides {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 14px;
+}
+
+.contribution-guide h3 {
+ margin: 0 0 14px;
+ font-size: 1rem;
+}
+
+.contribution-guide dl,
+.contribution-guide dd {
+ margin: 0;
+}
+
+.contribution-guide dl {
+ display: grid;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.contribution-guide dt {
+ color: var(--muted);
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.contribution-guide dd {
+ font-size: 0.9rem;
+}
+
+.conduct-note {
+ margin: 22px 0 0;
+ color: var(--muted);
+}
+
+@media (max-width: 760px) {
+ .section-heading,
+ .repository-heading {
+ display: block;
+ }
+
+ .section-heading > p {
+ margin: 8px 0 0;
+ white-space: normal;
+ }
+
+ .repository-heading > a {
+ display: inline-block;
+ margin-top: 8px;
+ }
+
+ .workflow-steps,
+ .contribution-guides {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/components/expected-pages.tsx b/components/expected-pages.tsx
index c666f8c..82fad19 100644
--- a/components/expected-pages.tsx
+++ b/components/expected-pages.tsx
@@ -23,7 +23,7 @@ export function ExpectedPages() {
/operators | Dashboard preview for AI gateways | Planned |
/compliance | Audit exports and policy packs | Planned |
/roadmap | Milestones vs grants | Scaffold |
- /contributors | Good first issues and guild roles | Planned |
+ /contributors | Good first issues and contribution workflow | Shipped |
/docs | Technical reference hub | Scaffold |
diff --git a/lib/contributor-issues.ts b/lib/contributor-issues.ts
new file mode 100644
index 0000000..fa87e03
--- /dev/null
+++ b/lib/contributor-issues.ts
@@ -0,0 +1,224 @@
+const GITHUB_API_URL = "https://api.github.com";
+const GITHUB_ORGANIZATION = "FinesseStudioLab";
+const ISSUE_LABELS = ["good first issue", "help wanted"] as const;
+const REVALIDATE_SECONDS = 60 * 60;
+
+export type ContributorRepository = {
+ name: string;
+ displayName: string;
+ description: string;
+ prerequisite: string;
+ checks: string;
+ url: string;
+ contributingUrl: string;
+};
+
+export const CONTRIBUTOR_REPOSITORIES = [
+ {
+ name: "modeltrace-frontend",
+ displayName: "Frontend",
+ description: "Next.js application, public protocol story, dashboards, and audit exports.",
+ prerequisite: "Node.js 20+",
+ checks: "Lint, typecheck, unit tests, production build, and browser tests",
+ url: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-frontend`,
+ contributingUrl: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-frontend/blob/main/CONTRIBUTING.md`,
+ },
+ {
+ name: "modeltrace-backend",
+ displayName: "Backend",
+ description: "Fastify API, Soroban RPC integration, and privileged gateway workflows.",
+ prerequisite: "Node.js 20+",
+ checks: "Typecheck and production build",
+ url: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-backend`,
+ contributingUrl: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-backend/blob/main/CONTRIBUTING.md`,
+ },
+ {
+ name: "modeltrace-contract",
+ displayName: "Smart contracts",
+ description: "Soroban contracts for attestation, metering, and dispute-aware settlement.",
+ prerequisite: "Rust 1.84+ and the wasm32v1-none target",
+ checks: "Format, Clippy, workspace tests, and release WASM build",
+ url: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-contract`,
+ contributingUrl: `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-contract/blob/main/CONTRIBUTING.md`,
+ },
+] as const satisfies readonly ContributorRepository[];
+
+export const CODE_OF_CONDUCT_URL =
+ `https://github.com/${GITHUB_ORGANIZATION}/modeltrace-frontend/blob/main/CODE_OF_CONDUCT.md`;
+
+export type ContributorIssue = {
+ id: number;
+ number: number;
+ title: string;
+ htmlUrl: string;
+ repository: string;
+ labels: string[];
+ difficulty?: string;
+};
+
+export type RepositoryIssueResult = {
+ repository: ContributorRepository;
+ issues: ContributorIssue[];
+ status: "available" | "partial" | "unavailable";
+};
+
+type GitHubFetch = (
+ input: string,
+ init: RequestInit & { next: { revalidate: number } },
+) => Promise;
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null;
+}
+
+function normalizeLabels(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ const labels = value.flatMap((label) => {
+ if (typeof label === "string") {
+ return [label.trim()];
+ }
+
+ if (isRecord(label) && typeof label.name === "string") {
+ return [label.name.trim()];
+ }
+
+ return [];
+ });
+
+ const seen = new Set();
+ return labels.filter((label) => {
+ const key = label.toLocaleLowerCase();
+ if (!label || seen.has(key)) {
+ return false;
+ }
+
+ seen.add(key);
+ return true;
+ });
+}
+
+export function extractDifficulty(labels: readonly string[]): string | undefined {
+ for (const label of labels) {
+ const match = /^difficulty:\s*(.+)$/i.exec(label);
+ if (match?.[1]) {
+ return match[1].trim().toLocaleLowerCase();
+ }
+ }
+
+ return undefined;
+}
+
+export function normalizeGitHubIssues(
+ payload: unknown,
+ repository: ContributorRepository,
+): ContributorIssue[] {
+ if (!Array.isArray(payload)) {
+ throw new Error("Unexpected GitHub response");
+ }
+
+ return payload.flatMap((value) => {
+ if (!isRecord(value) || "pull_request" in value) {
+ return [];
+ }
+
+ const { id, number, title, html_url: htmlUrl } = value;
+ if (
+ typeof id !== "number" ||
+ typeof number !== "number" ||
+ typeof title !== "string" ||
+ typeof htmlUrl !== "string"
+ ) {
+ return [];
+ }
+
+ const labels = normalizeLabels(value.labels);
+ return [{
+ id,
+ number,
+ title,
+ htmlUrl,
+ repository: repository.name,
+ labels,
+ difficulty: extractDifficulty(labels),
+ }];
+ });
+}
+
+function githubHeaders(): HeadersInit {
+ const headers: Record = {
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ };
+
+ if (process.env.GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
+ }
+
+ return headers;
+}
+
+async function fetchIssuesForLabel(
+ repository: ContributorRepository,
+ label: string,
+ fetcher: GitHubFetch,
+): Promise {
+ const query = new URLSearchParams({
+ state: "open",
+ labels: label,
+ per_page: "100",
+ });
+ const response = await fetcher(
+ `${GITHUB_API_URL}/repos/${GITHUB_ORGANIZATION}/${repository.name}/issues?${query}`,
+ {
+ headers: githubHeaders(),
+ next: { revalidate: REVALIDATE_SECONDS },
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error("GitHub request failed");
+ }
+
+ return normalizeGitHubIssues(await response.json(), repository);
+}
+
+async function fetchRepositoryIssues(
+ repository: ContributorRepository,
+ fetcher: GitHubFetch,
+): Promise {
+ const responses = await Promise.allSettled(
+ ISSUE_LABELS.map((label) => fetchIssuesForLabel(repository, label, fetcher)),
+ );
+ const successful = responses.filter(
+ (response): response is PromiseFulfilledResult =>
+ response.status === "fulfilled",
+ );
+
+ if (successful.length === 0) {
+ return { repository, issues: [], status: "unavailable" };
+ }
+
+ const issuesById = new Map();
+ for (const response of successful) {
+ for (const issue of response.value) {
+ issuesById.set(issue.id, issue);
+ }
+ }
+
+ return {
+ repository,
+ issues: [...issuesById.values()].sort((a, b) => b.number - a.number),
+ status: successful.length === ISSUE_LABELS.length ? "available" : "partial",
+ };
+}
+
+export async function getContributorIssues(
+ fetcher: GitHubFetch = fetch,
+): Promise {
+ return Promise.all(
+ CONTRIBUTOR_REPOSITORIES.map((repository) => fetchRepositoryIssues(repository, fetcher)),
+ );
+}
diff --git a/tests/unit/contributor-issues.test.ts b/tests/unit/contributor-issues.test.ts
new file mode 100644
index 0000000..fa7c92f
--- /dev/null
+++ b/tests/unit/contributor-issues.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ CONTRIBUTOR_REPOSITORIES,
+ extractDifficulty,
+ getContributorIssues,
+ normalizeGitHubIssues,
+} from "../../lib/contributor-issues";
+
+function response(body: unknown, ok = true): Response {
+ return {
+ ok,
+ json: async () => body,
+ } as Response;
+}
+
+describe("contributor issue normalization", () => {
+ it("filters pull requests, normalizes labels, and extracts difficulty", () => {
+ const issues = normalizeGitHubIssues([
+ {
+ id: 1,
+ number: 57,
+ title: "Build the contributors page",
+ html_url: "https://github.com/FinesseStudioLab/modeltrace-frontend/issues/57",
+ labels: [
+ { name: "good first issue" },
+ { name: " difficulty:Easy " },
+ "GOOD FIRST ISSUE",
+ { name: null },
+ ],
+ },
+ {
+ id: 2,
+ number: 58,
+ title: "A pull request",
+ html_url: "https://github.com/FinesseStudioLab/modeltrace-frontend/pull/58",
+ labels: [],
+ pull_request: {},
+ },
+ { id: "invalid" },
+ ], CONTRIBUTOR_REPOSITORIES[0]);
+
+ expect(issues).toHaveLength(1);
+ expect(issues[0]).toMatchObject({
+ id: 1,
+ labels: ["good first issue", "difficulty:Easy"],
+ difficulty: "easy",
+ repository: "modeltrace-frontend",
+ });
+ expect(extractDifficulty(["help wanted"])).toBeUndefined();
+ });
+
+ it("rejects a malformed top-level API response", () => {
+ expect(() => normalizeGitHubIssues({}, CONTRIBUTOR_REPOSITORIES[0])).toThrow();
+ });
+});
+
+describe("contributor issue fetching", () => {
+ it("combines both labels, removes duplicate issues, and configures caching", async () => {
+ const issue = {
+ id: 10,
+ number: 10,
+ title: "Shared issue",
+ html_url: "https://github.com/FinesseStudioLab/modeltrace-frontend/issues/10",
+ labels: [{ name: "good first issue" }, { name: "help wanted" }],
+ };
+ const fetcher = vi.fn(async (_url: string, _init: RequestInit) => response([issue]));
+
+ const results = await getContributorIssues(fetcher);
+
+ expect(results).toHaveLength(3);
+ expect(results.every((result) => result.issues.length === 1)).toBe(true);
+ expect(results.every((result) => result.status === "available")).toBe(true);
+ expect(fetcher).toHaveBeenCalledTimes(6);
+ expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ next: { revalidate: 3600 } });
+ expect(fetcher.mock.calls.map(([url]) => url)).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("labels=good+first+issue"),
+ expect.stringContaining("labels=help+wanted"),
+ ]),
+ );
+ });
+
+ it("keeps successful repositories when another repository fails", async () => {
+ const fetcher = vi.fn(async (url: string) => {
+ if (url.includes("modeltrace-backend")) {
+ throw new Error("rate limited");
+ }
+ return response([]);
+ });
+
+ const results = await getContributorIssues(fetcher);
+
+ expect(results.find(({ repository }) => repository.name === "modeltrace-backend")?.status)
+ .toBe("unavailable");
+ expect(results.filter(({ status }) => status === "available")).toHaveLength(2);
+ expect(results.filter(({ status }) => status === "available").every(({ issues }) =>
+ issues.length === 0)).toBe(true);
+ });
+
+ it("marks a repository partial when only one label request succeeds", async () => {
+ const fetcher = vi.fn(async (url: string) => {
+ if (url.includes("labels=help+wanted")) {
+ return response({}, false);
+ }
+ return response([]);
+ });
+
+ const results = await getContributorIssues(fetcher);
+
+ expect(results.every(({ status }) => status === "partial")).toBe(true);
+ });
+
+ it("degrades safely when all GitHub requests fail", async () => {
+ const fetcher = vi.fn(async () => response({ message: "rate limit" }, false));
+
+ const results = await getContributorIssues(fetcher);
+
+ expect(results.every(({ status, issues }) => status === "unavailable" && issues.length === 0))
+ .toBe(true);
+ });
+});