diff --git a/components/assessment/AssessmentApp.tsx b/components/assessment/AssessmentApp.tsx index 4feb8e2..d375536 100644 --- a/components/assessment/AssessmentApp.tsx +++ b/components/assessment/AssessmentApp.tsx @@ -8,6 +8,7 @@ import { SLICES, allAnswered, computeStage, decodeShareCode, shareCode } from "@ import type { SliceColor, SliceId } from "@lib/rubric/types"; import { VbButton } from "@components/ui/VbButton"; import { getShareUrl, shareNameFromQuery } from "@constants/index"; +import { Box } from "@obolnetwork/obol-ui"; import { CONFETTI_BRAND } from "@lib/theme/tokens"; import { Blockers, @@ -107,7 +108,23 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { - + + + Validator self-assessment + {a.atIntro ? ( diff --git a/components/assessment/Question.tsx b/components/assessment/Question.tsx index 0edc8ed..25dc661 100644 --- a/components/assessment/Question.tsx +++ b/components/assessment/Question.tsx @@ -67,6 +67,7 @@ export function Question({ type="button" selected={sel} color={o.color} + aria-pressed={sel} onClick={() => onChoose(sliceId, o.color)} > @@ -74,7 +75,9 @@ export function Question({ {o.label} {o.sub && {o.sub}} - {sel ? "✓" : ""} + ); })} diff --git a/components/landing/Landing.tsx b/components/landing/Landing.tsx index f2717e0..9091772 100644 --- a/components/landing/Landing.tsx +++ b/components/landing/Landing.tsx @@ -689,14 +689,16 @@ export function Landing() { return ( - - - - - - - - + + + + + + + + + + ); diff --git a/components/layout/SiteFooter.tsx b/components/layout/SiteFooter.tsx index eb5d312..0051143 100644 --- a/components/layout/SiteFooter.tsx +++ b/components/layout/SiteFooter.tsx @@ -1,7 +1,12 @@ import { Box, Text } from "@obolnetwork/obol-ui"; import type { CSS } from "@stitches/react"; import { TopNavLink, TopSpacer } from "@components/assessment/stitches"; -import { ASSESS_PATH, METHODOLOGY_PATH, VALOS_URL } from "@constants/index"; +import { + ASSESS_PATH, + GITHUB_URL, + METHODOLOGY_PATH, + VALOS_URL, +} from "@constants/index"; const navLink: CSS = { fontSize: "$2", @@ -54,6 +59,15 @@ export function SiteFooter({ contentWidth = 1140 }: SiteFooterProps) { > valOS + + GitHub + Assess diff --git a/components/layout/SiteHeader.tsx b/components/layout/SiteHeader.tsx index 93934f1..3f05ed9 100644 --- a/components/layout/SiteHeader.tsx +++ b/components/layout/SiteHeader.tsx @@ -90,7 +90,24 @@ export function SiteHeader({ contentWidth = 1140 }: SiteHeaderProps) { const onAssess = router.pathname === "/assess"; return ( - + + + Skip to content + (undefined); + + const copy = () => { + const url = `${window.location.origin}${window.location.pathname}#${id}`; + void navigator.clipboard?.writeText(url).then(() => { + setCopied(true); + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 1600); + }); + }; + + return ( + + {children} + + # + + {copied && ( + + Link copied + + )} + + ); +} diff --git a/components/pizza/Pizza.tsx b/components/pizza/Pizza.tsx index 8ef712c..3f3974b 100644 --- a/components/pizza/Pizza.tsx +++ b/components/pizza/Pizza.tsx @@ -93,6 +93,14 @@ export function Pizza({ const circ = 2 * Math.PI * ringR; const frac = answered / SLICES.length; + const sliceDesc = SLICES.map( + (s) => `${s.short} ${answers[s.id] ?? "unanswered"}`, + ).join(", "); + const ariaLabel = + stage != null + ? `Validator resilience pizza — Stage ${stage}. ${sliceDesc}.` + : `Validator resilience pizza — ${answered} of ${SLICES.length} slices answered. ${sliceDesc}.`; + return ( {big && ( diff --git a/constants/index.ts b/constants/index.ts index 28c0a52..590aad7 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -8,6 +8,7 @@ export const SITE_URL = export const ASSESS_PATH = "/assess/"; export const METHODOLOGY_PATH = "/methodology/"; export const VALOS_URL = "https://lidofinance.github.io/valos/valos-spec.html"; +export const GITHUB_URL = "https://github.com/ObolNetwork/validator-beat"; export { getShareUrl, diff --git a/lib/share/eth-mark.ts b/lib/share/eth-mark.ts new file mode 100644 index 0000000..a85e1cd --- /dev/null +++ b/lib/share/eth-mark.ts @@ -0,0 +1,33 @@ +/** + * Ethereum eth-diamond logomark inside a brand-green circular badge — the same + * lockup as public/icon.svg (green disc, light diamond), rendered inline. + * Polygon points are the Wikimedia originals (native viewBox ≈ 256×417); the + * opacity tiers preserve the 3D facets. + * + * @param cx badge centre x, in the parent SVG's user units + * @param cy badge centre y + * @param radius badge radius in px + * @param circleFill disc colour (brand green) + * @param diamondFill diamond colour (light grey / white) + */ +export function ethBadge( + cx: number, + cy: number, + radius: number, + circleFill: string, + diamondFill: string, +): string { + // Diamond height ≈ 57% of the badge diameter (matches icon.svg's 0.7 scale). + const s = (radius * 1.14) / 416.906; + const tx = (cx - 127.96 * s).toFixed(2); + const ty = (cy - 208.45 * s).toFixed(2); + return ` + + + + + + + + `; +} diff --git a/lib/share/landing-og-svg.ts b/lib/share/landing-og-svg.ts new file mode 100644 index 0000000..1bc3ae1 --- /dev/null +++ b/lib/share/landing-og-svg.ts @@ -0,0 +1,45 @@ +import type { SliceColor } from "@lib/rubric/types"; +import { + FONT, + OG_ACCENT, + OG_HEAD, + OG_HUB_IDLE_STROKE, + OG_SUB, + ogCta, + ogHeader, + ogPizza, + ogPulse, + ogShell, +} from "@lib/share/og-card"; +import { PIZZA_FILL } from "@lib/theme/tokens"; + +/** + * Default OG card for the landing page — the "What stage is your validator?" + * hook with a mixed pizza and a STAGE ? hub, so viewers realise they don't + * know their own answer. Rendered to public/og/landing.png at build time. + */ +export function landingOgSvg( + displayHost: string = "validatorbeat.com", +): string { + // Mixed, mostly-fine-with-one-red — "you're probably not as safe as you think". + const colors: SliceColor[] = [ + "green", + "yellow", + "red", + "green", + "yellow", + "green", + ]; + + return ogShell(` + ${ogHeader(displayHost)} + THE STANDARD FOR ETHEREUM STAKING SECURITY + What stage is + your validator? + Stage 0 — one failure can slash it. + Stage 1 — one failure won't slash it. + Stage 2 — one failure won't cause an outage. + ${ogPulse(514)} + ${ogCta(`Find your stage → ${displayHost}`, 578)} + ${ogPizza({ colors, hubText: "?", hubTextColor: PIZZA_FILL.yellow, hubStroke: OG_HUB_IDLE_STROKE })}`); +} diff --git a/lib/share/og-card.ts b/lib/share/og-card.ts new file mode 100644 index 0000000..ab036a5 --- /dev/null +++ b/lib/share/og-card.ts @@ -0,0 +1,130 @@ +/** + * Shared building blocks for the dark OG-card system (1200×630). + * + * Both the landing card (lib/share/landing-og-svg.ts) and the 729 share + * cards (lib/share/pizza-og-svg.ts) compose these pieces so layout, palette, + * and the pizza rendering stay pixel-identical across the whole OG surface. + * Cards are rendered to PNG at build time by scripts/generate-og-images.ts. + * + * Layout contract: text column starts at x=64; pizza sits at cx=905 cy=348 + * r=190; CTA baseline at y=556. Sizes are chosen for feed-scale legibility — + * cards display ~500px wide, so nothing here should go below ~19px. + */ +import { SLICES } from "@lib/rubric"; +import type { SliceColor, Stage } from "@lib/rubric/types"; +import { ethBadge } from "@lib/share/eth-mark"; +import { PIZZA_FILL, THEME_BRAND } from "@lib/theme/tokens"; + +export const OG_W = 1200; +export const OG_H = 630; + +export const FONT = "DM Sans, system-ui, sans-serif"; +export const MONO = "ui-monospace, monospace"; + +// Dark palette — tuned for contrast in social feeds, not for the (light) site. +export const OG_BG = "#0e1b1d"; +export const OG_PLATE = "#152829"; +export const OG_HEAD = "#f2f6f4"; +export const OG_SUB = "#9fb3ae"; +export const OG_LABEL = "#a9b8b3"; +export const OG_URL = "#6f827e"; +export const OG_ACCENT = "#2fe4ab"; +export const OG_HUB_FILL = "#0f2123"; +export const OG_HUB_IDLE_STROKE = "#2a4a48"; +const OG_EMPTY = "#1b2f30"; +const OG_EMPTY_STROKE = "#3a4f4c"; + +export const OG_STAGE_COLOR: Record = { + 0: PIZZA_FILL.red, + 1: PIZZA_FILL.yellow, + 2: PIZZA_FILL.green, +}; + +export function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +const RAD = Math.PI / 180; + +function polar(cx: number, cy: number, r: number, deg: number) { + return [cx + r * Math.cos(deg * RAD), cy + r * Math.sin(deg * RAD)] as const; +} + +function wedgePath(cx: number, cy: number, r: number, a0: number, a1: number) { + const [x0, y0] = polar(cx, cy, r, a0); + const [x1, y1] = polar(cx, cy, r, a1); + const large = a1 - a0 > 180 ? 1 : 0; + return `M${cx},${cy} L${x0.toFixed(1)},${y0.toFixed(1)} A${r},${r} 0 ${large} 1 ${x1.toFixed(1)},${y1.toFixed(1)} Z`; +} + +/** SVG open tag, dark background, and a teal glow behind the pizza. */ +export function ogShell(inner: string): string { + return ` + + + + + + + + ${inner} +`; +} + +/** Eth badge + wordmark top-left, URL top-right. */ +export function ogHeader(urlText: string): string { + return ` + ${ethBadge(94, 66, 22, THEME_BRAND, "#e6e6e6")} + Validator Beat + ${escapeXml(urlText)}`; +} + +export type OgPizzaOpts = { + /** One color per slice in SLICES order; null renders a dashed empty wedge. */ + colors: (SliceColor | null)[]; + hubText: string; + hubTextColor: string; + hubStroke: string; +}; + +/** The six-slice pizza with labels and a stage hub, at the fixed right-column position. */ +export function ogPizza(o: OgPizzaOpts): string { + const cx = 905; + const cy = 348; + const r = 190; + const gap = 1.6; + + const wedges = SLICES.map((s, i) => { + const a0 = -90 + i * 60 + gap; + const a1 = -90 + (i + 1) * 60 - gap; + const mid = (a0 + a1) / 2; + const [lx, ly] = polar(cx, cy, r + 34, mid); + const col = o.colors[i]; + const fill = col ? PIZZA_FILL[col] : OG_EMPTY; + const dash = col + ? "" + : ` stroke-dasharray="5 4" stroke="${OG_EMPTY_STROKE}" stroke-width="1.5"`; + return ` + + ${escapeXml(s.short)}`; + }).join(""); + + return ` + ${wedges} + + STAGE + ${escapeXml(o.hubText)}`; +} + +/** Bottom-left call to action in the accent green. */ +export function ogCta(text: string, y: number = 556): string { + return `${escapeXml(text)}`; +} + +/** A single EKG heartbeat across the text column — the "Beat" motif. */ +export function ogPulse(y: number): string { + const x0 = 64; + const x1 = 620; + const m = (x0 + x1) / 2; + return ``; +} diff --git a/lib/share/og-meta.ts b/lib/share/og-meta.ts index 822f181..0e66775 100644 --- a/lib/share/og-meta.ts +++ b/lib/share/og-meta.ts @@ -16,8 +16,8 @@ export function shareOgMeta(answers: Answers) { }).join(" · "); return { - title: `${STAGE_META[stage].name} · ${STAGE_META[stage].kind}`, - description: `${sliceLine}. Self-assessed validator security on ValidatorBeat.com`, + title: `My validator is ${STAGE_META[stage].name} · ${STAGE_META[stage].kind}`, + description: `${sliceLine}. What stage is yours? Rate any validator in 60 seconds on ValidatorBeat.com — free, nothing stored.`, }; } diff --git a/lib/share/pizza-og-svg.ts b/lib/share/pizza-og-svg.ts index 00e3296..d7dff78 100644 --- a/lib/share/pizza-og-svg.ts +++ b/lib/share/pizza-og-svg.ts @@ -1,97 +1,64 @@ import { SLICES, STAGE_META, computeStage } from "@lib/rubric"; -import type { Answers, Stage } from "@lib/rubric/types"; +import type { Answers } from "@lib/rubric/types"; import { - PIZZA_EMPTY, - PIZZA_EMPTY_STROKE, - PIZZA_FILL, - PIZZA_INK, - PIZZA_PLATE, - THEME_BRAND, -} from "@lib/theme/tokens"; + FONT, + OG_HEAD, + OG_HUB_IDLE_STROKE, + OG_LABEL, + OG_STAGE_COLOR, + OG_SUB, + escapeXml, + ogCta, + ogHeader, + ogPizza, + ogPulse, + ogShell, +} from "@lib/share/og-card"; +import { PIZZA_FILL } from "@lib/theme/tokens"; -const W = 1200; -const H = 630; -const RAD = Math.PI / 180; - -function polar(cx: number, cy: number, r: number, deg: number) { - return [cx + r * Math.cos(deg * RAD), cy + r * Math.sin(deg * RAD)] as const; -} - -function wedgePath(cx: number, cy: number, r: number, a0: number, a1: number) { - const [x0, y0] = polar(cx, cy, r, a0); - const [x1, y1] = polar(cx, cy, r, a1); - const large = a1 - a0 > 180 ? 1 : 0; - return `M${cx},${cy} L${x0.toFixed(1)},${y0.toFixed(1)} A${r},${r} 0 ${large} 1 ${x1.toFixed(1)},${y1.toFixed(1)} Z`; -} - -const STAGE_COLOR: Record = { - 0: PIZZA_FILL.red, - 1: PIZZA_FILL.yellow, - 2: PIZZA_FILL.green, -}; - -function escapeXml(s: string) { - return s.replace(/&/g, "&").replace(//g, ">"); -} +const EMPTY_DOT = "#5a6a66"; +/** + * OG card for a share result (/GYRYGG etc.) — "MY VALIDATOR SETUP IS Stage N" + * with the result pizza. The viral loop: the card states the sharer's stage + * and asks the viewer theirs. + */ export function pizzaOgSvg( answers: Answers, code: string, displayHost: string = "validatorbeat.com", ): string { const stage = computeStage(answers); - const cx = 300; - const cy = 315; - const r = 200; - const gap = 1.6; - - const wedges = SLICES.map((s, i) => { - const a0 = -90 + i * 60 + gap; - const a1 = -90 + (i + 1) * 60 - gap; - const mid = (a0 + a1) / 2; - const col = answers[s.id]; - const fill = col ? PIZZA_FILL[col] : PIZZA_EMPTY; - const [lx, ly] = polar(cx, cy, r + 34, mid); - const dash = col - ? "" - : ` stroke="${PIZZA_EMPTY_STROKE}" stroke-width="1.2" stroke-dasharray="5 4" opacity="0.7"`; - return ` - - ${escapeXml(s.short)}`; - }).join(""); + const colors = SLICES.map((s) => answers[s.id] ?? null); - const stageBlock = - stage == null - ? `Validator Beat` - : ` - MY VALIDATOR SETUP IS - Stage ${stage} - ${STAGE_META[stage].kind.toUpperCase()} - ${escapeXml(STAGE_META[stage].shareLine)} - ${SLICES.map((s) => { - const col = answers[s.id]; - const dot = col ? PIZZA_FILL[col] : PIZZA_EMPTY_STROKE; - return `${escapeXml(s.short)} `; - }).join("· ")}`; + if (stage == null) { + // Partial result (share codes with "_") — no stage to headline yet. + return ogShell(` + ${ogHeader(`${displayHost}/${code}`)} + MY VALIDATOR SETUP IS + In progress + Six checks, one Stage — nothing stored. + ${ogPulse(470)} + ${ogCta(`What stage is yours? → ${displayHost}`)} + ${ogPizza({ colors, hubText: "?", hubTextColor: PIZZA_FILL.yellow, hubStroke: OG_HUB_IDLE_STROKE })}`); + } - const hub = - stage != null - ? ` - STAGE - ${stage}` - : ""; + const stageColor = OG_STAGE_COLOR[stage]; + // Keep each label snug against its own dot; put the breathing room around + // the "·" separators so the label↔dot pairing reads unambiguously. + const dots = SLICES.map((s) => { + const c = answers[s.id]; + const dot = c ? PIZZA_FILL[c] : EMPTY_DOT; + return `${escapeXml(s.short)} `; + }).join(`· `); - return ` - - - - Validator Beat - ${escapeXml(displayHost)}/${code} - - ${wedges} - ${hub} - ${stageBlock} - How resilient is your validator? Find out at ValidatorBeat.com→ -`; + return ogShell(` + ${ogHeader(`${displayHost}/${code}`)} + MY VALIDATOR SETUP IS + Stage ${stage} + ${escapeXml(STAGE_META[stage].kind.toUpperCase())} + ${escapeXml(STAGE_META[stage].shareLine)} + ${dots} + ${ogCta(`What stage is yours? → ${displayHost}`)} + ${ogPizza({ colors, hubText: String(stage), hubTextColor: stageColor, hubStroke: stageColor })}`); } diff --git a/package.json b/package.json index dc12003..66579e3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "dev": "next dev", "build:og": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' scripts/generate-og-images.ts", "build:icons": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' scripts/generate-icons.ts", - "build": "yarn build:og && next build", + "build:agents": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' scripts/generate-agent-files.ts", + "build": "yarn build:og && yarn build:agents && next build", "start": "next start", "lint": "next lint", "test": "jest", @@ -22,41 +23,41 @@ }, "dependencies": { "@obolnetwork/obol-ui": "1.1.19", - "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-tooltip": "1.2.12", "@stitches/react": "1.2.8", "html-to-image": "1.11.13", - "next": "15.5.15", + "next": "15.5.18", "next-themes": "^0.4.6", - "react": "19.2.3", - "react-dom": "19.2.3", - "react-is": "19.2.3" + "react": "19.2.7", + "react-dom": "19.2.7", + "react-is": "19.2.7" }, "devDependencies": { "@testing-library/jest-dom": "6.9.1", "@types/jest": "29.5.14", "@types/node": "24.13.2", - "@types/react": "19.2.7", + "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@typescript-eslint/eslint-plugin": "8.59.1", - "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/eslint-plugin": "8.64.0", + "@typescript-eslint/parser": "8.64.0", "eslint": "8.57.1", - "eslint-config-next": "15.5.15", + "eslint-config-next": "15.5.18", "husky": "9.1.7", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", "js-yaml": "4.1.1", "lint-staged": "16.4.0", - "prettier": "3.8.3", + "prettier": "3.9.5", "sharp": "0.33.5", - "ts-jest": "29.4.9", + "ts-jest": "29.4.11", "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "typescript": "5.9.3", "zod": "3.25.76" }, "resolutions": { - "@obolnetwork/obol-ui/react": "19.2.3", - "@obolnetwork/obol-ui/react-dom": "19.2.3" + "@obolnetwork/obol-ui/react": "19.2.7", + "@obolnetwork/obol-ui/react-dom": "19.2.7" }, "packageManager": "yarn@1.22.22" } diff --git a/pages/_app.tsx b/pages/_app.tsx index 7dc44e8..5cbff47 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -4,9 +4,10 @@ import "@styles/colors_and_type.css"; import "@styles/obol-bridge.css"; import "@styles/pizza.css"; import "@styles/methodology.css"; -import { SITE_DESCRIPTION, SITE_NAME } from "@constants/index"; +import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@constants/index"; import type { AppProps } from "next/app"; import Head from "next/head"; +import { useRouter } from "next/router"; const globalStyles = globalCss({ html: { @@ -32,6 +33,7 @@ const globalStyles = globalCss({ export default function App({ Component, pageProps }: AppProps) { globalStyles(); + const router = useRouter(); const title = typeof pageProps.title === "string" @@ -41,12 +43,19 @@ export default function App({ Component, pageProps }: AppProps) { typeof pageProps.description === "string" ? pageProps.description : SITE_DESCRIPTION; + + const base = SITE_URL.replace(/\/$/, ""); + // Every page shares one OG image unless it brings its own (share results do). const ogImage = - typeof pageProps.ogImage === "string" ? pageProps.ogImage : undefined; + typeof pageProps.ogImage === "string" + ? pageProps.ogImage + : `${base}/og/landing.png`; + // Canonical falls back to the route itself (trailingSlash: true everywhere). + const routePath = router.asPath.split(/[?#]/)[0]; const canonicalUrl = typeof pageProps.canonicalUrl === "string" ? pageProps.canonicalUrl - : undefined; + : `${base}${routePath.endsWith("/") ? routePath : `${routePath}/`}`; return ( {title} + + - {canonicalUrl && } - {ogImage && ( - <> - - - - - - - )} + + + + + + + + diff --git a/pages/index.tsx b/pages/index.tsx index ad5ccb6..f726a11 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,14 +1,49 @@ import { Landing } from "@components/landing/Landing"; +import { SITE_URL } from "@constants/index"; import type { GetStaticProps } from "next"; +import Head from "next/head"; export default function HomePage() { - return ; + const base = SITE_URL.replace(/\/$/, ""); + const jsonLd = { + "@context": "https://schema.org", + "@type": "WebApplication", + name: "Validator Beat", + url: `${base}/`, + description: + "A free self-assessment that scores Ethereum validator setups Stage 0, 1, or 2 across six single points of failure: key custody, client diversity, infrastructure, OS, CPU architecture, and geography.", + applicationCategory: "FinanceApplication", + operatingSystem: "Web", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + publisher: { + "@type": "Organization", + name: "Obol", + url: "https://obol.org", + }, + }; + + return ( + <> + +