Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"extends": [
"next/core-web-vitals",
"next/typescript"
]
],
"rules": {
"react/no-danger": "error"
}
}
9 changes: 8 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { resolveSiteUrl } from "@/lib/site-url";
import "./globals.css";

const siteUrl = resolveSiteUrl();

export const metadata: Metadata = {
metadataBase: new URL("http://localhost:3000"),
metadataBase: new URL(siteUrl),
title: {
default: "ModelTrace",
template: "%s | " + "ModelTrace",
},
description: "Verifiable AI inference accounting on Stellar.",
applicationName: "ModelTrace",
alternates: {
canonical: "/",
},
openGraph: {
title: "ModelTrace",
description: "Verifiable AI inference accounting on Stellar.",
url: "/",
type: "website",
locale: "en_US",
},
Expand Down
9 changes: 5 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ export default function HomePage() {
<BrandLogo className="landing-logo" aria-label="ModelTrace logo" />
<span className="tag">Stellar · Soroban · AI governance</span>
<h1 className="hero-headline">Prove every inference.</h1>
<p
className="landing-lead"
dangerouslySetInnerHTML={{ __html: "ModelTrace turns AI usage into <strong>attested facts on-chain</strong>: which model ran, under which policy, and what it costs\u2014so procurement, finance, and auditors share one neutral layer." }}
/>
<p className="landing-lead">
ModelTrace turns AI usage into <strong>attested facts on-chain</strong>:
{" "}which model ran, under which policy, and what it costs{"\u2014"}so
procurement, finance, and auditors share one neutral layer.
</p>
<div className="landing-cta-row">
<Link href="/roadmap" className="cta">Ship the roadmap</Link>
<Link href="/contracts" className="cta-secondary">Read the contracts story</Link>
Expand Down
1 change: 1 addition & 0 deletions components/charts/chart-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function ChartFrame({
className={wide ? `${styles.plot} ${styles.plotWide}` : styles.plot}
role="img"
aria-label={summary}
tabIndex={wide ? 0 : undefined}
>
{children}
</div>
Expand Down
4 changes: 4 additions & 0 deletions components/charts/charts.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@

/* Wide charts scroll inside their own box; the page never scrolls sideways. */
.plot { overflow-x: auto; min-width: 0; }
.plot:focus-visible {
outline: 2px solid var(--accent, #59c2ff);
outline-offset: 3px;
}
.plot svg { display: block; max-width: 100%; height: auto; }

/* A plot with an axis has a legibility floor. Scaling an 8-week time series
Expand Down
24 changes: 24 additions & 0 deletions lib/site-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const LOCAL_SITE_URL = "http://localhost:3000";

type SiteUrlEnv = Record<string, string | undefined>;

function normalizeUrl(value: string): string {
const withProtocol = /^https?:\/\//i.test(value) ? value : `https://${value}`;
const url = new URL(withProtocol);
url.pathname = url.pathname.replace(/\/+$/, "");
url.search = "";
url.hash = "";
return url.toString().replace(/\/$/, "");
}

export function resolveSiteUrl(env: SiteUrlEnv = process.env): string {
if (env.NEXT_PUBLIC_SITE_URL?.trim()) {
return normalizeUrl(env.NEXT_PUBLIC_SITE_URL.trim());
}

if (env.VERCEL_URL?.trim()) {
return normalizeUrl(env.VERCEL_URL.trim());
}

return LOCAL_SITE_URL;
}
33 changes: 33 additions & 0 deletions tests/unit/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { resolveSiteUrl } from "../../lib/site-url";

describe("site metadata", () => {
it("uses an explicit public site URL when configured", () => {
expect(
resolveSiteUrl({
NEXT_PUBLIC_SITE_URL: "https://modeltrace.example/",
}),
).toBe("https://modeltrace.example");
});

it("falls back to Vercel's deployment URL for previews", () => {
expect(
resolveSiteUrl({
VERCEL_URL: "modeltrace-git-main-finesse.vercel.app",
}),
).toBe("https://modeltrace-git-main-finesse.vercel.app");
});

it("keeps localhost as the local-only fallback", () => {
expect(resolveSiteUrl({})).toBe("http://localhost:3000");
});

it("sets canonical and Open Graph URLs in the root metadata", () => {
const layout = readFileSync("app/layout.tsx", "utf8");

expect(layout).toContain("metadataBase: new URL(siteUrl)");
expect(layout).toContain("canonical: \"/\"");
expect(layout).toContain("url: \"/\"");
});
});
14 changes: 14 additions & 0 deletions tests/unit/raw-html.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

describe("raw HTML rendering guard", () => {
it("keeps the landing page free of dangerouslySetInnerHTML", () => {
const page = readFileSync("app/page.tsx", "utf8");
expect(page).not.toContain("dangerouslySetInnerHTML");
});

it("enables the React no-danger lint rule", () => {
const eslintConfig = JSON.parse(readFileSync(".eslintrc.json", "utf8"));
expect(eslintConfig.rules["react/no-danger"]).toBe("error");
});
});
Loading