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"); + }); +});