diff --git a/CLAUDE.md b/CLAUDE.md index fce858d..dd69717 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,22 @@ Pre-commit (husky) runs `yarn test` + `yarn lint`. Lint-staged additionally runs - Copy lives close to the rubric: question text in `lib/assessment/questions.ts`, slice `why` and remediation tips in `lib/rubric/index.ts`. Methodology page reads from `SLICES` so descriptions stay in sync. - Styling is a hybrid: `@obolnetwork/obol-ui` Stitches components (see `components/assessment/stitches.ts`) for the assessment shell, plus plain CSS files in `styles/` (CSS custom properties in `theme-tokens.css` are the palette source of truth — if you change pizza slice colors there, also update `lib/theme/tokens.ts`). +### UI and styling (use obol-ui) + +**Default:** build UI with `@obolnetwork/obol-ui` primitives — `Box`, `Text`, `Button`, `Link` — and the project’s Stitches tokens (`$bg01`, `$body`, `$textMiddle`, `var(--theme-brand)`, etc.). Bridge tokens live in `styles/theme-tokens.css` and `styles/obol-bridge.css`. + +- Prefer the **`css` prop** on `Box` / `Text` (see `components/layout/Navbar.tsx`, `components/landing/Landing.tsx`) or **reuse styled exports** from `components/assessment/stitches.ts` (`Card`, `Eyebrow`, `BrandLink`, `TopNavLink`, `risk`, …) instead of new page-specific CSS files or BEM class strings. +- Use **`VbButton`** (`components/ui/VbButton.tsx`) for primary actions in the assessment flow. +- **Do not** add separate per-page CSS files (e.g. a dedicated `landing.css`) unless there is a strong reason; the landing page was migrated off that pattern. + +**Exceptions (plain CSS or inline SVG is fine):** + +- **Complex animations** (keyframes, multi-step pulses) — e.g. a hero “heartbeat” effect; a small block in an existing global stylesheet or component-level `@keyframes` in `css` is OK if Stitches/obol-ui makes it awkward. +- **SVG visuals** tied to the product (`Pizza`, brand mark) — not generic UI icons; use `@radix-ui/react-icons` (already in the tree via obol-ui) for arrows, external-link, etc. (`components/landing/icons.tsx`). +- **Methodology** — still uses `styles/methodology.css` for now; new work should still prefer obol-ui where practical. + +**Site chrome (future):** landing and assessment currently use different headers/footers. Planned UX is a shared slim header + compact assess footer — see **Future UX** in [`README.md`](./README.md). Do not implement unless asked. + ## Deferred work (don't build yet unless asked) `data/operators/`, `content/methodology/`, `components/operators/`, and `lib/schemas/` are placeholder dirs for the **v1.2 operator registry** (YAML profiles + Zod validation + a `/operators` summary table). `scripts/validate-operators.ts` and `scripts/import-survey-csv.ts` are scaffolding for that phase. v0.1 deliberately does not ship any of this — the `/operators` route currently redirects to `/`. diff --git a/README.md b/README.md index 9d58c98..5300a1f 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,26 @@ Every push to `main` runs CI and, on success, deploys the static `out/` folder t **Theming:** edit [`styles/theme-tokens.css`](styles/theme-tokens.css) to change colors (Lido forum–aligned by default). If you change pizza slice colors, also update [`lib/theme/tokens.ts`](lib/theme/tokens.ts). +## Future UX: shared chrome across landing and assessment + +Today the **landing** (`/`) and **assessment** (`/assess/`) use different shells: the marketing page has a full nav + footer; the assessment uses a slim `TopBar` and a one-line `Footnote` inside a `100vh` focus layout (`AssessmentApp`). That is intentional for the six-question wizard (less distraction, pizza always visible) but can feel like leaving the site when users click “Assess your validator.” + +**Recommended direction (not implemented yet):** + +1. **One shared slim header** on `/`, `/assess/`, and `/methodology/` — logo (home), Methodology, valOS (external), theme toggle; primary CTA on marketing pages only. +2. **Compact footer on assess** — keep the trust line (“nothing submitted or stored”) plus links (Methodology · valOS · Home), not the full landing footer. +3. **Keep focus mode during Q1–Q6** — do not wrap the wizard in the full marketing layout; optional richer links on intro/results only if needed later. + +**Avoid:** pasting the full landing nav + footer around the assessment grid — it costs too much vertical space on mobile and fights the locked viewport. + +See `components/assessment/AssessmentApp.tsx` (`TopBar`, `Footnote`, `Shell`) and `components/landing/Landing.tsx` (`LNav`, `LFooter`) for the current split. + ## v0.1 routes | Route | Purpose | |-------|---------| -| `/` | Self-assessment (intro → 6 slices → results) | +| `/` | Marketing landing page | +| `/assess/` | Self-assessment (intro → 6 slices → results) | | `/GYRYGG` | Open a shared result (729 static pages + OG preview image) | | `/GYRYGG?n=Kody%27s%20Cluster` | Same, with optional display name (not in link preview) | | `/methodology/` | Framework and stage definitions | diff --git a/components/landing/Landing.tsx b/components/landing/Landing.tsx new file mode 100644 index 0000000..0d20854 --- /dev/null +++ b/components/landing/Landing.tsx @@ -0,0 +1,815 @@ +import { Box, Text } from "@obolnetwork/obol-ui"; +import NextLink from "next/link"; +import { Pizza } from "@components/pizza/Pizza"; +import { + BrandAccent, + BrandLink, + Card, + Eyebrow, + HeroCard, + RiskDot, + TopNavLink, + TopSpacer, + risk, +} from "@components/assessment/stitches"; +import { ThemeToggle } from "@components/assessment/ThemeToggle"; +import type { Answers, SliceId } from "@lib/rubric/types"; +import type { CSS } from "@stitches/react"; +import { + IconArrowDown, + IconArrowRight, + IconExternalLink, + IconLiveness, + IconSafety, +} from "./icons"; + +const ASSESS = "/assess/"; +const METHODOLOGY = "/methodology/"; +const VALOS = "https://lidofinance.github.io/valos/valos-spec.html"; + +const wrap: CSS = { maxWidth: 1140, margin: "0 auto", padding: "0 28px" }; +const section: CSS = { padding: "88px 0", "@media (max-width: 760px)": { padding: "62px 0" } }; +const sectionBand: CSS = { + backgroundColor: "$bg03", + borderTop: "1px solid $bg05", + borderBottom: "1px solid $bg05", +}; +const sectionHead: CSS = { maxWidth: 760 }; +const h1: CSS = { + fontSize: 56, + lineHeight: 1.04, + letterSpacing: "-0.03em", + fontWeight: "$bold", + color: "$body", + margin: 0, + textWrap: "balance", + "@media (max-width: 760px)": { fontSize: 40 }, +}; +const h2: CSS = { + fontSize: 38, + lineHeight: 1.1, + letterSpacing: "-0.025em", + fontWeight: "$bold", + color: "$body", + margin: 0, + textWrap: "balance", + "@media (max-width: 760px)": { fontSize: 30 }, +}; +const h3: CSS = { + fontSize: 20, + lineHeight: 1.25, + letterSpacing: "-0.01em", + fontWeight: "$bold", + color: "$body", + margin: 0, +}; +const lede: CSS = { + fontSize: "$4", + lineHeight: 1.6, + color: "$textMiddle", + marginTop: 20, + textWrap: "pretty", + "& strong": { color: "$body", fontWeight: "$semibold" }, +}; +const prose: CSS = { + fontSize: "$3", + lineHeight: 1.65, + color: "$textMiddle", + marginTop: 18, + textWrap: "pretty", + "& strong": { color: "$body", fontWeight: "$semibold" }, +}; +const pullQuote: CSS = { + marginTop: 28, + fontSize: 21, + lineHeight: 1.45, + fontWeight: "$semibold", + color: "$body", + letterSpacing: "-0.01em", + maxWidth: 760, + textWrap: "pretty", +}; +const ctaRow: CSS = { + display: "flex", + alignItems: "center", + gap: 18, + flexWrap: "wrap", + marginTop: 32, +}; +const primaryLink: CSS = { + display: "inline-flex", + alignItems: "center", + gap: 8, + fontSize: "$3", + fontWeight: "$semibold", + textDecoration: "none", + whiteSpace: "nowrap", + borderRadius: "$3", + padding: "12px 22px", + backgroundColor: "var(--theme-brand)", + color: "var(--theme-text-on-brand)", + "&:hover": { backgroundColor: "var(--theme-brand-hover)" }, +}; +const primaryLinkLg: CSS = { ...primaryLink, fontSize: "$4", padding: "14px 28px" }; +const ghostLink: CSS = { + display: "inline-flex", + alignItems: "center", + gap: 8, + fontSize: "$3", + fontWeight: "$semibold", + textDecoration: "none", + whiteSpace: "nowrap", + borderRadius: "$3", + padding: "12px 22px", + backgroundColor: "transparent", + color: "$body", + border: "1px solid $bg05", + "&:hover": { backgroundColor: "$bg04" }, +}; +const textLink: CSS = { + display: "inline-flex", + alignItems: "center", + gap: 6, + fontSize: "$3", + fontWeight: "$semibold", + color: "var(--theme-brand)", + textDecoration: "none", + "&:hover": { color: "var(--theme-brand-hover)" }, + "& svg": { transition: "transform 0.15s" }, + "&:hover svg": { transform: "translateX(3px)" }, +}; +const scrollLink: CSS = { + display: "inline-flex", + alignItems: "center", + gap: 6, + fontSize: "$2", + fontWeight: "$semibold", + color: "$textMiddle", + textDecoration: "none", + "&:hover": { color: "$body" }, +}; +const stickyNav: CSS = { + position: "sticky", + top: 0, + zIndex: 50, + borderBottom: "1px solid $bg05", + backgroundColor: "$bg01", + backdropFilter: "blur(10px)", +}; +const navInner: CSS = { + ...wrap, + display: "flex", + alignItems: "center", + gap: 14, + paddingTop: 14, + paddingBottom: 14, +}; +const navLinks: CSS = { + display: "flex", + alignItems: "center", + gap: 22, + "@media (max-width: 720px)": { display: "none" }, +}; +const heroGrid: CSS = { + display: "grid", + gridTemplateColumns: "1.05fr 0.95fr", + gap: 56, + alignItems: "center", + padding: "76px 0 84px", + "@media (max-width: 900px)": { gridTemplateColumns: "1fr", gap: 40, padding: "52px 0 64px" }, +}; +const heroPizzaCol: CSS = { + position: "relative", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 16, + "@media (max-width: 900px)": { order: -1 }, +}; +const cards2: CSS = { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: 20, + marginTop: 44, + "@media (max-width: 760px)": { gridTemplateColumns: "1fr" }, +}; +const cardPad: CSS = { padding: 28 }; +const stagesGrid: CSS = { + display: "grid", + gridTemplateColumns: "repeat(3, 1fr)", + gap: 18, + marginTop: 40, + "@media (max-width: 760px)": { gridTemplateColumns: "1fr" }, +}; +const readGrid: CSS = { + display: "grid", + gridTemplateColumns: "360px 1fr", + gap: 48, + alignItems: "center", + marginTop: 40, + "@media (max-width: 900px)": { gridTemplateColumns: "1fr", gap: 32 }, +}; +const readPizzaCol: CSS = { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 14, + "@media (max-width: 900px)": { order: -1 }, +}; +const sliceGrid: CSS = { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: "16px 22px", + "@media (max-width: 760px)": { gridTemplateColumns: "1fr" }, +}; +const sidesGrid: CSS = { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: 22, + marginTop: 44, + "@media (max-width: 760px)": { gridTemplateColumns: "1fr" }, +}; +const valosGrid: CSS = { + display: "grid", + gridTemplateColumns: "1fr 320px", + gap: 48, + alignItems: "center", + marginTop: 8, + "@media (max-width: 900px)": { gridTemplateColumns: "1fr", gap: 28 }, +}; +const lineage: CSS = { + display: "flex", + alignItems: "center", + gap: 12, + marginTop: 36, + flexWrap: "wrap", +}; +const lineagePill: CSS = { + display: "inline-flex", + alignItems: "center", + gap: 9, + padding: "9px 15px", + borderRadius: "$pill", + border: "1px solid $bg05", + backgroundColor: "$bg02", + fontSize: "$2", + fontWeight: "$semibold", + color: "$textMiddle", +}; +const lineagePillActive: CSS = { + ...lineagePill, + color: "$body", + borderColor: "var(--theme-brand)", + backgroundColor: "$bg03", +}; +const failIcon: CSS = { + width: 46, + height: 46, + borderRadius: "$3", + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 18, +}; + +const s = { + wrap, + section, + sectionBand, + sectionHead, + h1, + h2, + h3, + lede, + prose, + pullQuote, + ctaRow, + primaryLink, + primaryLinkLg, + ghostLink, + textLink, + scrollLink, + stickyNav, + navInner, + navLinks, + heroGrid, + heroPizzaCol, + cards2, + cardPad, + stagesGrid, + readGrid, + readPizzaCol, + sliceGrid, + sidesGrid, + valosGrid, + lineage, + lineagePill, + lineagePillActive, + failIcon, +}; + +function Mark() { + return ( + + ); +} + +const HERO_SAMPLE: Answers = { + keyCustody: "green", + clientDiversity: "green", + infraDiversity: "yellow", + osDiversity: "green", + cpuDiversity: "red", + geoDiversity: "yellow", +}; + +const READ_SAMPLE: Answers = { + keyCustody: "green", + clientDiversity: "green", + infraDiversity: "yellow", + osDiversity: "green", + cpuDiversity: "green", + geoDiversity: "yellow", +}; + +const SLICE_DESC: Record = { + keyCustody: + "How signing keys are held, and how many independent parties must cooperate to sign.", + clientDiversity: "Exposure to a bug in any single consensus or execution client.", + infraDiversity: "Concentration on a single cloud or hosting provider.", + osDiversity: "Correlated risk from running a single operating system across the fleet.", + cpuDiversity: "Correlated risk from running a single chipset across the fleet.", + geoDiversity: "Concentration in one region and exposure to power failure or natural disaster.", +}; + +const READ_LABEL: Record = { + keyCustody: "Key Custody", + clientDiversity: "Client Diversity", + infraDiversity: "Provider Diversity", + osDiversity: "OS Diversity", + cpuDiversity: "CPU Architecture", + geoDiversity: "Geographic Diversity", +}; + +const ORDER: SliceId[] = [ + "keyCustody", + "clientDiversity", + "infraDiversity", + "geoDiversity", + "osDiversity", + "cpuDiversity", +]; + +const STAGES = [ + { + stage: "0" as const, + tone: "red" as const, + num: "0", + name: "Stage 0 — Exposed", + kind: "Starting point", + desc: "At least one single point of failure remains. Most operators start here — the baseline.", + }, + { + stage: "1" as const, + tone: "yellow" as const, + num: "1", + name: "Stage 1 — Slashing-averse", + kind: "Safety", + desc: "No single compromise of one machine, one team member, or one signer can produce a slashable message.", + }, + { + stage: "2" as const, + tone: "green" as const, + num: "2", + name: "Stage 2 — Downtime-averse", + kind: "Liveness", + desc: "No single point of failure in the operator's infrastructure can take the validator offline. This is the end game.", + }, +]; + +function LNav() { + return ( + + + + Validator Beat + + + + + How it works + + Methodology + + valOS + + + + + Assess your validator + + + + ); +} + +function LHero() { + return ( + + + + + + A simple view into validator operations + + + The validator ecosystem is missing its beat. + + + When you stake ETH, you always (sometimes unknowingly) pick an operator. You{" "} + cannot see how they hold their keys, which clients they run, or how + correlated their setup is with everyone else securing the network. Validator Beat makes + that visible. + + + + Assess your validator + + + See how it works + + + + + + + + An example operator profile + + + + + + + ); +} + +function LGap() { + return ( + + + + The gap + + Transparency has been built into every layer, except the validators. + + + + {["L2Beat", "Walletbeat"].map((name) => ( + + + + {name} + + + + + + ))} + + + Validator Beat + + + + Rollups have L2Beat. Wallets have Walletbeat. The validator operators securing tens of + millions of staked ETH have had no way to differentiate themselves outside of performance + and reputation. Stakers, from ETH holders to institutional allocators, choose an operator or + protocol and forget about it. Compliance attestations like SOC 2 cover process and custody, + but they say nothing about the choices that actually determine whether a validator stays safe + and online: how signing keys are split, whether the operator runs a single client + implementation, how concentrated their infrastructure is, and how much of that overlaps with + the rest of the network. + + + That invisible risk is unpriced. + {" "}Validator Beat is the public entry point that compares these risks and gives operators a + reason to compete on it. + + + + ); +} + +function LFail() { + return ( + + + + Why it matters + How validators actually fail + + There are two ways a validator fails, and decentralization is the insurance against both. + + + + + + + + + Safety failure · slashing + + It signs something it never should have. + + A double attestation, or a supermajority vote on the wrong chain. The protocol slashes + the stake. This is the expensive failure. + + + + + + + + Liveness failure · downtime + + It goes offline and misses its duties. + + No slashing, but no rewards either, and a weaker network. The validator simply isn't + there when it's needed. + + + + + Most safety and liveness issues trace back to a single point of failure: + one machine, one team member, one client, one provider, one region. Validator Beat measures + how many of those single points of failure an operator has removed. + + + + ); +} + +function LRead() { + return ( + + + + The assessment + How Validator Beat assesses an operator + + Most operators start exposed with two stages to climb and a six-slice risk profile. + + + + {STAGES.map(({ tone, num, name, kind, desc }) => ( + + + + {num} + + + {name} + {kind} + + + {desc} + + ))} + + + + + + Each slice scored{" "} + green,{" "} + yellow, or{" "} + red + + + + {ORDER.map((id) => ( + + + + {READ_LABEL[id]} + + + {SLICE_DESC[id]} + + + ))} + + + + This can be read at a glance. For the exact thresholds and rubric, see the{" "} + methodology. To see where your + own validator lands,{" "} + + take the assessment + + . + + + Validator Beat is self-reported and consent-based. + + + + ); +} + +function LSides() { + return ( + + + + Who it's for + Built for both sides of the stake + + + + If you delegate your ETH + + Read any operator's profile in five seconds. + + + Before you stake, not after an incident. Two stages and six colors give you insight into + validator operations that the marketing page never will. + + + + If you're an operatooor + Proof of work. + + Run the assessment, earn your stages, and show off your pizza wherever you list your + product. Operators who have removed their single points of failure now have a way to show + it. + + + + Assess your validator + + + + + + + ); +} + +function LValos() { + return ( + + + + + The standard behind the score + Validator Beat and valOS + + Validator Beat is the public-facing who is running validators. valOS, + the Validator Operating Standard, is the technical how: a deep catalog + of the controls and mitigations behind professional validator operations. + + + A staker can quickly read an operator's stages and profile here. An operator doing + the hard work and implementing the mitigations should dig into valOS. Follow valOS, and + you'll end up at stage 2. + + + + Learn more about valOS + + + + + + Validator Beat + + The who — the public dashboard + + + + + + + valOS + + The how — the operating standard + + + + + + + ); +} + +function LNeutral() { + return ( + + + + Credibility + A neutral dashboard, built in the open + + Validator Beat is co-authored by Obol and Lido. It is deliberately neutral and no single + team owns the rubric. The methodology is public and is meant to be adopted, challenged, + and improved by the whole ecosystem. + + + The goal is a race to the top, where transparency about validator risk + becomes the default expectation. + + + {[ + { name: "Obol", color: "var(--theme-brand)" }, + { name: "Lido", color: "#00a3ff" }, + ].map(({ name, color }) => ( + + + {name} + + ))} + + + + + ); +} + +function LClosing() { + return ( + + + + Find out how secure your validator really is. + + + + Assess your validator + + + + Read the methodology + · + + Explore valOS + + + + + ); +} + +function LFooter() { + return ( + + + + Validator Beat + + + A simple view into validator operations + + + Methodology + + valOS + + Assess + + + ); +} + +export function Landing() { + return ( + + + + + + + + + + + + + ); +} diff --git a/components/landing/icons.tsx b/components/landing/icons.tsx new file mode 100644 index 0000000..0cfd1c5 --- /dev/null +++ b/components/landing/icons.tsx @@ -0,0 +1,38 @@ +import { + ActivityLogIcon, + ArrowDownIcon, + ArrowRightIcon, + ExclamationTriangleIcon, + ExternalLinkIcon, +} from "@radix-ui/react-icons"; +import type { ComponentProps } from "react"; + +type IconSize = { size?: number }; + +const iconStyle = (size: number): ComponentProps<"svg">["style"] => ({ + width: size, + height: size, + flexShrink: 0, +}); + +export function IconArrowRight({ size = 16 }: IconSize) { + return ; +} + +export function IconArrowDown({ size = 16 }: IconSize) { + return ; +} + +export function IconExternalLink({ size = 16 }: IconSize) { + return ; +} + +/** Safety / slashing failure */ +export function IconSafety({ size = 24 }: IconSize) { + return ; +} + +/** Liveness / downtime */ +export function IconLiveness({ size = 24 }: IconSize) { + return ; +} diff --git a/components/pizza/Pizza.tsx b/components/pizza/Pizza.tsx index 14c963e..8ef712c 100644 --- a/components/pizza/Pizza.tsx +++ b/components/pizza/Pizza.tsx @@ -46,6 +46,8 @@ export type PizzaProps = { labelScale?: number; showCenter?: boolean; stage?: Stage | null; + /** Wedge glow strength. Softer (e.g. 0.28) reads better on light surfaces. */ + glowOpacity?: number; }; export function Pizza({ @@ -58,6 +60,7 @@ export function Pizza({ labelScale = 1, showCenter = true, stage = null, + glowOpacity = 0.5, }: PizzaProps) { const big = size >= 120; const pop = big ? size * 0.022 : 0; @@ -115,7 +118,7 @@ export function Pizza({ dy="0" stdDeviation={size * 0.013} floodColor={PIZZA_FILL[c]} - floodOpacity="0.5" + floodOpacity={glowOpacity} /> ))} diff --git a/lib/rubric/index.ts b/lib/rubric/index.ts index 806a924..83fa9b4 100644 --- a/lib/rubric/index.ts +++ b/lib/rubric/index.ts @@ -21,8 +21,8 @@ export const SLICES: SliceMeta[] = [ }, { id: "infraDiversity", - label: "Infrastructure", - short: "Infra", + label: "Provider", + short: "Provider", why: "One hosting provider's outage or compromise can take every validator hosted there with it.", }, { diff --git a/pages/assess.tsx b/pages/assess.tsx new file mode 100644 index 0000000..7315452 --- /dev/null +++ b/pages/assess.tsx @@ -0,0 +1,14 @@ +import { AssessmentApp } from "@components/assessment/AssessmentApp"; +import type { GetStaticProps } from "next"; + +export default function AssessPage() { + return ; +} + +export const getStaticProps: GetStaticProps = () => ({ + props: { + title: "Self-assessment", + description: + "Six questions about your Ethereum validator setup. Score your resilience in about a minute — nothing is submitted or stored.", + }, +}); diff --git a/pages/index.tsx b/pages/index.tsx index a151268..a6a4860 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,14 +1,14 @@ -import { AssessmentApp } from "@components/assessment/AssessmentApp"; +import { Landing } from "@components/landing/Landing"; import type { GetStaticProps } from "next"; export default function HomePage() { - return ; + return ; } export const getStaticProps: GetStaticProps = () => ({ props: { - title: "Self-assessment", + title: "The standard for validator resilience", description: - "Six questions about your Ethereum validator setup. Score your resilience in about a minute — nothing is submitted or stored.", + "When you stake, you pick an operator — but you can't see how they run. Validator Beat makes validator resilience visible: two stages, six slices, one pizza.", }, }); diff --git a/pages/methodology.tsx b/pages/methodology.tsx index 39de130..34950ec 100644 --- a/pages/methodology.tsx +++ b/pages/methodology.tsx @@ -170,7 +170,7 @@ export default function MethodologyPage() {

- + Take the assessment →