---
name: metagraphed
description: >-
  Use when writing, validating, or preparing ANY contribution or pull request to the
  JSONbored/metagraphed repo — adding/enriching a subnet's public surfaces (the most common
  contribution), a code/schema change to the Worker API or build scripts, picking an issue,
  running the local gates, and formatting the commit + PR. metagraphed reviews PRs ONE-SHOT via
  the Gittensory Gate (the GitHub App that auto-merges/auto-closes) plus a strict CI suite; there
  is no review back-and-forth, so a PR must be correct, in-scope, and green before it is pushed.
  Surfaces live in ONE file per subnet (registry/subnets/<slug>.json) — never per-surface
  candidate files, never split across multiple PRs. Also covers frontend PRs against apps/ui/
  (the web app, folded into this repo via monorepo consolidation) — external-contributor visual
  changes require a before/after screenshot table and are held for manual review; explicitly
  maintainer-directed work may use the documented override. Invoke for any "contribute to / open a
  PR against / enrich a subnet in / add a surface to / fix a bug in / add a frontend feature to
  metagraphed" task.
---

# Contributing to metagraphed — the one-shot PR playbook

metagraphed is the Bittensor subnet **integration registry** — every subnet, metagraphed. The repo
is a Cloudflare Worker API + Node build scripts; **JSON Schema is the canonical contract** (→ OpenAPI
→ typed clients), and everything under `public/metagraph/` is a _generated projection_ of reviewed
source, never hand-authored truth.

It merges through an **automated, one-shot review**: the **Gittensory Gate** (a GitHub App that posts
`Gittensory Gate` + `Gittensory Context` checks and a single verdict) plus a **strict CI suite**
(`Validate`). There is no human ping-pong and no "fix it in review" — **the PR must be right before
you push.** This skill is the end-to-end procedure to make that happen with AI tools (Claude Code /
Codex).

Work through the phases **in order** for your contribution type. If you cannot get the local gate
green, **do not push** — an incomplete PR is auto-closed or held, not coached.

`reference.md` (next to this file) has the exhaustive tables — every CI check, the surface schema,
the `kind` enum, the gate disposition, the validator list, the commit/PR rubric. Read it when a phase
says to.

**Zero-setup environment:** if you're operating in a devcontainer-aware tool, open the repo there —
`.devcontainer/devcontainer.json` pins Node 22 and preinstalls Playwright's Chromium (needed for
Phase C2's screenshot contract), so `npm install` is the only remaining step. Otherwise `.nvmrc` at
the repo root pins Node 22 for `nvm use`.

---

## Three kinds of contribution — pick your path

| You are…                                                                                                      | Path                                             | Files you touch                                                                 |
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------- |
| **Adding or enriching a subnet's public surfaces** (API, OpenAPI, docs, repo, dashboard, SDK, data artifact…) | **Path A — Surface contribution** (Phases A0–A5) | **exactly one** `registry/subnets/<slug>.json`                                  |
| **Changing code, schemas, or build scripts** (Worker API, `schemas/`, `scripts/`, workflows)                  | **Path B — Code/schema PR** (Phases B0–B5)       | `src/`, `workers/`, `schemas/`, `scripts/`, `.github/`, + regenerated artifacts |
| **Fixing a bug or shipping a feature in the web app** (block explorer, docs pages, dev tools)                 | **Path C — Frontend PR** (below)                 | `apps/ui/**` only                                                               |

Most contributions are **Path A**. Do **not** mix any of the three in one PR.

---

## What the gate does to your PR — it merges and closes, automatically

The Gittensory Gate is **not advisory**. Once your checks settle, for a **contributor** PR (you are
not the repo owner or an automation bot) it takes a one-shot disposition:

| Situation                                                                                                                                                         | Gate action                         |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| Content **verified** (owner-matched, fresh, grounded) + **both** AI reviewers confidently approve (≥0.9) + CI green + mergeable-clean                             | **auto-approve → MERGE**            |
| A **deterministic fail** — duplicate surface, placeholder, private/localhost URL, secret, dead `source_url`                                                       | **CLOSE** (one-shot)                |
| **Every** reviewer returns a clear reject                                                                                                                         | **CLOSE** (one-shot)                |
| Any CI check failed                                                                                                                                               | **CLOSE** (cites the failing check) |
| Legitimate but **uncertain** — a reviewer wanted merge but under 0.9, a reviewer said `manual`, reviewers split, owner-mismatch, stale repo, unfetchable evidence | **MANUAL** (held, not closed)       |
| CI still pending / unverified fork run                                                                                                                            | **no action** — waits               |

So a flawed contributor PR is **closed, not coached** — recovery means fixing the problem and opening
a **fresh** PR. **Verified + green ⇒ merged; a clear adverse signal ⇒ closed; genuine uncertainty ⇒
held for a human.** (Owner / automation-bot PRs are exempt from auto-close — but assume you are a
contributor.)

