Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# production
/build
/public/og/*.png
/public/badge/*.svg

# misc
.DS_Store
Expand Down
41 changes: 25 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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`).
Expand All @@ -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/<slug>.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`.
Loading
Loading