diff --git a/.gitignore b/.gitignore index 369359b..9f83732 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ # production /build /public/og/*.png +/public/badge/*.svg # misc .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 9610f2c..521c929 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this project is -Validator Beat is a **client-side, public-good self-assessment** for Ethereum validator operators. Six banded questions → six "pizza slices" (each green/yellow/red) → one Stage (0, 1, or 2). The whole thing runs in the browser; nothing is submitted, stored, or fetched at runtime. The static export ships to GitHub Pages. +Validator Beat is a **client-side, public-good self-assessment** for Ethereum validator operators. Six banded questions → six "pizza slices" (each green/yellow/red, or grey for "not sure") → one Stage (0, 1, or 2). The whole thing runs in the browser; nothing is submitted, stored, or fetched at runtime. The static export ships to GitHub Pages. Built by Obol, in partnership with Lido (the stewards of ValOS) — never call Lido a co-author. -Authoritative product plan: [`docs/PLAN.md`](./docs/PLAN.md). Read it before changing the rubric, stages, or slice set. +The rubric's source of truth is `lib/rubric/` plus the public methodology page (`pages/methodology.tsx`). Pre-launch, breaking rubric/share-code changes are fine — update every surface rather than documenting the old behaviour. ## Domain context: why these six slices @@ -16,14 +16,16 @@ The six slices are deliberately ordered worst-failure-first: 1. **Key Custody** — concentration of signing power (a Safety risk; one compromise can sign with the operator's stake) 2. **Client Diversity** — supermajority-client bug exposure (a Safety risk; refuse-to-attest turns it into mere downtime) -3. **Infrastructure** — hosting-provider concentration (a Liveness risk) -4. **OS Diversity** — distro monoculture (supply-chain risk; can be Safety *or* Liveness depending on the compromise) -5. **CPU Architecture** — ISA monoculture (hardware-level supply-chain / side-channel risk) -6. **Geographic** — jurisdictional concentration (Liveness + censorship risk) +3. **Provider** — hosting-provider concentration (a Liveness risk) +4. **OS Diversity** — distro monoculture (a Safety risk: one supply-chain compromise reaching a signing threshold) +5. **CPU Architecture** — ISA monoculture (a Safety risk: one hardware/side-channel flaw reaching a signing threshold) +6. **Geographic** — jurisdictional concentration (a Liveness risk, plus censorship) + +Each slice has a `kind`: **safety** (Keys, Clients, OS, CPU) or **liveness** (Provider, Geo). Stage rules (`computeStage`): Stage 0 = any safety slice red or not sure; Stage 1 = every safety slice green/yellow; Stage 2 = all six green. Colors carry one meaning: red = one failure could slash you, yellow = one failure could take you offline (or a partial safety gap). So liveness slices are `bands: [green, yellow]` and safety yes/no slices are `[green, red]`. "Not sure" (`unknown`, share letter `U`) is an open gap: it holds a safety slice at Stage 0 and a liveness slice short of Stage 2. The bottom four slices (Infra / OS / CPU / Geo) **assume the operator runs per-validator DVT or a multiplexer** — i.e. duties are split across independent nodes so diversity translates into uptime rather than just redundancy. This premise is stated in `pages/methodology.tsx` and in each question's `helper` text. -Banding thresholds (⅓ / ⅔) are chosen to map to the consensus-layer math: a ⅔ supermajority can finalize a wrong chain, so any single party holding ⅔ is a red; ⅓ is the safety threshold below which no single party can block or force anything. +Banding thresholds (⅓ / ⅔) map to the threshold-signing math: DV clusters typically need ⅔ of key shares to sign, so any single point holding **⅔ or more** is red, and **⅓ or less** is green (losing it still leaves a threshold online). OS and CPU are binary (`bands: [green, red]`): the question is whether one OS/ISA holds a signing threshold. Provider and Geo are binary (`bands: [green, yellow]`): over ⅓ on one provider/region can already drop you below the threshold, so there's one cut-off, and the worst case is downtime, not slashing. ## Architecture @@ -42,11 +44,11 @@ Banding thresholds (⅓ / ⅔) are chosen to map to the consensus-layer math: a └─────────────────────────────────────────────────────────────┘ ``` -- **`lib/rubric/`** is the single source of truth for scoring. It exports `SLICES` (the canonical ordered list — also defines pizza slice order and share-code character order), `computeStage`, `blockers`, `getTip`, `shareCode`, `decodeShareCode`. Pure, fully unit-tested. -- **`lib/assessment/questions.ts`** holds question copy + banded options. Each option's `color` is the rubric input — there is no separate scoring step in v0.1; the user's chosen band *is* the slice color. +- **`lib/rubric/`** is the single source of truth for scoring. It exports `SLICES` (the canonical ordered list with each slice's `kind` and `bands` — also defines pizza slice order and share-code character order), `computeStage`, `blockers`, `sliceTarget`, `getTip`, `shareCode`, `decodeShareCode`, `allShareCodes`. Pure, fully unit-tested; a test asserts `questions.ts` options match each slice's `bands`. +- **`lib/assessment/questions.ts`** holds question copy + banded options. Each option's `color` is the rubric input — there is no separate scoring step; the user's chosen band *is* the slice color. The "Not sure" option is appended to every question by `Question.tsx`. - **`hooks/useAssessment.ts`** is the state machine (intro → 6 questions → results) with a 420 ms auto-advance after each answer. -- **`pages/[code].tsx`** statically generates **all 729 share pages** (3⁶) at build time via `getStaticPaths` + `lib/theme/share-codes.ts`. Each share URL like `/GYRYGG` rehydrates the assessment to that result. -- **`scripts/generate-og-images.ts`** runs *before* `next build` (see `package.json` `build` script) and pre-renders **729 OG preview PNGs** into `public/og/` via `sharp`. Both the static pages and the OG images must stay in sync with `decodeShareCode`. +- **`pages/[code].tsx`** statically generates **one share page per valid code** (`allShareCodes()`, currently 1,296) at build time. Each share URL like `/GYYGGY` rehydrates the assessment to that result in "guest" mode (big owner name from `?n=`, "Take it yourself & compare" CTA). `?vs=CODE&vn=Name` adds a head-to-head (`components/assessment/Compare.tsx`); `lib/share/share-url.ts` owns all query parsing/building. +- **`scripts/generate-og-images.ts`** runs *before* `next build` (see `package.json` `build` script) and pre-renders an OG preview PNG into `public/og/` and a README badge SVG into `public/badge/` for every code. Both must stay in sync with `allShareCodes`. ### Hard constraints from the static-export setup @@ -65,19 +67,20 @@ Banding thresholds (⅓ / ⅔) are chosen to map to the consensus-layer math: a |---|---| | `yarn dev` | Next dev server on :3000 | | `yarn test` | Jest (jsdom) — primarily `lib/rubric/*.test.ts` | -| `yarn test -t "all green"` | Run a single test by name | +| `yarn test -t "not sure"` | Run a single test by name | | `yarn lint` | `next lint` (lints `pages`, `components`, `lib`, `constants`, `scripts`) | | `yarn tsc --noEmit` | Type-check only (also runs via husky pre-commit on staged files) | | `yarn build` | `build:og` → `next build` → static `out/` | -| `yarn build:og` | Regenerate the 729 OG PNGs only (slow; run when share-card visuals change) | +| `yarn build:og` | Regenerate the OG PNGs + badges only (~1 min; run when share-card visuals change) | +| `yarn build:banner` | Regenerate the committed README banner (`docs/images/banner.png`) | | `npx serve out` | Serve the static export locally to verify share pages, OG tags, and trailing slashes | Pre-commit (husky) runs `yarn test` + `yarn lint`. Lint-staged additionally runs `tsc --noEmit`, ESLint --fix, and Prettier on staged files. ## Conventions worth knowing -- **The rubric is the contract.** If you add, rename, or reorder a slice, you must update: `lib/rubric/types.ts` (`SliceId` union), `lib/rubric/index.ts` (`SLICES`, `TIPS`), `lib/assessment/questions.ts`, the `Pizza` component's slice order, and the share-code length/test (`/^[GYR_]{6}$/` regex in `decodeShareCode`). The share-code character order **is** `SLICES` order — breaking it invalidates every existing share link. -- **Share codes are user-visible URLs.** Six chars, `G`/`Y`/`R`, one per slice. Treat as a stable wire format. +- **The rubric is the contract.** If you add, rename, or reorder a slice, you must update: `lib/rubric/types.ts` (`SliceId` union), `lib/rubric/index.ts` (`SLICES`, `TIPS`), `lib/assessment/questions.ts`, the `Pizza` component's slice order, and the methodology/landing examples. The share-code character order **is** `SLICES` order — breaking it invalidates every existing share link. +- **Share codes are user-visible URLs.** Six chars, `G`/`Y`/`R`/`U`, one per slice; `decodeShareCode` rejects bands a slice doesn't offer. Treat as a stable wire format once launched. - Stage rules live in one place — `computeStage` in `lib/rubric/index.ts`. Don't reimplement them in components. - 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`). @@ -100,4 +103,10 @@ Pre-commit (husky) runs `yarn test` + `yarn lint`. Lint-staged additionally runs ## 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 `/`. +`data/operators/`, `content/methodology/`, `components/operators/`, and `lib/schemas/` are placeholder dirs for the **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. The `/operators` route currently redirects to `/`. + +Preferred design when we pick it up — **Git as the backend**, no stateful service: + +- Operators open a PR adding `data/operators/.yaml` with their share code, name, links, and optional evidence. The PR thread is the public audit trail; CODEOWNERS review is the moderation step. +- CI validates the YAML (Zod) and that the code passes `decodeShareCode`; the static build renders `/operators` and per-operator pages from the files. +- Later: per-slice minimums for stages, live client-share checks against clientdiversity.org, and automating `ethSecured`. diff --git a/README.md b/README.md index 6ec259c..b83dc25 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,85 @@ -# Validator Beat +
+ Validator Beat — what stage is your validator? +

