diff --git a/.eslintrc.json b/.eslintrc.json
index 6b10a5b..564b29e 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -2,5 +2,8 @@
"extends": [
"next/core-web-vitals",
"next/typescript"
- ]
+ ],
+ "rules": {
+ "react/no-danger": "error"
+ }
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 0875230..9deb9d7 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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",
},
diff --git a/app/page.tsx b/app/page.tsx
index 206226b..3cfb7fa 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -11,10 +11,11 @@ export default function HomePage() {
Stellar · Soroban · AI governance
Prove every inference.
-
attested facts on-chain: which model ran, under which policy, and what it costs\u2014so procurement, finance, and auditors share one neutral layer." }}
- />
+
+ ModelTrace turns AI usage into attested facts on-chain:
+ {" "}which model ran, under which policy, and what it costs{"\u2014"}so
+ procurement, finance, and auditors share one neutral layer.
+
Ship the roadmap
Read the contracts story
diff --git a/components/charts/chart-frame.tsx b/components/charts/chart-frame.tsx
index 7c864c0..2dc21e2 100644
--- a/components/charts/chart-frame.tsx
+++ b/components/charts/chart-frame.tsx
@@ -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}
diff --git a/components/charts/charts.module.css b/components/charts/charts.module.css
index 47afa98..82d4447 100644
--- a/components/charts/charts.module.css
+++ b/components/charts/charts.module.css
@@ -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
diff --git a/lib/site-url.ts b/lib/site-url.ts
new file mode 100644
index 0000000..2464e65
--- /dev/null
+++ b/lib/site-url.ts
@@ -0,0 +1,24 @@
+const LOCAL_SITE_URL = "http://localhost:3000";
+
+type SiteUrlEnv = Record;
+
+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;
+}
diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts
new file mode 100644
index 0000000..113cd67
--- /dev/null
+++ b/tests/unit/metadata.test.ts
@@ -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: \"/\"");
+ });
+});
diff --git a/tests/unit/raw-html.test.ts b/tests/unit/raw-html.test.ts
new file mode 100644
index 0000000..d721930
--- /dev/null
+++ b/tests/unit/raw-html.test.ts
@@ -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");
+ });
+});