---

## The non-negotiables (read once, hold throughout)

1. **One subnet = one file = one PR.** A surface contribution edits **exactly one**
   `registry/subnets/<slug>.json` and **nothing else** (no generated artifacts, no scripts, no other
   subnet). You may add **several surfaces for that one subnet in the same diff** — that is one merge,
   the way it should be. **Never** split a subnet's surfaces across multiple PRs and **never** re-title
   the same surface as a different `kind` to make it look new: the gate dedups within the file and
   **closes redundant/near-duplicate PRs**. (This is exactly the farming the single-file model exists to
   stop.)
2. **Prove the claim.** Every surface needs a public `url` **and** a `source_urls` entry that
   _independently proves_ the subnet/operator actually publishes it (an official repo README, the
   provider's own site, on-chain identity). A `source_url` that 404s or doesn't back the claim → closed.
3. **Don't invent surfaces.** Only register what a subnet actually exposes. Schema-valid ≠ accepted.
4. **Health is probe-derived only.** Never hand-set health, uptime, latency, incidents, or
   `verification` — the build's prober owns those. You set identity (`url`, `kind`, `provider`,
   `source_urls`) and `review.state: community-submitted`; the gate and build do the rest.
5. **Public-safe only.** No secrets, PATs, wallet/hotkey/coldkey paths, private/localhost URLs, or
   validator-local data anywhere — in files, commits, or PR text. `auth` fields are _placeholders_
   (`Bearer <token>`), never real credentials.