Validator Beat

+

Six questions. Six slices. One Stage.
A neutral, public-good self-assessment of how resilient an Ethereum validator setup really is.

+

+ Take the assessment +

+

+ CI + License: Apache 2.0 + Methodology +

+
-Neutral, public-good **self-assessment** for Ethereum validator operators — six questions, six pizza slices, one Stage. Runs entirely in your browser; nothing is submitted or stored. +--- -**Planning:** [docs/PLAN.md](./docs/PLAN.md) +Staking earns a few percent a year. One bad day can take far more. Validators rarely fail because of something exotic — they fail at a **single point of failure** nobody wrote down: one person with the mnemonic, one supermajority client, one cloud account, one distro, one CPU architecture, one country's power grid. -## Development +Validator Beat makes those single points of failure legible. Answer six questions, get a pizza with six colored slices, and a Stage anyone can read in five seconds. -Requires **Node.js 24+** (see `.nvmrc`). +## The stages -```bash -yarn install -yarn dev -``` - -Open http://localhost:3000 - -| Command | Description | -|---------|-------------| -| `yarn dev` | Local dev server | -| `yarn build` | Static export to `out/` | -| `yarn test` | Rubric unit tests | -| `yarn lint` | ESLint | +| | Stage | Meaning | Rule | +|---|---|---|---| +| 🔴 | **Stage 0 · Getting started** | One failure could get you slashed. | A safety slice is red or *not sure* | +| 🟡 | **Stage 1 · Safety** | No single failure can get you slashed. | Every safety slice is green or yellow | +| 🟢 | **Stage 2 · Liveness** | No single failure can slash you *or* take you offline. | All six slices green | -Optional: set `NEXT_PUBLIC_SITE_URL` in `.env.local` for share links (defaults to `https://validatorbeat.com`). +## The six slices -## Deploy (GitHub Pages) +| Slice | Guards against | Kind | +|---|---|---| +| **Key Custody** | One party, machine, or custodian holding enough key material to sign | Safety | +| **Client Diversity** | Following a buggy supermajority execution or consensus client onto the wrong chain | Safety | +| **Provider Diversity** | One hosting provider's outage taking you offline | Liveness | +| **OS Diversity** | One distro's supply chain reaching enough key shares to sign | Safety | +| **CPU Architecture** | One ISA-level flaw reaching enough key shares to sign | Safety | +| **Geographic Diversity** | One country or region's outage taking you offline | Liveness | -Every push to `main` runs CI and, on success, deploys the static `out/` folder to GitHub Pages. +Colors mean one thing everywhere: 🔴 **red** — one failure could get you slashed; 🟡 **yellow** — one failure could take you offline (or a safety gap is only partly closed); 🟢 **green** — no single failure can do either. Bands follow the threshold-signing math: distributed validators typically need ⅔ of key shares to sign, so a single point holding ⅔ or more is red and ⅓ or less is green. Every question also offers ⚪ **Not sure**, treated as an open gap — one you can't verify is closed has to be assumed open. Liveness matters more than it used to: under [EIP-7716](https://eips.ethereum.org/EIPS/eip-7716), correlated downtime could cost far more than it does today (see the [downtime calculator](https://validatordowntime.obol.org)). -**One-time repo setup** +The full rationale, nuances, and known limits are in the [methodology](https://validatorbeat.com/methodology/). -1. **Settings → Pages → Build and deployment** — set **Source** to **GitHub Actions**. -2. Production builds use the GitHub Pages project path in [`.github/workflows/ci.yml`](.github/workflows/ci.yml): - - `NEXT_PUBLIC_BASE_PATH=/validator-beat` — required so `/_next/` JS/CSS load at `obolnetwork.github.io/validator-beat/` - - `NEXT_PUBLIC_SITE_URL=https://obolnetwork.github.io/validator-beat` — share links and OG URLs -3. **Custom domain** (`validatorbeat.com`): point DNS at GitHub Pages, then rebuild with `NEXT_PUBLIC_BASE_PATH=` (empty) and `NEXT_PUBLIC_SITE_URL=https://validatorbeat.com`. +## Share it -**Theming:** edit [`styles/theme-tokens.css`](styles/theme-tokens.css) to change colors (warm cream, ecosystem-neutral by default; an Obol-branded dark palette under `[data-theme="dark"]`). If you change pizza slice colors, also update [`lib/theme/tokens.ts`](lib/theme/tokens.ts). +Every result is a six-letter code, one letter per slice — `G`reen, `Y`ellow, `R`ed, or `U` for not sure — so a result is just a URL: -## Site chrome: shared header and footer +``` +https://validatorbeat.com/GGGRGG/?n=Ethereum%20Foundation's%20cluster +``` -The **landing** (`/`), **assessment** (`/assess/`), and **methodology** (`/methodology/`) share one header and footer — [`components/layout/SiteHeader.tsx`](components/layout/SiteHeader.tsx) and [`SiteFooter.tsx`](components/layout/SiteFooter.tsx) (the landing nav, generalized). +- **Link previews** — each code has a pre-rendered Open Graph card, so links unfurl with the pizza in X, Slack, Discord, and Telegram. +- **Head to head** — add `&vs=&vn=` to show two results side by side. Anyone opening a shared result can take the assessment themselves and compare. +- **Badge** — drop a live stage badge into your docs, README, or website: -- **`SiteHeader`** adapts to the route: it hides the "Assess your validator" CTA on `/assess` (you're already there) and points "How it works" at `/#how` from inner pages. The logo mark reads the `--vb-*` tokens, so it follows the theme. -- Both take a **`contentWidth`** prop so the inner content lines up with the page's main column (landing/methodology `1140`, assessment `1440`). -- The assessment renders them as the top/bottom rows of the `100vh` `Shell`; `MainGrid` is `flex:1` and absorbs the remaining height, so the focus layout still fits one screen. + ```markdown + [![Validator Beat: Stage 1](https://validatorbeat.com/badge/GYYGGY.svg)](https://validatorbeat.com/GYYGGY/) + ``` -## v0.1 routes +## Private by construction -| Route | Purpose | -|-------|---------| -| `/` | 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 | -| `/operators/` | Redirects to `/` until operator registry ships | +There is no backend. The site is a static export on GitHub Pages; scoring runs in your browser and nothing is submitted, stored, or tracked. Every one of the 1,296 possible results is a pre-built page. AI agents can run the same assessment from [`skill.md`](https://validatorbeat.com/skill.md) and [`llms.txt`](https://validatorbeat.com/llms.txt), both generated from the rubric so they can't drift. -## Share codes +## Improve the rubric -Six characters: `G` green, `Y` yellow, `R` red — one per slice in order (Keys → Geo). +The rubric is meant to be challenged. It lives in one place — [`lib/rubric/`](./lib/rubric) for stages, slices, and tips, and [`lib/assessment/questions.ts`](./lib/assessment/questions.ts) for question copy — and is fully unit-tested. Open an issue or PR with the scenario the current bands get wrong. -## Link previews (OG / Twitter cards) +For the operational controls behind each slice, see [ValOS, the Validator Operations Standard](https://lidofinance.github.io/valos/valos-spec.html). -Share pages expose Open Graph tags and a per-code image at `/og/{code}.png` (e.g. `/og/GYRYGG.png`). +## Develop -**Local** +Requires **Node.js 24+** (see [`.nvmrc`](./.nvmrc)). ```bash -yarn build -npx serve out -``` - -Then open a share URL (e.g. `http://localhost:3000/GYRYGG/`) and confirm meta tags: - -```bash -curl -s http://localhost:3000/GYRYGG/ | grep -E 'og:|twitter:' +yarn install +yarn dev # http://localhost:3000 +yarn test # rubric unit tests +yarn build # OG images + badges + agent files → static export in out/ ``` -Open the image directly: `http://localhost:3000/og/GYRYGG.png`. - -**After deploy** - -| Tool | URL | -|------|-----| -| Meta Tags | https://metatags.io/ (paste your share URL) | -| Facebook / LinkedIn debugger | https://developers.facebook.com/tools/debug/ | -| opengraph.xyz | https://www.opengraph.xyz/ | - -Paste a live share link such as `https://validatorbeat.com/GYRYGG`. Debuggers cache previews — use “Scrape again” if you redeployed OG images. - -**Quick checks** - -- Image loads: `https://validatorbeat.com/og/GYRYGG.png` -- `og:image` in page source matches that URL (must be absolute HTTPS) -- Slack / Discord / iMessage: paste the URL in a draft message to see the unfurl +Deployment, link-preview debugging, and theming notes are in [`docs/DEVELOPMENT.md`](./docs/DEVELOPMENT.md). -## License +## Credits -Licensed under the Apache License, Version 2.0. See [LICENSE](./LICENSE). +Built by [Obol](https://obol.org), in partnership with [Lido](https://lido.fi). Licensed under [Apache 2.0](./LICENSE). diff --git a/components/assessment/AssessmentApp.tsx b/components/assessment/AssessmentApp.tsx index d375536..f637f2c 100644 --- a/components/assessment/AssessmentApp.tsx +++ b/components/assessment/AssessmentApp.tsx @@ -1,13 +1,20 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/router"; import { Pizza } from "@components/pizza/Pizza"; import { useAssessment } from "@hooks/useAssessment"; import { vbConfetti } from "@lib/confetti"; -import { SLICES, allAnswered, computeStage, decodeShareCode, shareCode } from "@lib/rubric"; +import { SLICES, STAGE_META, allAnswered, computeStage, decodeShareCode, shareCode } from "@lib/rubric"; import type { SliceColor, SliceId } from "@lib/rubric/types"; import { VbButton } from "@components/ui/VbButton"; -import { getShareUrl, shareNameFromQuery } from "@constants/index"; +import { SITE_NAME } from "@constants/index"; +import { + type CompareTarget, + compareAssessPath, + getShareUrl, + parseShareQuery, +} from "@lib/share/share-url"; import { Box } from "@obolnetwork/obol-ui"; import { CONFETTI_BRAND } from "@lib/theme/tokens"; import { @@ -16,6 +23,7 @@ import { Legend, pizzaOrigin, } from "./Blockers"; +import { CompareCard } from "./Compare"; import { Intro } from "./Intro"; import { Question } from "./Question"; import { LevelUp, ResultHero, ShareModal } from "./Results"; @@ -38,8 +46,12 @@ type AssessmentAppProps = { export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { const a = useAssessment(); + const router = useRouter(); const [share, setShare] = useState(false); const [shareName, setShareName] = useState(""); + /** Opened from someone's share link and not yet edited — the result isn't the viewer's own. */ + const [guest, setGuest] = useState(Boolean(initialShareCode)); + const [compare, setCompare] = useState(null); const active = a.atIntro || a.atResults ? null : a.current.id; useEffect(() => { @@ -52,7 +64,9 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { } } - setShareName(shareNameFromQuery(window.location.search)); + const q = parseShareQuery(window.location.search); + if (initialShareCode) setShareName(q.name); + setCompare(q.compare); return () => { document.body.style.overflow = ""; @@ -61,6 +75,7 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { }, [initialShareCode]); const choose = (id: SliceId, color: SliceColor) => { + setGuest(false); if (color === "green") { const idx = SLICES.findIndex((s) => s.id === id); const o = pizzaOrigin(idx); @@ -101,8 +116,30 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { return () => cancelAnimationFrame(frame); }, [atResults, stage, answers, takeResultsConfetti]); - const shareUrl = - a.stage != null ? getShareUrl(shareCode(a.answers), shareName) : ""; + const code = a.stage != null ? shareCode(a.answers) : ""; + const shareUrl = code ? getShareUrl(code, shareName, compare) : ""; + + const compareAnswers = useMemo( + () => (compare ? decodeShareCode(compare.code) : null), + [compare], + ); + const compareStage = compareAnswers ? computeStage(compareAnswers) : null; + const theirLabel = compare?.name || "Their setup"; + const myLabel = guest ? shareName || "This setup" : "You"; + const compareLine = + compare && a.stage != null && compareStage != null + ? `${guest ? myLabel : shareName || "My validator"} is ${STAGE_META[a.stage].name}; ${theirLabel} is ${STAGE_META[compareStage].name} — compared on Validator Beat.` + : undefined; + + // Static pages can't know ?n=, so name the tab client-side once we can. + useEffect(() => { + if (!guest || !shareName || a.stage == null) return; + document.title = `${shareName} is ${STAGE_META[a.stage].name} | ${SITE_NAME}`; + }, [guest, shareName, a.stage]); + + const takeItYourself = () => { + router.push(compareAssessPath({ code, name: shareName })); + }; return ( @@ -127,7 +164,14 @@ export function AssessmentApp({ initialShareCode }: AssessmentAppProps) { {a.atIntro ? ( - + ) : !a.atResults ? ( ) : a.stage != null ? ( <> - + + {guest && ( + + + {compareAnswers ? "Take it yourself →" : "Take it yourself & compare →"} + + setShare(true)}> + Share this result + + + )} + {compareAnswers && ( + + )} - {a.stage === 2 ? "Your perfect score" : "Your progress"} + {a.stage === 2 ? "Perfect score" : guest ? "Progress" : "Your progress"} - - - setShare(true)}>Share my pizza → - - Start over - - + + {!guest && ( + + setShare(true)}> + {compareAnswers ? "Share the head-to-head →" : "Share my pizza →"} + + + Start over + + + )} ) : null} - + {/* On phones the pizza leads, except on a shared link, where the name and stage should. */} + setShare(false)} /> )} diff --git a/components/assessment/Blockers.tsx b/components/assessment/Blockers.tsx index 008e23f..0122fce 100644 --- a/components/assessment/Blockers.tsx +++ b/components/assessment/Blockers.tsx @@ -44,10 +44,13 @@ export function Legend() { Green · no single point of failure - Yellow · partially mitigated + Yellow · could take you offline, or partly mitigated - Red · single point of failure + Red · one failure could slash you + + + Not sure · treated as a gap ); diff --git a/components/assessment/Compare.tsx b/components/assessment/Compare.tsx new file mode 100644 index 0000000..67ed692 --- /dev/null +++ b/components/assessment/Compare.tsx @@ -0,0 +1,105 @@ +import { Pizza } from "@components/pizza/Pizza"; +import { SLICES, STAGE_META, computeStage } from "@lib/rubric"; +import type { Answers, SliceColor } from "@lib/rubric/types"; +import { Box, Text } from "@obolnetwork/obol-ui"; +import { RiskDot, SectionLabel, risk } from "./stitches"; + +const RANK: Record = { green: 2, yellow: 1, red: 0, unknown: 0 }; + +type Side = { label: string; answers: Answers }; + +type CompareCardProps = { left: Side; right: Side }; + +/** Head-to-head of two results: two pizzas, a verdict, and the slices where they differ. */ +export function CompareCard({ left, right }: CompareCardProps) { + const ls = computeStage(left.answers); + const rs = computeStage(right.answers); + if (ls == null || rs == null) return null; + + const diffs = SLICES.filter((s) => left.answers[s.id] !== right.answers[s.id]); + const ahead = (label: string, by: number) => + `${label} ${label === "You" ? "are" : "is"} ${by === 1 ? "a stage" : "two stages"} ahead`; + const verdict = + ls === rs + ? `Both ${STAGE_META[ls].name}` + : ls > rs + ? ahead(left.label, ls - rs) + : ahead(right.label, rs - ls); + + return ( + + Head to head + + + {[ + { side: left, stage: ls }, + { side: right, stage: rs }, + ].map(({ side, stage }) => ( + + + + {side.label} + + + {STAGE_META[stage].name} + + + ))} + + + {verdict} + {diffs.length === 0 ? " — identical setups." : ` · ${diffs.length} of 6 slices differ`} + + {diffs.length > 0 && ( + + + + + {left.label} + {right.label} + + + + {diffs.map((s) => { + const l = left.answers[s.id]!; + const r = right.answers[s.id]!; + const cell = (c: SliceColor, ahead: boolean) => ( + + + + {c === "unknown" ? "not sure" : c} + + + ); + return ( + + {s.label} + {cell(l, RANK[l] > RANK[r])} + {cell(r, RANK[r] > RANK[l])} + + ); + })} + + + )} + + + ); +} diff --git a/components/assessment/Intro.tsx b/components/assessment/Intro.tsx index e2ac0d6..e31c548 100644 --- a/components/assessment/Intro.tsx +++ b/components/assessment/Intro.tsx @@ -1,7 +1,12 @@ import { VbButton } from "@components/ui/VbButton"; +import { STAGE_META } from "@lib/rubric"; +import type { Stage } from "@lib/rubric/types"; import { StageLadder } from "./StageLadder"; import { Eyebrow, + HeroCard, + HeroEyebrow, + HeroLine, IntroGoal, IntroGoalText, IntroLede, @@ -13,11 +18,25 @@ import { type IntroProps = { onStart: () => void; + /** Taking the assessment against someone's shared result. */ + compare?: { name: string; stage: Stage }; }; -export function Intro({ onStart }: IntroProps) { +export function Intro({ onStart, compare }: IntroProps) { return ( + {compare && ( + + Head to head + + {compare.name} is{" "} + + {STAGE_META[compare.stage].name} + + . Answer for your own setup and see how you compare, slice by slice. + + + )} A neutral resource for validator fault tolerance How resilient is your validator setup? diff --git a/components/assessment/Question.tsx b/components/assessment/Question.tsx index 25dc661..2bbc9db 100644 --- a/components/assessment/Question.tsx +++ b/components/assessment/Question.tsx @@ -1,5 +1,6 @@ import { VbButton } from "@components/ui/VbButton"; import { QUESTIONS } from "@lib/assessment/questions"; +import type { QuestionOption } from "@lib/assessment/questions"; import { SLICES } from "@lib/rubric"; import type { SliceColor, SliceId } from "@lib/rubric/types"; import { @@ -25,6 +26,13 @@ import { RiskDot, } from "./stitches"; +/** Offered on every question: an unverified gap has to be assumed open. */ +const NOT_SURE: QuestionOption = { + color: "unknown", + label: "Not sure", + sub: "Treated as a gap until you can check — your results will say how to find out.", +}; + type QuestionProps = { sliceId: SliceId; value?: SliceColor; @@ -59,7 +67,7 @@ export function Question({ {data.q} {data.helper && {data.helper}} - {data.options.map((o) => { + {[...data.options, NOT_SURE].map((o) => { const sel = value === o.color; return ( onChoose(sliceId, o.color)} + css={o.color === "unknown" ? { marginTop: 4, borderStyle: "dashed" } : undefined} > diff --git a/components/assessment/Results.tsx b/components/assessment/Results.tsx index 51a3088..9e238ff 100644 --- a/components/assessment/Results.tsx +++ b/components/assessment/Results.tsx @@ -1,16 +1,23 @@ import React, { forwardRef, useEffect, useRef, useState } from "react"; import { Pizza } from "@components/pizza/Pizza"; import { VbButton } from "@components/ui/VbButton"; -import { SHARE_NAME_MAX } from "@constants/index"; +import { DOWNTIME_CALCULATOR_URL } from "@constants/index"; +import { badgeSvg } from "@lib/share/badge-svg"; +import { copyText } from "@lib/share/clipboard"; import { downloadElementAsPng } from "@lib/share/download-image"; -import { SLICES, STAGE_META, getTip, shareCode } from "@lib/rubric"; +import { SHARE_NAME_MAX, badgeUrl } from "@lib/share/share-url"; +import { SLICES, STAGE_META, blockers, getTip, shareCode, sliceTarget } from "@lib/rubric"; import type { Answers, SliceColor, SliceId, Stage } from "@lib/rubric/types"; import type { SliceMeta } from "@lib/rubric/types"; import { BrandAccent, HeroCard, + EmbedActions, + EmbedBox, + EmbedHead, HeroEyebrow, HeroLine, + HeroOwner, HeroProgress, HeroStageKind, HeroStageLine, @@ -39,6 +46,9 @@ import { ShareKind, ShareLine, ShareMeta, + ShareMore, + ShareMoreLink, + ShareOwner, ShareResk, ShareStage, ShareTop, @@ -71,44 +81,58 @@ const WHY_MAXED: Record = { keyCustody: "No single compromise can sign with your stake.", clientDiversity: "No supermajority-client fork can drag you in.", infraDiversity: "No single provider can take your validator offline.", - osDiversity: "No single OS compromise can reach all of your keys.", - cpuDiversity: "No single CPU-level flaw can reach all of your keys.", + osDiversity: "No single OS compromise can reach enough key shares to sign.", + cpuDiversity: "No single CPU-level flaw can reach enough key shares to sign.", geoDiversity: "No single region's outage can take your validator offline.", }; -type ResultHeroProps = { stage: Stage; answers: Answers; ownerName?: string }; +type ResultHeroProps = { + stage: Stage; + answers: Answers; + ownerName?: string; + /** Viewing someone else's shared link rather than your own fresh result. */ + guest?: boolean; +}; -export function ResultHero({ stage, answers, ownerName }: ResultHeroProps) { +export function ResultHero({ stage, answers, ownerName, guest = false }: ResultHeroProps) { const m = STAGE_META[stage]; const tone = risk[m.tone]; + const name = ownerName?.trim(); const greens = SLICES.filter((s) => answers[s.id] === "green").length; - const outOfRed = SLICES.filter((s) => answers[s.id] && answers[s.id] !== "red").length; + const toGo = blockers(answers).length; const progress = stage === 2 ? "All six slices maxed out" - : `${outOfRed} of 6 slices secured · ${greens} maxed`; + : `${greens} of 6 maxed · ${toGo} to go for Stage ${stage + 1}`; return ( - - {ownerName?.trim() ? `${ownerName.trim()}'s result` : "Your result"} - + {guest ? "Shared result" : name ? "Result for" : "Your result"} + {name && {name}} {m.name} {m.kind} {progress} - {STAGE_LINE[stage]} + {guest ? m.tagline + "." : STAGE_LINE[stage]} ); } -type LevelUpProps = { answers: Answers; stage: Stage }; +type LevelUpProps = { answers: Answers; stage: Stage; guest?: boolean }; + +const FLAG: Record<1 | 2, { fix: string; check: string }> = { + 1: { fix: "Next → Stage 1", check: "Find out → Stage 1" }, + 2: { fix: "Go for green → Stage 2", check: "Find out → Stage 2" }, +}; -export function LevelUp({ answers, stage }: LevelUpProps) { +export function LevelUp({ answers, stage, guest = false }: LevelUpProps) { const greens = SLICES.filter((s) => answers[s.id] === "green"); - const reds = SLICES.filter((s) => answers[s.id] === "red"); - const yellows = SLICES.filter((s) => answers[s.id] === "yellow"); + // Stage 1 blockers first, then everything standing between you and Stage 2. + const open = SLICES.map((s) => ({ slice: s, color: answers[s.id]!, target: sliceTarget(s, answers[s.id]) })) + .filter((x): x is { slice: SliceMeta; color: SliceColor; target: 1 | 2 } => x.target != null) + .sort((x, y) => x.target - y.target); + const livenessOpen = open.some((x) => x.slice.kind === "liveness"); const Win = ({ slice }: { slice: SliceMeta }) => ( @@ -123,14 +147,14 @@ export function LevelUp({ answers, stage }: LevelUpProps) { ); - const Next = ({ slice, color }: { slice: SliceMeta; color: SliceColor }) => ( - + const Next = ({ slice, color, target }: { slice: SliceMeta; color: SliceColor; target: 1 | 2 }) => ( + {slice.label} - - {color === "red" ? "Next → Stage 1" : "Go for green → Stage 2"} + + {color === "unknown" ? FLAG[target].check : FLAG[target].fix} {getTip(slice.id, color)} @@ -145,8 +169,10 @@ export function LevelUp({ answers, stage }: LevelUpProps) { ★ {stage === 2 - ? "You maxed out all six — perfect score" - : `You've maxed ${greens.length} of 6 — top tier`} + ? guest + ? "All six maxed — perfect score" + : "You maxed out all six — perfect score" + : `${guest ? "Maxed" : "You've maxed"} ${greens.length} of 6 — top tier`} {greens.map((s) => ( @@ -155,19 +181,29 @@ export function LevelUp({ answers, stage }: LevelUpProps) { )} - {reds.length + yellows.length > 0 && ( + {open.length > 0 && ( - {greens.length > 0 ? "Keep climbing — your next wins" : "Your next wins"} + {greens.length > 0 ? "Keep climbing — next wins" : "Next wins"} - {reds.map((s) => ( - - ))} - {yellows.map((s) => ( - + {open.map((x) => ( + ))} + {livenessOpen && ( + + Downtime may soon cost more than it does today:{" "} + + EIP-7716 + {" "} + would scale missed-attestation penalties with how many validators fail together — + roughly 78× today's rate when 10% of the network is down.{" "} + + Estimate your exposure ↗ + + + )} )} @@ -178,11 +214,13 @@ type ShareCardProps = { answers: Answers; stage: Stage; shareUrl: string; + ownerName?: string; }; export const ShareCard = forwardRef( - function ShareCard({ answers, stage, shareUrl }, ref) { + function ShareCard({ answers, stage, shareUrl, ownerName }, ref) { const m = STAGE_META[stage]; + const name = ownerName?.trim(); const tone = risk[m.tone]; return ( @@ -190,7 +228,7 @@ export const ShareCard = forwardRef( Validator Beat - {shareUrl.replace(/^https?:\/\//, "")} + {shareUrl.replace(/^https?:\/\//, "").replace(/\?.*$/, "")} ( labelScale={0.72} /> - My validator setup is + {name ? ( + <> + {name} + is + + ) : ( + My validator setup is + )} {m.name} {m.kind} {m.shareLine} @@ -220,22 +265,55 @@ type ShareModalProps = { shareUrl: string; ownerName: string; onOwnerNameChange: (name: string) => void; + /** Set when this result was taken against someone else's; the link then carries both. */ + compareLine?: string; onClose: () => void; }; +type CopyState = "idle" | "copied" | "failed"; + +function useCopy() { + const [state, setState] = useState<{ key: string; status: CopyState }>({ key: "", status: "idle" }); + const copy = async (key: string, text: string) => { + const ok = await copyText(text); + setState({ key, status: ok ? "copied" : "failed" }); + setTimeout(() => setState({ key: "", status: "idle" }), 1800); + }; + const label = (key: string, idle: string) => + state.key !== key ? idle : state.status === "copied" ? "Copied ✓" : state.status === "failed" ? "Copy failed" : idle; + return { copy, label }; +} + export function ShareModal({ answers, stage, shareUrl, ownerName, onOwnerNameChange, + compareLine, onClose, }: ShareModalProps) { - const [copied, setCopied] = useState(false); const [downloading, setDownloading] = useState(false); const [nameError, setNameError] = useState(false); + const [canNativeShare, setCanNativeShare] = useState(false); const cardRef = useRef(null); const code = shareCode(answers); + const { copy, label } = useCopy(); + + const who = ownerName.trim(); + const stageName = STAGE_META[stage].name; + const shareText = compareLine + ? `${compareLine} How resilient is yours?` + : who + ? `${who} is ${stageName} on Validator Beat. How resilient is yours?` + : `My validator setup is ${stageName} on Validator Beat. How resilient is yours?`; + const badgeAlt = `Validator Beat: ${stageName}`; + const badgeMarkdown = `[![${badgeAlt}](${badgeUrl(code)})](${shareUrl})`; + const badgeHtml = `${badgeAlt}`; + + useEffect(() => { + setCanNativeShare(typeof navigator !== "undefined" && typeof navigator.share === "function"); + }, []); const handleNameChange = (value: string) => { if (value.length > SHARE_NAME_MAX) { @@ -254,23 +332,12 @@ export function ShareModal({ return () => window.removeEventListener("keydown", onKey); }, [onClose]); - const copy = () => { - navigator.clipboard?.writeText(shareUrl).catch(() => {}); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; + const openIntent = (url: string) => window.open(url, "_blank", "noopener,noreferrer"); + const text = encodeURIComponent(shareText); + const url = encodeURIComponent(shareUrl); - const postToX = () => { - const who = ownerName.trim(); - const line = who - ? `${who}'s validator setup is ${STAGE_META[stage].name} on Validator Beat. How resilient is yours?` - : `My validator setup is ${STAGE_META[stage].name} on Validator Beat. How resilient is yours?`; - const text = encodeURIComponent(line); - window.open( - `https://x.com/intent/post?text=${text}&url=${encodeURIComponent(shareUrl)}`, - "_blank", - "noopener,noreferrer", - ); + const nativeShare = () => { + navigator.share({ title: "Validator Beat", text: shareText, url: shareUrl }).catch(() => {}); }; const downloadImage = async () => { @@ -291,23 +358,24 @@ export function ShareModal({ e.stopPropagation()} role="dialog" + aria-modal="true" aria-labelledby="share-title" > - Share your result + Share this result ✕ - Your name (optional, in URL only) + Name this setup (optional, shown on the page) ) => handleNameChange(e.target.value) } - placeholder="e.g. Kody's Cluster" + placeholder="e.g. Ethereum Foundation's cluster" maxLength={SHARE_NAME_MAX} aria-invalid={nameError} css={nameError ? { borderColor: risk.red } : undefined} @@ -321,21 +389,71 @@ export function ShareModal({ answers={answers} stage={stage} shareUrl={shareUrl} + ownerName={ownerName} /> - Post to X → + {canNativeShare && Share…} + openIntent(`https://x.com/intent/post?text=${text}&url=${url}`)} + > + Post to X → + - - {copied ? "Copied ✓" : "Copy link"} + copy("link", shareUrl)}> + {label("link", "Copy link")} {downloading ? "Preparing…" : "Download image"} + + Also on + openIntent(`https://farcaster.xyz/~/compose?text=${text}&embeds[]=${url}`)} + > + Farcaster + + openIntent(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`)} + > + LinkedIn + + openIntent(`https://t.me/share/url?url=${url}&text=${text}`)} + > + Telegram + + + + + Badge for your docs, README, or site + + + copy("md", badgeMarkdown)}> + {label("md", "Copy Markdown")} + + copy("html", badgeHtml)}> + {label("html", "Copy HTML")} + + + - The link encodes your six colors ({code}) — anyone who opens it sees - this exact result. Nothing is stored. + The link encodes your six answers ({code}) + {compareLine ? " plus the result you compared against" : ""} — anyone who opens it + sees this exact result. Nothing is stored. diff --git a/components/assessment/stitches.ts b/components/assessment/stitches.ts index 689330d..6784f1e 100644 --- a/components/assessment/stitches.ts +++ b/components/assessment/stitches.ts @@ -7,12 +7,15 @@ export const risk = { green: "var(--vb-green)", yellow: "var(--vb-yellow)", red: "var(--vb-red)", + unknown: "var(--vb-unknown)", greenT: "var(--vb-green-t)", yellowT: "var(--vb-yellow-t)", redT: "var(--vb-red-t)", + unknownT: "var(--vb-unknown-t)", greenB: "var(--vb-green-b)", yellowB: "var(--vb-yellow-b)", redB: "var(--vb-red-b)", + unknownB: "var(--vb-unknown-b)", empty: "var(--vb-empty)", emptyStroke: "var(--vb-empty-stroke)", } as const; @@ -246,6 +249,7 @@ export const RiskDot = styled(Box, { green: { backgroundColor: risk.green }, yellow: { backgroundColor: risk.yellow }, red: { backgroundColor: risk.red }, + unknown: { backgroundColor: risk.unknown }, empty: { backgroundColor: risk.empty, border: `1px solid ${risk.emptyStroke}`, @@ -514,9 +518,18 @@ export const OptionButton = styled("button", { green: {}, yellow: {}, red: {}, + unknown: {}, }, }, compoundVariants: [ + { + selected: true, + color: "unknown", + css: { + borderColor: risk.unknownB, + boxShadow: `0 0 0 1px ${risk.unknownB}`, + }, + }, { selected: true, color: "green", @@ -684,6 +697,16 @@ export const HeroEyebrow = styled(Text, { color: "$textMiddle", }); +export const HeroOwner = styled(Text, { + fontSize: 26, + fontWeight: "$bold", + letterSpacing: "-0.02em", + lineHeight: 1.15, + color: "$body", + marginTop: 6, + overflowWrap: "anywhere", +}); + export const HeroStageLine = styled(Box, { display: "flex", alignItems: "center", @@ -828,6 +851,7 @@ export const UpCard = styled(Box, { color: { red: { borderLeft: `3px solid ${risk.red}` }, yellow: { borderLeft: `3px solid ${risk.yellow}` }, + unknown: { borderLeft: `3px solid ${risk.unknown}` }, }, }, }); @@ -863,6 +887,7 @@ export const UpFlag = styled(Text, { color: { red: { color: risk.red }, yellow: { color: risk.yellow }, + unknown: { color: risk.unknown }, }, }, }); @@ -932,6 +957,16 @@ export const ShareResk = styled(Text, { color: "$textMiddle", }); +export const ShareOwner = styled(Text, { + fontSize: 22, + fontWeight: "$bold", + letterSpacing: "-0.01em", + lineHeight: 1.15, + color: "$body", + marginBottom: 4, + overflowWrap: "anywhere", +}); + export const ShareStage = styled(Text, { fontSize: 40, fontWeight: "$bold", @@ -1084,3 +1119,50 @@ export const ModalNote = styled(Text, { fontWeight: "$semibold", }, }); + +/* ---- Share modal extras -------------------------------------------------- */ + +export const ShareMore = styled(Box, { + display: "flex", + alignItems: "center", + flexWrap: "wrap", + gap: 14, + marginTop: 12, + fontSize: "$2", + color: "$textMiddle", +}); + +export const ShareMoreLink = styled(Box, { + all: "unset", + cursor: "pointer", + fontWeight: "$semibold", + color: "$body", + textDecoration: "underline", + textUnderlineOffset: 3, + "&:hover": { color: "var(--theme-brand)" }, + "&:focus-visible": { outline: "2px solid var(--theme-brand)", outlineOffset: 2 }, +}); + +export const EmbedBox = styled(Box, { + marginTop: 16, + padding: "12px 14px", + border: "1px dashed $bg05", + borderRadius: "$3", +}); + +export const EmbedHead = styled(Box, { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + flexWrap: "wrap", + "& svg": { display: "block" }, +}); + +export const EmbedActions = styled(Box, { + display: "flex", + gap: 10, + flexWrap: "wrap", + marginTop: 10, + "& button": { width: "auto" }, +}); diff --git a/components/landing/Landing.tsx b/components/landing/Landing.tsx index 9091772..05fd0ec 100644 --- a/components/landing/Landing.tsx +++ b/components/landing/Landing.tsx @@ -309,14 +309,14 @@ const SLICE_DESC: Record = { "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.", + osDiversity: "Whether one operating system's supply chain could reach enough key shares to sign.", + cpuDiversity: "Whether one CPU architecture's flaws could reach enough key shares to sign.", geoDiversity: "Concentration in one region and exposure to power failure or natural disaster.", }; /** Landing-voice detail per stage; naming comes from STAGE_META. */ const STAGE_DESC: Record = { - 0: "At least one single point of failure remains. Most operators start here — the baseline.", + 0: "One failure — a leaked key, a client bug, a poisoned OS update — could still get you slashed. Most operators start here.", 1: "No single compromise of one machine, one team member, or one signer can produce a slashable message.", 2: "No single point of failure in the operator's infrastructure can take the validator offline. This is the end game.", }; @@ -556,16 +556,17 @@ function LSides() { Read any operator's profile in five seconds. - Before you stake, not after an incident. A Stage and six colors give you insight into - validator operations that the marketing page never will. + Before you stake, not after an incident: ask for their Validator Beat link or badge. A + Stage 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 + Run the assessment, earn your stages, and show off your pizza — or embed the badge — + wherever you list your product. Operators who have removed their single points of failure now have a way to show it. @@ -587,21 +588,21 @@ function LValos() { The standard behind the score - Validator Beat and valOS + Validator Beat and ValOS - Validator Beat is the public-facing who: which operators run - validators, and how resilient their setups are. valOS, the Validator Operating - Standard, is the technical how: a deep catalog of the controls and - mitigations behind professional validator operations. + Validator Beat is the public-facing what: a quick, shareable read of + how resilient a validator setup is. ValOS, the Validator Operations 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 stage and profile here. An operator doing - the hard work and implementing the mitigations should dig into valOS. Follow valOS, and + A staker can read an operator's stage from a shared link or badge. 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 + Learn more about ValOS @@ -609,16 +610,16 @@ function LValos() { Validator Beat - The who — the public dashboard + The what — a stage anyone can read - valOS + ValOS - The how — the operating standard + The how — the operations standard @@ -634,10 +635,10 @@ function LNeutral() { Credibility - A neutral dashboard, built in the open + A neutral yardstick, 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, + Validator Beat is built by Obol, in partnership with Lido, the stewards of ValOS. 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. @@ -677,7 +678,7 @@ function LClosing() { Read the methodology · - Explore valOS + Explore ValOS diff --git a/components/layout/SiteFooter.tsx b/components/layout/SiteFooter.tsx index 0051143..ecce1b7 100644 --- a/components/layout/SiteFooter.tsx +++ b/components/layout/SiteFooter.tsx @@ -57,7 +57,7 @@ export function SiteFooter({ contentWidth = 1140 }: SiteFooterProps) { rel="noopener noreferrer" css={navLink} > - valOS + ValOS @@ -134,7 +136,7 @@ export function SiteHeader({ contentWidth = 1140 }: SiteHeaderProps) { rel="noopener noreferrer" css={navLink} > - valOS + ValOS diff --git a/components/pizza/Pizza.tsx b/components/pizza/Pizza.tsx index 3f3974b..6dba8d8 100644 --- a/components/pizza/Pizza.tsx +++ b/components/pizza/Pizza.tsx @@ -10,11 +10,13 @@ const PIZZA_FILL: Record = { green: "var(--vb-green)", yellow: "var(--vb-yellow)", red: "var(--vb-red)", + unknown: "var(--vb-unknown)", }; const PIZZA_RING: Record = { green: "var(--theme-risk-green-ring)", yellow: "var(--theme-risk-yellow-ring)", red: "var(--theme-risk-red-ring)", + unknown: "var(--theme-risk-unknown-ring)", }; const PIZZA_PLATE = "var(--vb-plate)"; const PIZZA_EMPTY = "var(--vb-empty)"; @@ -94,7 +96,7 @@ export function Pizza({ const frac = answered / SLICES.length; const sliceDesc = SLICES.map( - (s) => `${s.short} ${answers[s.id] ?? "unanswered"}`, + (s) => `${s.short} ${answers[s.id] === "unknown" ? "not sure" : (answers[s.id] ?? "unanswered")}`, ).join(", "); const ariaLabel = stage != null @@ -112,7 +114,7 @@ export function Pizza({ > {big && ( - {(["green", "yellow", "red"] as const).map((c) => ( + {(["green", "yellow", "red", "unknown"] as const).map((c) => ( onSlice!(s.id) : undefined} + role={clickable ? "button" : undefined} + tabIndex={clickable ? 0 : undefined} + aria-label={clickable ? `Go to ${s.label}` : undefined} + onKeyDown={ + clickable + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSlice!(s.id); + } + } + : undefined + } > )} + {col === "unknown" && ( + + )} {isActive && ( ; +type VbButtonProps = Omit, "variant" | "color"> & { + variant?: "primary" | "secondary"; +}; -/** obol-ui Button — Lido brand blue (#00A3FF), not obolGreen mint. */ -export function VbButton({ className, css, variant, ...props }: VbButtonProps) { - const isPrimary = variant !== "secondary"; +/** + * obol-ui Button in the Validator Beat palette. obol-ui picks its look via the + * `color` variant (its `variant` prop is for nav/tx/wallet buttons), and its + * hover state swaps in its own text color — so both variants pin text color + * on hover to keep contrast in light and dark themes. + */ +export function VbButton({ className, css, variant = "primary", ...props }: VbButtonProps) { + const look = + variant === "primary" + ? { + backgroundColor: "var(--theme-brand)", + color: "var(--theme-text-on-brand)", + "&:hover": { + backgroundColor: "var(--theme-brand-hover)", + color: "var(--theme-text-on-brand)", + }, + } + : { + backgroundColor: "$bg03", + color: "$body", + border: "2px solid $bg05", + "&:hover": { backgroundColor: "$bg04", color: "$body", border: "2px solid $bg05" }, + }; return (