6. **Link an open issue — required.** Every PR must reference an issue (`Closes #<n>` / `Refs #<n>`)
   in the PR body, and that issue must be **open/unclosed** at submission time — the gate verifies the
   PR against that issue's intent, clause by clause. No linked issue, or a linked issue that's already
   closed, is an automatic close on its own, before content is even scored. For surface work, the
   per-subnet enrichment issues under [epic #427](https://github.com/JSONbored/metagraphed/issues/427)
   are the natural home to link — pick one that's still open before you start.
7. **Schema is the contract — regenerate + commit (Path B).** Editing `schemas/` means
   `npm run build` then committing `openapi.json` + types/clients in the same PR, or
   `validate:contract-drift` fails CI.
8. **Conventional Commits, no AI attribution.** Lowercase scope, specific subject, no trailing period;
   **no AI/Claude/agent mention** anywhere in commits or PR text. Frontend/UI work lives in this repo
   at `apps/ui/` — see **Path C** below; it is not Path A or Path B.

---

## Path A — Surface contribution (the common case)

### Phase A0 — Bootstrap

```sh
# External contributor? Fork JSONbored/metagraphed, then clone YOUR fork:
git clone https://github.com/<you>/metagraphed && cd metagraphed
git remote add upstream https://github.com/JSONbored/metagraphed
nvm use            # Node 22 (engines: >=22.23.0)
npm install        # required before any validator runs
```

### Phase A1 — Pick the subnet + find a real surface

- **Search first.** Check open issues AND open PRs for the same subnet/surface — a duplicate is a
  close-worthy signal. Browse [`good first issue`](https://github.com/JSONbored/metagraphed/labels/good%20first%20issue)
  / [`help wanted`](https://github.com/JSONbored/metagraphed/labels/help%20wanted); the per-subnet
  enrichment issues (#427) each name the exact gap.
- **Find the gap.** `npm run curation:brief` lists profile-light subnets (directory-only, no website /
  source repo, public APIs with no OpenAPI yet). See `docs/curation-playbook.md`.
- **Confirm the surface is real and public.** A safe public `url` you can fetch, plus a `source_url`
  that proves the subnet publishes it. Pick the right `kind` (full enum in `reference.md`): contributor
  kinds are `docs, website, source-repo, openapi, subnet-api, dashboard, sse, data-artifact, sdk,
example, repo-registry` — all auto-reviewable; authed/paid APIs + unknown providers are higher-trust
  (airtight ownership proof). Base-layer chain endpoints (`subtensor-rpc/wss`, `archive`) are
  maintainer-curated infra (the endpoint lane), not contributor surfaces.
  **Prefer high-value callable kinds** (`openapi`, `subnet-api`, `sse`, `data-artifact`, `sdk`) —
  `source-repo` and `website` are auto-promoted from on-chain identity data, and `validate:surface`
  rejects them if the machine already has the URL (CI fails → gate closes). See `reference.md §5`.

### Phase A2 — Edit the ONE subnet file

A surface contribution adds entries to the `surfaces[]` array of `registry/subnets/<slug>.json`. Use
the helper so the id/shape are correct:

```sh
# Find the provider slug for the team behind the surface.
npm run providers:list

# Append a community surface to the subnet file (writes into registry/subnets/<slug>.json):
npm run surface:add -- \
  --netuid 43 --kind subnet-api \
  --url https://api.example.com/v1 \
  --source-url https://github.com/example/project/blob/main/README.md \
  --provider <provider-slug> --submitted-by <github-login> --write
  # Debut provider (slug not registered)? Add the team identity and surface:add scaffolds
  # registry/providers/<slug>.json (flat — trust is the authority field) in the SAME PR.
  # --provider-url is the provider's website_url and MUST be a public URL (validate
  # rejects private/localhost), as must any logo/docs/github/team/contact/social URL:
  #   --provider-name "Example Team" --provider-url https://example.com
```

Each added surface must carry `authority: "community"` and a `review` block — the helper sets these:

```jsonc
{
  "id": "sn-43-example-subnet-api",
  "name": "Example subnet API",
  "kind": "subnet-api",
  "url": "https://api.example.com/v1",
  "provider": "example",
  "authority": "community", // existing enum value — community-submitted, not official truth
  "auth_required": false,
  "public_safe": true,
  "source_urls": ["https://github.com/example/project/blob/main/README.md"],
  "review": {
    "state": "community-submitted",
    "submitted_by": "<github-login>",
  },
  "notes": "One line on what it is / why it's the right surface.",
}
```

You set **identity + proof + `review.state: community-submitted`** only. For an existing subnet
manifest, **do not** add `verification`, health, or `curation` changes, and **do not** touch other
surfaces or top-level fields in the file — a community PR that edits anything beyond appending its own
community surface(s) is out-of-shape and gets routed to full review or closed. A missing subnet
manifest is the exception: `subnet:new` creates the required top-level scaffold fields, then
`surface:add` appends the community surface in that same new file. `review.state` is the
human-governance axis: a maintainer flips it → `maintainer-reviewed` (or `rejected`) in place; machine
verification + freshness is the separate probe overlay (the build's prober fills
`verification`/health).

> New subnet not yet in `registry/subnets/`? Scaffold it with `npm run subnet:new -- --netuid <n>`
> first (one file), then add your surface to it in the same PR.

### Phase A3 — Validate locally

```sh
npm run validate:surface -- registry/subnets/<slug>.json   # schema + provider-slug + review-shape
npm run scan:public-safety                                  # no secrets / private URLs
```

Fix every finding. (CI runs the full `validate` suite; these two are the fast local pre-checks for the
submission lane.)

### Phase A4 — Commit + open the PR

- **One subnet file changed, nothing else.** `git diff --stat` should show a single
  `registry/subnets/<slug>.json`.
- **Commit (Conventional):** `feat(registry): add SN43 Example subnet-api surface (#<issue>)`.
- **PR body:** fill `.github/pull_request_template.md` honestly — a real Summary, the `url` +
  `source_url` proof, the validation commands you ran, and **`Closes #<issue>`** — required, and the
  issue must still be open. No AI attribution.

### Phase A5 — Let the gate adjudicate

Watch `Validate` and `Gittensory Gate` go green. Verified + green → merged. A deterministic fail
(dup / dead source / private URL) or a clear reject → closed; fix and open a fresh PR. Genuine
uncertainty → held for a human — don't open a duplicate.

---

## Path B — Code / schema PR

### Phase B0 — Bootstrap + scope

`npm install` (Node 22). Open an issue first for anything risky (public behavior, schema/contract
changes, new routes, workflows, deps). Keep the PR narrow — one coherent change. **Anchor on existing
code:** find ≥2 analogues in the repo, cite them `file:line`, trace the closest end-to-end, and match
its structure, naming, and comment density. Build for the class, not the one case.

### Phase B1 — Implement (match the house style)

- The Worker entry/router is `workers/api.ts`; serving/overlay/health logic lives in `src/*.ts`;
  the contract lives in **`schemas-src/` (Zod, the single source)** and `src/contracts.ts`.
  `schemas/*.json` is a different thing — the INPUT schemas that validate hand-written registry
  files. A published component declared anywhere but `schemas-src/` fails `validate:single-schema-source`.
- **All new code/script/test files must be `.ts`** — never `.mjs`/`.js`. The TypeScript migration
  (metagraphed#7510) is complete, and the `validate:no-hand-written-mjs` CI gate fails any PR that
  adds a `.mjs`/`.js` file under `src/`, `workers/`, `scripts/`, or `tests/`
  (metagraphed#7521).
- **Schema-first rule:** never hand-edit the generated contract. Edit the Zod schema under
  `schemas-src/` (and register it in `schemas-src/openapi-registry.ts` if it is new) →
  `npm run build` → commit `openapi.json` + generated types/clients in the same PR.
- A new `/api/v1` route or artifact trips hidden contract gates — see the new-route checklist in
  `reference.md` before adding one.

### Phase B2 — Test

Tests are vitest under `tests/`. Add coverage for new branches and fallback paths, and a **regression
test for every bug fix**. **Codecov is the coverage gate** — `codecov/patch` enforces **99% patch
coverage, branch-counted, with zero threshold slack** (`target: 99%, threshold: 0%` in `codecov.yml`),
scoped to `src/**` + `workers/**` runtime code. Run it unsharded locally: `npm run test:coverage`.
Reader tests serve R2-only artifacts that only exist after a build, so `npm run build` before the
suite if a test reads served artifacts. Running a single reader test file in isolation (e.g.
`npx vitest run tests/some-route.test.ts`) needs the same precondition — those artifacts live under
gitignored `dist/metagraph-r2/metagraph/`, populated as a side effect of `tests/artifacts.test.ts` /
`tests/discovery-artifacts.test.ts` when the full suite runs first (alphabetically), but absent on a
fresh checkout or a filtered single-file run. For just the fixture tree, without the rest of
`npm run build`'s slower steps (type/client/GraphQL generation) and without ever touching
`public/metagraph/`, run `npm run artifacts:prepare-local` first.

### Phase B3 — Regenerate what you invalidated (then commit it)

| You changed…                                 | Run             | Commit                                                                                            |
| -------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------- |
| `schemas-src/` (any published schema)        | `npm run build` | `openapi.json`, generated types, `contracts.json`, api-index                                      |
| A new/edited `/api/v1` route or artifact     | `npm run build` | the derived `public/metagraph/*` it produces                                                      |
| A canonical `registry/providers/<slug>.json` | `npm run build` | regenerated artifacts (commit only the provider file + its artifacts)                             |
| MCP tools in `src/mcp-server.ts`             | —               | **nothing** — the server card is worker-computed, not a committed artifact                        |
| _(any of the above)_                         | `npm run build` | **never** `public/metagraph/r2-manifest.json` / `public/metagraph/schemas/index.json` — see below |

Stale committed artifacts fail the **derived-artifact freshness** + **contract-drift** gates.

**Never commit `public/metagraph/r2-manifest.json` or `public/metagraph/schemas/index.json`.**
`npm run build` always rewrites both to reflect a full local/CI build, but neither committed copy is
yours to move: `r2-manifest.json` is the publish lockfile, read from its committed path at publish
time; `schemas/index.json` is a live network-capture cache whose current copy lives in the
`generated/schemas-index.json` R2 store, written daily by a Worker cron
(`src/schema-snapshots-sync.ts`) — the committed file is only the **fallback seed** a
credential-less build reads when it cannot reach that store. Both are excluded from the "Verify
committed derived artifacts are fresh" step in `.github/workflows/validate.yml` for this reason. A
contributor build will **always** show them as changed for reasons unrelated to your change.

`public/metagraph/operational-surfaces.json` is the same shape of thing one rung down: also a
fallback seed (for the health prober's cold start), also written live by an hourly Worker cron
(`src/operational-surfaces-sync.ts` → `generated/operational-surfaces.json`), also excluded from
that gate. It is not on the never-commit list because a normal build reproduces it deterministically
— but if it shows up dirty in a PR that did not intend to change the surface set, revert it the same
way.

Both `npm run build` and a standalone `node scripts/build-artifacts.ts` /
`npm run build:artifacts` already **auto-revert** whichever of these actually went dirty, back
against your base remote (`upstream/main` if you forked per Phase A0, `origin/main` otherwise) —
`git status` should already be clean after either. Treat that as a safety net, not a guarantee: it
silently degrades to a printed warning (not a failure) if the revert itself can't reach your base
remote (e.g. no network, or `upstream`/`origin` isn't fetched). So still check `git status` before
staging, and if either file shows modified, revert it by hand:

```sh
git checkout "$(git remote | grep -qx upstream && echo upstream || echo origin)/main" -- \
  public/metagraph/r2-manifest.json public/metagraph/schemas/index.json
```

before staging/committing.

**Client SDK version:** do **not** bump `packages/client/package.json` in your PR. The
`sync-client-version` workflow auto-opens a `chore/sync-client-version` PR after a contract-changing
merge. `validate:client-sdk-sync` now emits a notice (not a failure) when the version wasn't bumped
in the contributor PR.

### Phase B4 — Run the gates locally (must be green)

```sh
git diff --check
npm run lint && npm run format:check        # NOTE: main isn't fully prettier-clean — never reformat whole files you didn't change
npm run typecheck                           # tsc --noEmit, whole in-scope tree (src/workers/scripts/tests)
npm run validate                            # registry + API + OpenAPI checks
npm test                                    # or: npm run test:coverage for the coverage gate
# Then the focused validators for what you touched (full list in reference.md), e.g.:
npm run validate:contract-drift  npm run validate:schemas  npm run validate:api  npm run validate:openapi
```

For a faithful full local run, `npm run pipeline:check` — but only trust it in isolation **after** a
clean `npm run build` (see the build-gotchas note in `reference.md`).

### Phase B5 — Commit + PR

Conventional Commit (no AI attribution); `Closes #<issue>` — required, and the issue must still be
open; fill the PR template with the validation commands you actually ran. Sync with `main` if it moved
(`git fetch upstream && git rebase upstream/main`) — a base conflict closes a contributor PR.

---

## Path C — Frontend PR (`apps/ui/`)

`apps/ui/` is the TanStack Start + Vite + React web app at [metagraph.sh](https://metagraph.sh) —
folded into this repo as an npm workspace via the monorepo consolidation. It has its own `ui` CI job
(lint + typecheck + test + a responsive-overflow e2e check + build + bundle-budget, see
`reference.md §2`) and its own review contract,
distinct from Path A/B.

### Phase C0 — Bootstrap + pick an issue

```sh
npm install            # root install wires the apps/ui workspace too (Node 22)
```

Pick a `gittensor:bug` / `gittensor:feature` issue scoped to `apps/ui/` (Wave 3 milestone). Keep the
PR **narrow — aim for ≤10 files / ≤1000 LOC**; if an issue looks bigger than that once you're in the
code, ship the smallest coherent slice and leave a follow-up note rather than bundling everything into
one PR.

### Phase C1 — Implement (match the house style)

- Reuse existing shared components and the design tokens in `packages/ui-kit/src/styles.css` (the v2
  system — warm bone/paper background, deep ink text, mint accent used **sparingly**, flat surfaces
  with hairline borders, **no shadows or gradients**) instead of inventing new one-off styles.
- Anchor on an existing analogous page/component before writing a new one — this codebase already has
  shared primitives (table-controls, chart primitives, copy/share buttons, entity hover-cards,
  freshness badges) that most issues should compose rather than reimplement.
- Creative additions beyond an issue's stated scope are welcome but held to a **higher bar** — expect
  extra scrutiny, and call out explicitly in the PR body anything you added beyond the issue.

### Phase C2 — Screenshot contract (external-contributor visual changes)

**Non-negotiable for an external-contributor PR that changes rendered output.** Without an explicit
maintainer override, a PR without this evidence is auto-closed. A real PR (#3757) shipped 10 of its
12 screenshots at 115,000–142,000px tall (a full-page capture bug, not a display issue) and sat
unreviewable until recaptured. Don't repeat that.

> **Maintainer-directed override.** When a repository maintainer explicitly waives screenshots for
> the current task, skip this phase—including static and animated evidence—and do not request the
> waived evidence again. Never infer the waiver from branch ownership or repository access. Continue
> to run proportional responsive/interaction validation and every required code/CI gate.

**Recommended: automated capture (#3769).** `apps/ui/tests/e2e/capture-pr-screenshots.ts`
automates everything below — the two-worktree orchestration, fixed-viewport-only capture, explicit
theme toggling, the 12-image matrix, and (with `--push`) hosting + the ready-to-paste markdown
table:

```sh
npm run screenshots --workspace=apps/ui -- --route /subnets/1 --section volume-24h --prefix 5483-volume --push
```

Add `--section <id>` for a below-the-fold section anchor (omit to capture the page top), and
`--fallback-section <id>` when `before` doesn't have that anchor yet (the common case for a new
section — point it at the existing anchor the new one attaches after). Already have two dev
servers running (e.g. mid-session in an AI coding tool)? Skip the orchestration and point at them
directly: `--before-url http://localhost:8081 --after-url http://localhost:8080`. Run
`npm run screenshots --workspace=apps/ui -- --help` for the full flag list.

If the tool doesn't fit your case (a capture step needs manual intervention, or you're debugging
the tool itself), the equivalent manual steps are below — same contract, same output.

**1. Two dev servers — one for `before`, one for `after`.** Don't reuse a single server for both; run
the `before` state from a separate worktree so nothing needs stashing/restoring mid-capture:

```sh
git worktree add ../metagraphed-before $(git merge-base main HEAD)
cd ../metagraphed-before && npm install && npm run dev --workspace=apps/ui   # note the printed Local URL — this is "before"
cd -                                                                          # back to your feature branch
npm run dev --workspace=apps/ui                                              # note this Local URL — this is "after"
```

**2. Fixed viewport sizes only — never a full-page / `fullPage: true` capture.** A full-scroll-height
capture is exactly what produced #3757's broken screenshots. Use these three sizes — chosen to straddle
this app's actual Tailwind breakpoints (`md`=768px, `lg`=1024px, the two most-used responsive prefixes
in `apps/ui/src`):

| Viewport | Size (px)  |
| -------- | ---------- |
| Mobile   | 375 × 812  |
| Tablet   | 768 × 1024 |
| Desktop  | 1280 × 800 |

Capture exactly that viewport, nothing more. If the changed content is below the fold, scroll to it
first — don't reach for a full-page capture to get there.

**Also never `locator.screenshot()` / `elementHandle.screenshot()`** (e.g.
`page.locator(".hero").screenshot(...)`) as a shortcut to "just the changed part" — confirmed on
#6928/#6933, both shipped with a locator-scoped capture instead of the fixed viewport. It crops to
that element's own rendered box, not the fixed viewport frame this contract requires, so a
reviewer can't see the change in its real page context (surrounding layout, whether anything else
shifted). The only call to make is `page.screenshot({ path })` — no `fullPage`, no locator, after
resizing the viewport and scrolling to the changed content if needed.

**3. Force each theme explicitly — never rely on system/`prefers-color-scheme`** (it varies by capture
environment, so it isn't reproducible run to run). In the page, before capturing:

```js
localStorage.setItem("mg-theme", "dark"); // or "light"
location.reload();
```

`mg-theme` is `THEME_STORAGE_KEY` in `apps/ui/src/lib/theme.ts` — the only supported mechanism. Reload
after setting it so the pre-hydration bootstrap script applies it with no flash-of-wrong-theme.

**4. 3 viewports × 2 themes × {before, after} = 12 images**, for a page/feature-level change. Skip a
combo only if you state in one sentence why it's provably unaffected (e.g. a change gated behind a
desktop-only code path).

**5. Host the 12 files on a dedicated branch in your own fork — never drag-and-drop, never commit them
to your feature branch.** Drag-and-drop into the GitHub web editor requires a human browser session,
which an AI coding tool cannot do end-to-end; a pushed branch is fully scriptable and keeps binary
images out of your feature branch's diff entirely. Do this from a throwaway worktree, not your feature
branch's working directory:

```sh
git worktree add ../metagraphed-screenshots main
cd ../metagraphed-screenshots
git checkout --orphan screenshots       # first time; if you already have a `screenshots` branch from a
git rm -rf . 2>/dev/null                # prior PR, just `git checkout screenshots` instead and skip these two lines
cp /path/to/your/12/*.png .
git add *.png && git commit -m "screenshots for PR"
git push origin screenshots
cd -    # your feature branch's working directory was never touched
```

Reference each file as `https://raw.githubusercontent.com/<your-fork-owner>/metagraphed/screenshots/<file>.png`.

**6. Table format — one row per viewport+theme, thumbnail + caption in each cell, both before and
after:**

```md
| Viewport · Theme | Before                                                                | After                                                              |
| ---------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------ |
| Desktop · Light  | [<img src="BEFORE_URL" width="260">](BEFORE_URL)<br><sub>before</sub> | [<img src="AFTER_URL" width="260">](AFTER_URL)<br><sub>after</sub> |
| Desktop · Dark   | [<img src="BEFORE_URL" width="260">](BEFORE_URL)<br><sub>before</sub> | [<img src="AFTER_URL" width="260">](AFTER_URL)<br><sub>after</sub> |
| Tablet · Light   | [<img src="BEFORE_URL" width="260">](BEFORE_URL)<br><sub>before</sub> | [<img src="AFTER_URL" width="260">](AFTER_URL)<br><sub>after</sub> |
| Tablet · Dark    | [<img src="BEFORE_URL" width="260">](BEFORE_URL)<br><sub>before</sub> | [<img src="AFTER_URL" width="260">](AFTER_URL)<br><sub>after</sub> |
| Mobile · Light   | [<img src="BEFORE_URL" width="260">](BEFORE_URL)<br><sub>before</sub> | [<img src="AFTER_URL" width="260">](AFTER_URL)<br><sub>after</sub> |
| Mobile · Dark    | [<img src="BEFORE_URL" width="260">](BEFORE_URL)<br><sub>before</sub> | [<img src="AFTER_URL" width="260">](AFTER_URL)<br><sub>after</sub> |
```

Screenshots go **inside the table only** — never pasted loose in the PR body, never committed to the
feature branch itself.

A PR confined to `apps/ui/src/lib/**` / `apps/ui/src/hooks/**` / test files, with **no** visual change,
skips this entirely — it isn't rendering anything different.

> The devcontainer (`.devcontainer/devcontainer.json`) preinstalls Node 22 + Playwright's Chromium, so
> setup for the steps above (manual or via the automated tool) is zero-config there.

**Animated evidence (#4825) — for effects no static screenshot can show.** Required whenever the
changed behavior is only visible in motion: a hover-triggered popover, a scroll-linked effect, a CSS
transition/animation, a drag interaction, or anything else where "before" and "after" aren't just two
different static layouts. This is _additional_ to the static table above, not a replacement for it — a
real PR (#4814) shipped both: the static viewport × theme matrix for the at-rest layout, plus a
before/after GIF table for the hover behavior itself, because a still image genuinely cannot show what
happens on hover.

1. **Record the interaction, don't screenshot it.** Use your OS's screen recording (macOS `Cmd+Shift+5`
   or `screencapture -V`; Linux `wf-recorder`/`ffmpeg -f x11grab`) or a Playwright video/trace, scoped
   tightly to the interactive element — not the full viewport, and not a long clip. A few seconds
   showing the cursor entering, the effect triggering, and the resulting state is enough.
2. **Convert to a GIF** — a `.mov`/`.webm` file won't render inline in a GitHub-hosted `<img>` tag the
   way a `.gif` does:
   ```sh
   ffmpeg -i recording.mov -vf "fps=12,scale=480:-1:flags=lanczos" -loop 0 hover-before.gif
   ```
   Keep it small (a few seconds, ~12fps, ≤480px wide) — an oversized GIF is as unreviewable as #3757's
   full-page screenshot bug was.
3. **Same hosting mechanism as step 5 above — the dedicated `screenshots` branch on your own fork.**
   Push the `.gif` files alongside your PNGs in the same orphan-branch commit; reference them the same
   way: `https://raw.githubusercontent.com/<your-fork-owner>/metagraphed/screenshots/<file>.gif`.
4. **Table format — one row per interaction target** (not per viewport/theme; a hover/scroll/transition
   effect is rarely breakpoint- or theme-dependent, so don't multiply it out the way the static matrix
   does unless the interaction genuinely differs by breakpoint):
   ```md
   ### Hover interaction (animated)

   Static images can't show the pointer-driven [behavior] — here's the actual interaction.

   | Target                                                          | Before                                                   | After                                                  |
   | --------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------ |
   | <describe the interactive element, e.g. "Blocks · author cell"> | [<img src="BEFORE_GIF_URL" width="380">](BEFORE_GIF_URL) | [<img src="AFTER_GIF_URL" width="380">](AFTER_GIF_URL) |
   ```
   One row per distinct interactive element the PR changes.
5. A PURELY interaction-only change (identical at-rest state, only the triggered behavior changed) can
   skip the static matrix for that specific view — state why in one sentence, the same "provably
   unaffected" exception already allowed for a static viewport/theme combo.

### Phase C3 — Test + gates locally

The `ui` CI job runs lint, typecheck, test, **build**, a responsive-overflow e2e check, and a
bundle-size-budget check, in that order — run the same locally before pushing:

```sh
npm run lint --workspace=apps/ui && npm run format:check --workspace=apps/ui
npm run typecheck --workspace=apps/ui  # auto-builds packages/client first (pretypecheck) -- no separate step needed
npm test --workspace=apps/ui
npm run build:worker --workspace=apps/ui  # MUST precede test:e2e since #8928 — see below
npm run test:e2e --workspace=apps/ui   # needs a Chromium browser: npx playwright install --with-deps chromium (once)
```

**Build now comes before `test:e2e`** (#8928). The e2e check serves the PRODUCTION build via
`wrangler dev` rather than `npm run dev`: the sweep loads 26 routes x 4 viewports, and Vite's
dev server compiled each route on first hit, making that step ~48% of the whole `ui` job. It
also means the check now exercises the bundle that actually ships, so prod-only breakage the
dev server hid is in scope.

Use **`build:worker`**, not `build`. A plain `npm run build` emits `.output/` with no Worker
entry; the cloudflare-module preset (which CI and production use) emits **`dist/`**. `test:e2e`
serves `dist/server/wrangler.json` and fails without it.

This does NOT change Phase C2's screenshot workflow below, which still uses `npm run dev` on
purpose: those are contributor-facing before/after captures, not a CI gate.

The responsive-overflow e2e check replays recorded API traffic (`tests/e2e/har/*.har`)
instead of live production data, so it's deterministic regardless of live chain state. If
your PR adds a new API call on one of the checked routes (`/`, `/subnets/1`,
`/endpoints`, `/status`, `/settings`, `/explorer`), re-record:
`npm run test:e2e:record-har --workspace=apps/ui` against a running dev server.

CI also gzip-measures the initial client JS for a cold `/` visit against a budget (currently 400 KB,
`.github/workflows/validate.yml`'s "Bundle size budget" step) — keep new dependencies/imports lean; if
a real feature legitimately grows it, raise the budget deliberately in the same PR. If your PR also
touches `packages/client` or `packages/ui-kit`, CI rebuilds each fresh and diffs against its committed
`dist` (`packages/client/dist` / `packages/ui-kit/dist`) — run `npm run build --workspace=packages/client`
(or `--workspace=packages/ui-kit`) and commit the result if you changed `packages/client/src` (or
`packages/ui-kit/src`). `packages/ui-kit` also gets its own `npm run typecheck --workspace=packages/ui-kit`
step in the `ui` CI job.

### Phase C4 — Commit + PR

Conventional Commit (e.g. `feat(ui): add validator directory table`), no AI attribution, `Closes
#<issue>` — required, and the issue must still be open. External-contributor visual changes must fill
the screenshot table unless a repository maintainer explicitly waived it for the current task.

### Phase C5 — Review disposition

An **external-contributor** visual PR touching `apps/ui/` is held for manual review regardless of
AI-review confidence. For maintainer-directed work, an explicit authorization to merge after required
CI is green replaces that manual-review wait; merge when green without asking for the same approval
again. A non-visual `apps/ui/` PR (data/hooks/tests only) follows the normal auto-merge/auto-close gate
like Path A/B.

---

## Final pre-push checklist

**Path A (surface):**

- [ ] Exactly one `registry/subnets/<slug>.json` changed; existing manifests only append community
      surface(s), while missing manifests may include the required `subnet:new` scaffold plus the
      community surface(s); no other file.
- [ ] Each surface: real public `url` + a proving `source_url`; right `kind`; `authority: community`;
      `review.state: community-submitted`; `public_safe: true`; no health/`verification`/secrets set by hand.
- [ ] Not a duplicate of an existing surface or an open PR; not the same surface re-titled by `kind`.
- [ ] `npm run validate:surface` + `npm run scan:public-safety` clean.
- [ ] If you ran `npm run build` locally out of caution (not normally required for Path A), your diff
      still touches only your one subnet file — see the Path B note below on
      `public/metagraph/r2-manifest.json` / `public/metagraph/schemas/index.json`; the Gittensory Gate's
      registry-review lane rejects a PR that bundles either in with your surface change.
- [ ] Conventional Commit (no AI attribution); PR template filled; **`Closes #<issue>`** — required, referencing an issue that's still open.

**Path B (code/schema):**

- [ ] In scope, narrow, anchored on ≥2 analogues; general not special-cased.
- [ ] Regenerated + committed: `npm run build` artifacts (OpenAPI/types/contracts) as applicable. MCP
      tool additions do NOT require server-card regen (worker-computed). Client version bump NOT required
      (auto-sync workflow handles it post-merge).
- [ ] `public/metagraph/r2-manifest.json` and `public/metagraph/schemas/index.json` are **not** part of
      your diff — both always change on a local/CI build for reasons unrelated to your PR (they're
      deploy/publish-pipeline-owned, not contract artifacts). `npm run build` (and a standalone
      `scripts/build-artifacts.ts`) already auto-revert either one if it went dirty; double-check
      `git status` and fall back to the Phase B3 command above if either still shows modified (e.g.
      the auto-revert couldn't reach your base remote).
- [ ] `git diff --check` clean · `lint` + `format:check` + `typecheck` clean · `npm run validate` green ·
      `npm run test:coverage` green · the focused `validate:*` for what you touched green.
- [ ] Branch current with `main`; Conventional Commit (no AI attribution); PR template filled; `Closes #<issue>` — required, referencing an issue that's still open.

**Path C (frontend):**

- [ ] Scoped to `apps/ui/**` only; ≤10 files / ≤1000 LOC where reasonably possible.
- [ ] Reuses existing design tokens (`apps/ui/src/styles.css`) and shared components rather than
      one-off styling.
- [ ] If visual and not explicitly waived by a repository maintainer: a filled before/after screenshot
      table (mobile + dark-mode captures where relevant) — missing/malformed external-contributor
      evidence is an automatic close.
- [ ] If the change is only visible in motion (hover/scroll/transition/animation) and evidence was not
      explicitly waived: a before/after GIF table alongside the static one, per the "Animated
      evidence" step in Phase C2.
- [ ] `lint` + `format:check` + `typecheck` + `test` + `test:e2e` + `build` all green
      (`--workspace=apps/ui`); bundle size still under budget.
- [ ] If `packages/client/src` or `packages/ui-kit/src` changed: rebuilt and committed the respective
      `dist` (`packages/client/dist` / `packages/ui-kit/dist`).
- [ ] Conventional Commit (no AI attribution); `Closes #<issue>` — required, referencing an issue that's still open.

If every box is checked, the PR has the best chance of a one-shot approve-and-merge. If any box can't
be checked, **keep working — don't push.**

---

When you need the exhaustive detail behind any phase, read **`reference.md`** in this skill directory.
