diff --git a/.cursor/rules/git.mdc b/.cursor/rules/git.mdc index 441baf4..69f0b2e 100644 --- a/.cursor/rules/git.mdc +++ b/.cursor/rules/git.mdc @@ -21,6 +21,7 @@ This Cursor window’s default cwd is often `ethereumjs-monorepo`. Nested FYP ru - Do **not** commit, push, or open a PR unless the human asks. - Working-tree **inspection** (branch, dirty files) is fine. Do not stash, checkout, create, rename, or push branches unless the human asks (including round-trip phase 2 — branches are human-led or explicit instruction). - **Exception:** [round-trip-close](../skills/round-trip-close/SKILL.md) at merge GO (merge PRs, delete remote `eip-NNNN`, checkout / pull default, safe-delete local). Do not run close outside phase 6. +- **Exception:** [Unicorn merge 🦄](../skills/unicorn-merge/SKILL.md) **only** when the human explicitly says that phrase — coordinated commit/push/PR/CI-wait/merge/cleanup/new branches for session FYP work. Not a synonym for round-trip or generic “merge my PRs”. - A **push** ask includes the [pre-push CI](#before-a-push) loop: fix failures and commit those fixes without a second ask, then push. ## Scope diff --git a/.cursor/rules/repo-structure.mdc b/.cursor/rules/repo-structure.mdc index fa3eced..00e9ab3 100644 --- a/.cursor/rules/repo-structure.mdc +++ b/.cursor/rules/repo-structure.mdc @@ -44,7 +44,7 @@ Fleet overview for humans: [docs.feelyourprotocol.org](https://docs.feelyourprot | **Rules — protocol data** (always-on) | `eip-canonical-data.mdc` | Twin + lab data homes, when to update, how to retrieve (website + MCP). Engine `website-relation.mdc` points here — do not fork the map | | **Rules — task invariants** (glob-scoped) | `explorations-website.mdc` on `src/explorations/**`, `src/eComponents/**`; `design.mdc` on shared Vue/chrome; `exploration-design.mdc` on exploration Vue, UICs, and cover/layout files; `mcp-docs-design.mdc` on `mcp-docs/**` | Failure modes for one domain — not a fourth primitive | | **Rules — specialized** | `video-recording.mdc`, `og-images.mdc` | Long capture/setup playbooks; do not fold into primitives | -| **Skills** | `.cursor/skills/round-trip-protocol-change/`, `add-exploration`, `update-ethereumjs`, `cover-image`, `bro-bruh-comic`, `x-engagement` | Ordered workflows; point at website-docs + source, do not duplicate catalogs | +| **Skills** | `.cursor/skills/round-trip-protocol-change/`, `unicorn-merge`, `add-exploration`, `update-ethereumjs`, `cover-image`, `bro-bruh-comic`, `x-engagement` | Ordered workflows; point at website-docs + source, do not duplicate catalogs | New work: invariants → rule or glob rule; executable procedure → skill; concepts and API tables → website-docs only. diff --git a/.cursor/skills/round-trip-close/SKILL.md b/.cursor/skills/round-trip-close/SKILL.md index 37aa288..5f82f9f 100644 --- a/.cursor/skills/round-trip-close/SKILL.md +++ b/.cursor/skills/round-trip-close/SKILL.md @@ -9,7 +9,7 @@ description: >- # Round-trip close -**Only** after [round-trip](../round-trip-protocol-change/SKILL.md) comic and video (or skip of those asks). Not a general git habit. Standalone widget / engine / comic / video work must not run this. +**Only** after [round-trip](../round-trip-protocol-change/SKILL.md) comic and video (or skip of those asks). Not a general git habit. Standalone widget / engine / comic / video work must not run this. For non–round-trip multi-repo ship/merge, the human uses **[Unicorn merge 🦄](../unicorn-merge/SKILL.md)** (explicit trigger only). Target: the confirmed id (`eip-NNNN`). The three siblings: `website`, `mcp-execution-engine`, `mcp-gateway`. Git cwd rules: [git.mdc](../../rules/git.mdc). diff --git a/.cursor/skills/unicorn-merge/SKILL.md b/.cursor/skills/unicorn-merge/SKILL.md new file mode 100644 index 0000000..7a24826 --- /dev/null +++ b/.cursor/skills/unicorn-merge/SKILL.md @@ -0,0 +1,107 @@ +--- +name: unicorn-merge +description: >- + Unicorn merge 🦄 only — when the human explicitly asks for that phrase (with + or without the emoji). Ship session FYP branches to merged PRs, clean default + branches, and fresh working branches. Not exploration round-trip close. +--- + +# Unicorn merge 🦄 + +**Trigger:** the human says **Unicorn merge** or **Unicorn merge 🦄** (or clearly names this skill). Do **not** run from vague “merge my PRs”, “ship it”, or “round-trip close” — those are different workflows ([round-trip-close](../round-trip-close/SKILL.md) is EIP phase 6 only). + +**Goal:** move from open feature branches on the FYP repos involved in **this session** to merged PRs, updated `main`, deleted feature branches, and new local working branches. + +**Repos:** `website`, `mcp-execution-engine`, `mcp-gateway` — only siblings the human named or that hold unpushed/ahead work on the **same coordinated effort** this session. Skip clean unrelated checkouts. `server-config` only when this session touched it. + +**Git mechanics:** [git.mdc](../../rules/git.mdc) — `working_directory`, `required_permissions: ["all"]`, **one signed op at a time**, commit messages, [pre-push CI](#before-a-push) (in git.mdc), PR title/body rules. Multi-repo push/PR order when all three move: **engine → gateway → website**. + +Human may stop after any step; report status and what is left. + +--- + +## 1 — Commit if needed + +Per in-scope repo: `git status`. If dirty or untracked work belongs to this effort, commit (human already asked via Unicorn merge). One-line subject, no body. If nothing to commit, note clean. + +Do **not** start Unicorn merge without the explicit trigger, even if trees are dirty. + +--- + +## 2 — Push + +Per in-scope repo, in turn (engine → gateway → website when all three): + +1. Run that repo’s pre-push CI from git.mdc; fix red, commit fixes, re-run. +2. `git push` (set upstream if needed) → wait success. + +--- + +## 3 — Open PRs + +Per repo, after that repo’s push, one at a time: `gh pr view `. + +- **PR exists:** refresh title/body if branch essence changed (git.mdc § Pull requests). +- **No PR:** `gh pr create` with Related links to sibling PRs when coordinated. + +Report each PR URL. + +--- + +## 4 — Wait for green CI + +Per repo with a PR: `gh pr checks` (and `gh pr view` for state). + +| Result | Action | +| --- | --- | +| Pending / queued / in progress | **Stop here.** Report “waiting on CI” with PR links. Human re-invokes Unicorn merge 🦄 when checks finish. | +| Failed / cancelled | Report job + short cause. Ask fix vs retry. Do not merge. | +| Success / skipped / neutral (all required) | Repo is green. | + +Proceed to §5 only when **every** in-scope PR is green. Trees should match remote (not ahead/behind); if not, say so before merge. + +--- + +## 5 — Merge PRs and delete remote feature branches + +Only when §4 is fully green for all in-scope PRs. + +Per repo, in turn: `gh pr merge --delete-branch` (add `--merge` if `gh` requires a merge method). On failure, **stop** — do not merge the rest until resolved. + +--- + +## 6 — Update local default and delete local feature branches + +After **all** merges in §5 succeeded, per repo in turn: + +1. `git fetch origin` +2. Checkout default (`main` / `origin/HEAD`) +3. `git pull --ff-only origin ` +4. `git branch -d ` (safe delete only — not `-D` unless human asks) + +Report default @ short SHA; local feature branch gone. + +--- + +## 7 — New working branches + +Per in-scope repo, in turn (after §6 for that repo): + +`git checkout -b ` where `` is the branch name the human gave, or **`new-work`** if they did not name one. + +If the human wanted **one shared name** across repos, use the same name on each. If names differ per repo, they will say so. + +**Stop** with a short summary: merged PR URLs, default SHAs, new branch names, optional deploy reminder (gateway/hosted MCP, website, metrics) when this session changed runtime surfaces. + +--- + +## Distinction from round-trip close + +| | Unicorn merge 🦄 | Round-trip close | +| --- | --- | --- | +| Trigger | Explicit “Unicorn merge” | Round-trip phase 6 GO | +| Branch names | Any session feature branch | `eip-NNNN` on three siblings | +| Extra | — | YouTube, announcement tweets | +| Scope | Session-coordinated FYP repos | Always website + engine + gateway for that EIP | + +Reuse merge/checkout/delete **patterns** from round-trip-close §3; do not run round-trip-close marketing/YouTube unless the human also asked for phase 6. diff --git a/AGENTS.md b/AGENTS.md index 0393f4a..e7e9076 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Tool-agnostic entrypoint for coding agents (Cursor, Claude Code, Codex, etc.) in | Task | Read first | | --- | --- | | **Full round-trip** (EIP → exploration → MCP → optional comic/video → close) | [`.cursor/skills/round-trip-protocol-change/SKILL.md`](.cursor/skills/round-trip-protocol-change/SKILL.md) | +| **Unicorn merge 🦄** (explicit phrase only — session branches → merged PRs → fresh `new-work`) | [`.cursor/skills/unicorn-merge/SKILL.md`](.cursor/skills/unicorn-merge/SKILL.md) — not round-trip close | | **Brief a protocol change** (round-trip phase 1) | [`.cursor/skills/brief-protocol-change/SKILL.md`](.cursor/skills/brief-protocol-change/SKILL.md) — includes working-tree prep (observe git only) | | **Create or change an exploration** (round-trip phase 2) | [`.cursor/skills/add-exploration/SKILL.md`](.cursor/skills/add-exploration/SKILL.md) → [adding-an-exploration.md](website-docs/contributing/adding-an-exploration.md) | | **EthereumJS / EST bump** (re-pin EIP specs) | [`.cursor/skills/update-ethereumjs/SKILL.md`](.cursor/skills/update-ethereumjs/SKILL.md) → [eip-canonical-data.mdc](.cursor/rules/eip-canonical-data.mdc) § Spec versioning | diff --git a/mcp-docs/use/forks/fusaka.md b/mcp-docs/use/forks/fusaka.md index c2ebc9b..00778f8 100644 --- a/mcp-docs/use/forks/fusaka.md +++ b/mcp-docs/use/forks/fusaka.md @@ -1,4 +1,4 @@ -# Current mainnet EL — Fusaka +# 🦓 Current mainnet EL — Fusaka > **Status:** **Live on the public MCP.** **`baselineForkId`** in the probe — today’s mainnet execution-layer rules. diff --git a/mcp-docs/use/forks/glamsterdam.md b/mcp-docs/use/forks/glamsterdam.md index 62437d2..18de6a9 100644 --- a/mcp-docs/use/forks/glamsterdam.md +++ b/mcp-docs/use/forks/glamsterdam.md @@ -1,4 +1,4 @@ -# Amsterdam now +# 🐻‍❄️ Amsterdam now (Glamsterdam) ::: info Naming **Glamsterdam** is the catalog id; **`amsterdam`** is the Ethereum execution-layer alias. Both mean the same fork here. Default when you omit `fork`. diff --git a/mcp-docs/use/forks/historical-forks.md b/mcp-docs/use/forks/historical-forks.md index 69ba2f3..aee7a02 100644 --- a/mcp-docs/use/forks/historical-forks.md +++ b/mcp-docs/use/forks/historical-forks.md @@ -34,16 +34,29 @@ Examples (compare prompts): - **PUSH0** (3855) → **`shapella`** → compare **`paris`** then **`shapella`** (`0x5f00` on bytecode). - **ModExp repricing** (7883) → **`fusaka`** → compare **`pectra`** then **`fusaka`** ([EIP-7883](/use/eips/eip-7883)). +## Upgrade mascots + +From **Paris (The Merge)** on, Ethereum upgrades often have a community mascot ([EIP-8066](https://eips.ethereum.org/EIPS/eip-8066)). The probe exposes them on `namedForks[].mascotEmoji` and on run results as `provenance.forkLabel` / `forkMascotEmoji`. Berlin and London predate that process. + +| Fork | Mascot | +| --- | --- | +| Paris (Merge) | 🐼 | +| Shapella | 🦉 | +| Dencun | 🐡 | +| Pectra | 🦒 | +| Fusaka | 🦓 | +| Glamsterdam | 🐻‍❄️ | + ## Lineage table (historical rows) -| Order | `baseHardfork` | Aliases | Notable activations (full lists in probe) | -| --- | --- | --- | --- | -| 0 | `berlin` | — | 2565 ModExp, 2718 typed txs, 2929 cold/warm gas, 2930 access lists | -| 1 | `london` | — | **1559** fee market, 3198 BASEFEE, 3529 refunds, 3541 initcode prefix | -| 2 | `paris` | `merge`, `the-merge` | 3675 Merge (not replayable here), 4399 PREVRANDAO | -| 3 | `shapella` | `shanghai` | 3855 PUSH0, 3860 initcode, 4895 withdrawals (limited in lab) | -| 4 | `dencun` | `cancun` | 1153 transient storage, 5656 MCOPY, 4844 blobs (no blob txs in lab) | -| 5 | `pectra` | `prague` | 7702 set-code (`authorizationList` on run_transaction), 2537 BLS precompiles, 7623 calldata | +| Order | `baseHardfork` | Mascot | Aliases | Notable activations (full lists in probe) | +| --- | --- | --- | --- | --- | +| 0 | `berlin` | — | — | 2565 ModExp, 2718 typed txs, 2929 cold/warm gas, 2930 access lists | +| 1 | `london` | — | — | **1559** fee market, 3198 BASEFEE, 3529 refunds, 3541 initcode prefix | +| 2 | `paris` | 🐼 | `merge`, `the-merge` | 3675 Merge (not replayable here), 4399 PREVRANDAO | +| 3 | `shapella` | 🦉 | `shanghai` | 3855 PUSH0, 3860 initcode, 4895 withdrawals (limited in lab) | +| 4 | `dencun` | 🐡 | `cancun` | 1153 transient storage, 5656 MCOPY, 4844 blobs (no blob txs in lab) | +| 5 | `pectra` | 🦒 | `prague` | 7702 set-code (`authorizationList` on run_transaction), 2537 BLS precompiles, 7623 calldata | **Floor:** **Berlin** — Istanbul and earlier hardforks are out of scope for this MCP round. diff --git a/mcp-docs/use/tools/describe-capabilities.md b/mcp-docs/use/tools/describe-capabilities.md index 7e46771..9f1684f 100644 --- a/mcp-docs/use/tools/describe-capabilities.md +++ b/mcp-docs/use/tools/describe-capabilities.md @@ -38,7 +38,7 @@ None required. Pass `{}` or omit arguments. | `ceilings` | `maxGasLimit`, `maxTransactionGasLimit`, `defaultGasLimit`, `maxBytecodeBytes`, `maxTraceSteps`, `maxTxsPerBlock` | | `baselineForkId` | Current mainnet EL (`fusaka`) — probe label for mainnet-today; run the fork the user asked for | | `queryShapes` | Dictionary: catalog `id` (`simulate`, `generate`, …) → `mcpTool` (`run_bytecode`, `generate_artifact`, …). Agents call `mcpTool`. | -| `namedForks` | Berlin→Glamsterdam lineage — `order`, `predecessorId`, `successorId`, `role`, `activatedEips`, advertised `relatedEips`, **`tools`** (MCP names) | +| `namedForks` | Berlin→Glamsterdam lineage — `order`, `predecessorId`, `successorId`, `role`, `mascotEmoji` (when assigned), `activatedEips`, advertised `relatedEips`, **`tools`** (MCP names) | | `eipIntroductions` | When each EIP activated. **`coverage`** is `twin` (runnable module), `supported` (hardfork already applies it — omit that id from `eips` and use the fork tools), `unshown` (execution-layer, in the bundle, and this lab does not demonstrate it — do not put it in `eips`; a generic fork run does not stand in for it), `consensus` (consensus-layer — this lab does not execute it; do not put it in `eips`), `networking` (wire protocol — this lab does not speak it; do not put it in `eips`), `informational` (analysis or a schedule — this lab does not execute it; do not put it in `eips`), or `listed` (name and fork only). **`observableTools`** are MCP names; `supported`, `unshown`, `consensus`, `networking`, and `informational` rows omit them | | `eips` | Runnable modules — optional `comparison` lookup from `eipIntroductions` (use when the user asks for before/after); **`tools`** lists MCP names to call; each row includes `specUrl`, `specDate`, `status`, and optional `testReleaseUrl` / `testReleaseName` (or a live `eips.ethereum.org` page when unpinned) | | `allowedBaseHardforks` | Lineage forks (`berlin` … `glamsterdam`) plus aliases; glacier/BPO ids rejected | @@ -61,8 +61,8 @@ _Output (abbreviated):_ { "id": "simulate", "mcpTool": "run_bytecode", "summary": "…" } ], "namedForks": [ - { "id": "fusaka", "role": "current", "aliases": ["osaka", "mainnet-el"], "relatedEips": [7883, 7951], "tools": ["run_bytecode", "run_transaction", "run_block"], "…": "…" }, - { "id": "glamsterdam", "role": "preview", "aliases": ["amsterdam"], "relatedEips": [2780, 7708, 7778, 7843, 7928, 7954, 7976, 8024, 8037, 8038], "tools": ["run_bytecode", "run_transaction", "run_block"], "…": "…" } + { "id": "fusaka", "role": "current", "mascotEmoji": "🦓", "aliases": ["osaka", "mainnet-el"], "relatedEips": [7883, 7951], "tools": ["run_bytecode", "run_transaction", "run_block"], "…": "…" }, + { "id": "glamsterdam", "role": "preview", "mascotEmoji": "🐻‍❄️", "aliases": ["amsterdam"], "relatedEips": [2780, 7708, 7778, 7843, 7928, 7954, 7976, 8024, 8037, 8038], "tools": ["run_bytecode", "run_transaction", "run_block"], "…": "…" } ], "eips": [{ "eip": 8024, diff --git a/src/explorations/ExplorationMetaPills.vue b/src/explorations/ExplorationMetaPills.vue index ba72c28..59c3072 100644 --- a/src/explorations/ExplorationMetaPills.vue +++ b/src/explorations/ExplorationMetaPills.vue @@ -21,9 +21,11 @@ const props = withDefaults( const pillLinkClass = 'exploration-meta-pill inline-flex items-center justify-center font-mono rounded-full border no-underline cursor-pointer transition-colors hover:bg-slate-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-400 text-xs px-3 py-2 min-h-11 sm:min-h-0 sm:px-2 sm:py-0.5 sm:text-[0.65rem]' -const timelineLabel = computed( - () => TIMELINE[props.exploration.timeline]?.title ?? props.exploration.timeline, -) +const timelineLabel = computed(() => { + const entry = TIMELINE[props.exploration.timeline] + if (!entry) return props.exploration.timeline + return `${entry.emoji} ${entry.title}` +}) const timelineBrowseTo = computed(() => ({ path: '/all', query: { timeline: props.exploration.timeline }, diff --git a/src/explorations/TIMELINE.ts b/src/explorations/TIMELINE.ts index 2328e48..df2debf 100644 --- a/src/explorations/TIMELINE.ts +++ b/src/explorations/TIMELINE.ts @@ -1,3 +1,5 @@ +import { FORK_MASCOT_EMOJI } from '@/explorations/forkCatalog' + /** * Website catalog timeline — forks that have explorations. * @@ -27,13 +29,13 @@ export const TIMELINE: Timeline = { fusaka: { title: 'Fusaka', role: 'current', - emoji: '🏯', + emoji: FORK_MASCOT_EMOJI.fusaka!, order: 100, }, glamsterdam: { title: 'Glamsterdam', role: 'preview', - emoji: '🌷', + emoji: FORK_MASCOT_EMOJI.glamsterdam!, order: 110, }, } diff --git a/src/explorations/__tests__/forkCatalog.spec.ts b/src/explorations/__tests__/forkCatalog.spec.ts new file mode 100644 index 0000000..bd69e4e --- /dev/null +++ b/src/explorations/__tests__/forkCatalog.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { + FORK_MASCOT_EMOJI, + forkDisplayLabel, + fypHardforkToggleOptions, + MCP_FORK_IDS, +} from '@/explorations/forkCatalog' +import { TIMELINE } from '@/explorations/TIMELINE' + +describe('forkCatalog mascots', () => { + it('keeps TIMELINE emoji aligned with FORK_MASCOT_EMOJI', () => { + for (const [id, entry] of Object.entries(TIMELINE)) { + expect(entry.emoji).toBe(FORK_MASCOT_EMOJI[id as keyof typeof FORK_MASCOT_EMOJI]) + } + }) + + it('lists zebra and polar bear on hardfork toggles', () => { + const labels = fypHardforkToggleOptions().map((row) => row.label) + expect(labels).toEqual(['🐻‍❄️ Glamsterdam', '🦓 Fusaka']) + }) + + it('covers every mascot id in MCP_FORK_IDS', () => { + const mascotIds = Object.keys(FORK_MASCOT_EMOJI).sort() + expect( + mascotIds.every((id) => MCP_FORK_IDS.includes(id as (typeof MCP_FORK_IDS)[number])), + ).toBe(true) + expect(forkDisplayLabel('pectra', 'Pectra')).toBe('🦒 Pectra') + }) +}) diff --git a/src/explorations/eip-2780/MyC.vue b/src/explorations/eip-2780/MyC.vue index 0e65468..48680bd 100644 --- a/src/explorations/eip-2780/MyC.vue +++ b/src/explorations/eip-2780/MyC.vue @@ -8,6 +8,7 @@ import ScenarioStepNavUIC from '@/eComponents/ui/ScenarioStepNavUIC.vue' import SegmentedToggleUIC from '@/eComponents/ui/SegmentedToggleUIC.vue' import WidgetChromeUIC from '@/eComponents/ui/WidgetChromeUIC.vue' import ExplorationC from '@/explorations/ExplorationC.vue' +import { fypHardforkToggleOptions } from '@/explorations/forkCatalog' import { TOPICS } from '@/explorations/TOPICS' import { resolveInitialExample } from '@/libs/exampleFromQuery' import { useExplorationExampleQuery } from '@/libs/useExplorationExampleQuery' @@ -75,10 +76,7 @@ function onHardforkInput(value: string) { if (value === 'glamsterdam' || value === 'fusaka') setHardfork(value) } -const hardforkOptions = [ - { value: 'glamsterdam', label: 'Glamsterdam', testId: 'hardfork-glamsterdam' }, - { value: 'fusaka', label: 'Fusaka', testId: 'hardfork-fusaka' }, -] +const hardforkOptions = fypHardforkToggleOptions() async function runTxAction(): Promise { if (!isShapeId(example.value)) return diff --git a/src/explorations/eip-2780/tests.spec.ts b/src/explorations/eip-2780/tests.spec.ts index a0b18f3..e549674 100644 --- a/src/explorations/eip-2780/tests.spec.ts +++ b/src/explorations/eip-2780/tests.spec.ts @@ -130,9 +130,9 @@ describe('EIP-2780 intrinsic transaction gas exploration', () => { it('shows a flat 21,000 when Fusaka is selected', async () => { const wrapper = await mountWidget() - const fusaka = wrapper.findAll('button').find((button) => button.text() === 'Fusaka') - expect(fusaka).toBeDefined() - await fusaka!.trigger('click') + const fusaka = wrapper.find('[data-testid="hardfork-fusaka"]') + expect(fusaka.exists()).toBe(true) + await fusaka.trigger('click') expect(wrapper.find('[data-testid="piece-ledger"]').attributes('data-split')).toBe('false') expect(wrapper.find('[data-testid="piece-total"]').text()).toBe('21,000') expect(wrapper.find('[data-testid="piece-base"]').text()).toContain('flat 21,000') diff --git a/src/explorations/eip-7708/MyC.vue b/src/explorations/eip-7708/MyC.vue index 0058f3e..731dd2c 100644 --- a/src/explorations/eip-7708/MyC.vue +++ b/src/explorations/eip-7708/MyC.vue @@ -9,6 +9,7 @@ import ScenarioStepNavUIC from '@/eComponents/ui/ScenarioStepNavUIC.vue' import SegmentedToggleUIC from '@/eComponents/ui/SegmentedToggleUIC.vue' import WidgetChromeUIC from '@/eComponents/ui/WidgetChromeUIC.vue' import ExplorationC from '@/explorations/ExplorationC.vue' +import { fypHardforkToggleOptions } from '@/explorations/forkCatalog' import { TOPICS } from '@/explorations/TOPICS' import { useCompanionStatusPublisher } from '@/libs/companionStatus' import { resolveInitialExample } from '@/libs/exampleFromQuery' @@ -68,10 +69,7 @@ function onHardforkInput(value: string) { if (value === 'glamsterdam' || value === 'fusaka') setHardfork(value) } -const hardforkOptions = [ - { value: 'glamsterdam', label: 'Glamsterdam', testId: 'hardfork-glamsterdam' }, - { value: 'fusaka', label: 'Fusaka', testId: 'hardfork-fusaka' }, -] +const hardforkOptions = fypHardforkToggleOptions() async function runBlock(): Promise { if (example.value === '') return diff --git a/src/explorations/eip-7708/tests.spec.ts b/src/explorations/eip-7708/tests.spec.ts index ac07f08..a0a9c6f 100644 --- a/src/explorations/eip-7708/tests.spec.ts +++ b/src/explorations/eip-7708/tests.spec.ts @@ -153,7 +153,7 @@ describe('EIP-7708 transfer-log exploration', () => { expect(wrapper.text()).toContain('Glamsterdam') const glamsterdam = wrapper.find('[aria-pressed="true"]') expect(glamsterdam.exists()).toBe(true) - expect(glamsterdam.text()).toBe('Glamsterdam') + expect(glamsterdam.text()).toContain('Glamsterdam') expect(wrapper.text()).toContain('Run the block to inspect receipt logs') }) @@ -185,14 +185,14 @@ describe('EIP-7708 transfer-log exploration', () => { await flushPromises() await flushPromises() - const fusaka = wrapper.findAll('button').find((b) => b.text() === 'Fusaka') - expect(fusaka).toBeDefined() - await fusaka!.trigger('click') - expect(wrapper.find('[aria-pressed="true"]').text()).toBe('Fusaka') + const fusaka = wrapper.find('[data-testid="hardfork-fusaka"]') + expect(fusaka.exists()).toBe(true) + await fusaka.trigger('click') + expect(wrapper.find('[aria-pressed="true"]').text()).toContain('Fusaka') const next = wrapper.findAll('button').find((b) => b.text().includes('next')) await next!.trigger('click') - expect(wrapper.find('[aria-pressed="true"]').text()).toBe('Glamsterdam') + expect(wrapper.find('[aria-pressed="true"]').text()).toContain('Glamsterdam') }) }) }) diff --git a/src/explorations/eip-7778/MyC.vue b/src/explorations/eip-7778/MyC.vue index 4b306fe..2b5835b 100644 --- a/src/explorations/eip-7778/MyC.vue +++ b/src/explorations/eip-7778/MyC.vue @@ -8,6 +8,7 @@ import ScenarioStepNavUIC from '@/eComponents/ui/ScenarioStepNavUIC.vue' import SegmentedToggleUIC from '@/eComponents/ui/SegmentedToggleUIC.vue' import WidgetChromeUIC from '@/eComponents/ui/WidgetChromeUIC.vue' import ExplorationC from '@/explorations/ExplorationC.vue' +import { fypHardforkToggleOptions } from '@/explorations/forkCatalog' import { TOPICS } from '@/explorations/TOPICS' import { resolveInitialExample } from '@/libs/exampleFromQuery' import { useExplorationExampleQuery } from '@/libs/useExplorationExampleQuery' @@ -66,10 +67,7 @@ function onHardforkInput(value: string) { if (value === 'glamsterdam' || value === 'fusaka') setHardfork(value) } -const hardforkOptions = [ - { value: 'glamsterdam', label: 'Glamsterdam', testId: 'hardfork-glamsterdam' }, - { value: 'fusaka', label: 'Fusaka', testId: 'hardfork-fusaka' }, -] +const hardforkOptions = fypHardforkToggleOptions() async function runTxAction(): Promise { if (example.value === '') return diff --git a/src/explorations/eip-7843/SlotCompanion.vue b/src/explorations/eip-7843/SlotCompanion.vue index 7a8a310..13a305a 100644 --- a/src/explorations/eip-7843/SlotCompanion.vue +++ b/src/explorations/eip-7843/SlotCompanion.vue @@ -2,6 +2,7 @@ import { computed, watch } from 'vue' import SegmentedToggleUIC from '@/eComponents/ui/SegmentedToggleUIC.vue' +import { fypHardforkToggleOptions } from '@/explorations/forkCatalog' import { TOPIC_COLORS, topicCSSVars, TOPICS } from '@/explorations/TOPICS' import { useCompanionStatusPublisher } from '@/libs/companionStatus' @@ -22,10 +23,7 @@ const SLOT_PRESETS = [ { value: '32', label: '32', testId: 'slot-preset-32' }, ] -const hardforkOptions = [ - { value: 'glamsterdam', label: 'Glamsterdam', testId: 'hardfork-glamsterdam' }, - { value: 'fusaka', label: 'Fusaka', testId: 'hardfork-fusaka' }, -] +const hardforkOptions = fypHardforkToggleOptions() const parsed = computed(() => parseSlotNumber(slotInput.value)) const appliedSlot = computed(() => (parsed.value.ok ? parsed.value.value : DEFAULT_SLOT)) diff --git a/src/explorations/eip-7976/MyC.vue b/src/explorations/eip-7976/MyC.vue index 49ca030..e4634e3 100644 --- a/src/explorations/eip-7976/MyC.vue +++ b/src/explorations/eip-7976/MyC.vue @@ -7,6 +7,7 @@ import ScenarioStepNavUIC from '@/eComponents/ui/ScenarioStepNavUIC.vue' import SegmentedToggleUIC from '@/eComponents/ui/SegmentedToggleUIC.vue' import WidgetChromeUIC from '@/eComponents/ui/WidgetChromeUIC.vue' import ExplorationC from '@/explorations/ExplorationC.vue' +import { fypHardforkToggleOptions } from '@/explorations/forkCatalog' import { TOPICS } from '@/explorations/TOPICS' import { resolveInitialExample } from '@/libs/exampleFromQuery' import { useExplorationExampleQuery } from '@/libs/useExplorationExampleQuery' @@ -85,10 +86,7 @@ function onHardforkInput(value: string) { if (value === 'glamsterdam' || value === 'fusaka') setHardfork(value) } -const hardforkOptions = [ - { value: 'glamsterdam', label: 'Glamsterdam', testId: 'hardfork-glamsterdam' }, - { value: 'fusaka', label: 'Fusaka', testId: 'hardfork-fusaka' }, -] +const hardforkOptions = fypHardforkToggleOptions() async function init() { example.value = resolveInitialExample(examples, DEFAULT_SHAPE_ID, exampleQuery) diff --git a/src/explorations/eip-7976/tests.spec.ts b/src/explorations/eip-7976/tests.spec.ts index c291661..f5e75cc 100644 --- a/src/explorations/eip-7976/tests.spec.ts +++ b/src/explorations/eip-7976/tests.spec.ts @@ -225,9 +225,9 @@ describe('EIP-7976 calldata floor exploration', () => { it('shows the Fusaka floor of 10 per zero byte', async () => { const wrapper = await mountWidget() - const fusaka = wrapper.findAll('button').find((button) => button.text() === 'Fusaka') - expect(fusaka).toBeDefined() - await fusaka!.trigger('click') + const fusaka = wrapper.find('[data-testid="hardfork-fusaka"]') + expect(fusaka.exists()).toBe(true) + await fusaka.trigger('click') await vi.waitFor(() => { expect(wrapper.find('[data-testid="charged"]').text()).toBe('22,000') }) diff --git a/src/explorations/eip-8037/MyC.vue b/src/explorations/eip-8037/MyC.vue index 4ea0796..893cd86 100644 --- a/src/explorations/eip-8037/MyC.vue +++ b/src/explorations/eip-8037/MyC.vue @@ -8,6 +8,7 @@ import ScenarioStepNavUIC from '@/eComponents/ui/ScenarioStepNavUIC.vue' import SegmentedToggleUIC from '@/eComponents/ui/SegmentedToggleUIC.vue' import WidgetChromeUIC from '@/eComponents/ui/WidgetChromeUIC.vue' import ExplorationC from '@/explorations/ExplorationC.vue' +import { fypHardforkToggleOptions } from '@/explorations/forkCatalog' import { TOPICS } from '@/explorations/TOPICS' import { resolveInitialExample } from '@/libs/exampleFromQuery' import { useExplorationExampleQuery } from '@/libs/useExplorationExampleQuery' @@ -86,10 +87,7 @@ function onGasLimitInput(value: string) { if (value === 'classic' || value === 'recommended') setGasLimitMode(value) } -const hardforkOptions = [ - { value: 'glamsterdam', label: 'Glamsterdam', testId: 'hardfork-glamsterdam' }, - { value: 'fusaka', label: 'Fusaka', testId: 'hardfork-fusaka' }, -] +const hardforkOptions = fypHardforkToggleOptions() const gasLimitOptions = computed(() => [ { value: 'classic', label: formatGas(CLASSIC_GAS_LIMIT), testId: 'gas-limit-classic' }, diff --git a/src/explorations/eip-8037/tests.spec.ts b/src/explorations/eip-8037/tests.spec.ts index 4504755..02b8d30 100644 --- a/src/explorations/eip-8037/tests.spec.ts +++ b/src/explorations/eip-8037/tests.spec.ts @@ -201,7 +201,7 @@ describe('EIP-8037 state-creation gas exploration', () => { expect(wrapper.text()).toContain('Recommended') const glamsterdam = wrapper.find('[aria-pressed="true"]') expect(glamsterdam.exists()).toBe(true) - expect(glamsterdam.text()).toBe('Glamsterdam') + expect(glamsterdam.text()).toContain('Glamsterdam') expect(wrapper.find('[data-testid="gas-bars"]').attributes('data-has-run')).toBe('false') expect(wrapper.text()).toContain('Run tx to fill execution and state bars') const gasBars = wrapper.find('[data-testid="gas-bars"]') @@ -237,9 +237,9 @@ describe('EIP-8037 state-creation gas exploration', () => { await flushPromises() await flushPromises() - const fusaka = wrapper.findAll('button').find((b) => b.text() === 'Fusaka') - expect(fusaka).toBeDefined() - await fusaka!.trigger('click') + const fusaka = wrapper.find('[data-testid="hardfork-fusaka"]') + expect(fusaka.exists()).toBe(true) + await fusaka.trigger('click') expect(wrapper.find('[data-testid="hardfork-fusaka"]').attributes('aria-pressed')).toBe( 'true', ) diff --git a/src/explorations/eip-8038/MyC.vue b/src/explorations/eip-8038/MyC.vue index 33b172c..8bc2b88 100644 --- a/src/explorations/eip-8038/MyC.vue +++ b/src/explorations/eip-8038/MyC.vue @@ -7,6 +7,7 @@ import ResultBoxUIC from '@/eComponents/ui/resultBox/ResultBoxUIC.vue' import SegmentedToggleUIC from '@/eComponents/ui/SegmentedToggleUIC.vue' import WidgetChromeUIC from '@/eComponents/ui/WidgetChromeUIC.vue' import ExplorationC from '@/explorations/ExplorationC.vue' +import { fypHardforkToggleOptions } from '@/explorations/forkCatalog' import { TOPICS } from '@/explorations/TOPICS' import { resolveInitialExample } from '@/libs/exampleFromQuery' import { useExplorationExampleQuery } from '@/libs/useExplorationExampleQuery' @@ -57,10 +58,7 @@ function onHardforkInput(value: string) { if (value === 'glamsterdam' || value === 'fusaka') setHardfork(value) } -const hardforkOptions = [ - { value: 'glamsterdam', label: 'Glamsterdam', testId: 'hardfork-glamsterdam' }, - { value: 'fusaka', label: 'Fusaka', testId: 'hardfork-fusaka' }, -] +const hardforkOptions = fypHardforkToggleOptions() async function runProgram(): Promise { if (example.value === '') return diff --git a/src/explorations/eip-8038/tests.spec.ts b/src/explorations/eip-8038/tests.spec.ts index 14973f8..ad2c679 100644 --- a/src/explorations/eip-8038/tests.spec.ts +++ b/src/explorations/eip-8038/tests.spec.ts @@ -150,7 +150,7 @@ describe('EIP-8038 state-access gas exploration', () => { expect(wrapper.text()).toContain('Glamsterdam') const glamsterdam = wrapper.find('[aria-pressed="true"]') expect(glamsterdam.exists()).toBe(true) - expect(glamsterdam.text()).toBe('Glamsterdam') + expect(glamsterdam.text()).toContain('Glamsterdam') expect(wrapper.find('[data-testid="cost-breakdown"]').attributes('data-has-run')).toBe( 'false', ) @@ -190,9 +190,9 @@ describe('EIP-8038 state-access gas exploration', () => { await flushPromises() await flushPromises() - const fusaka = wrapper.findAll('button').find((b) => b.text() === 'Fusaka') - expect(fusaka).toBeDefined() - await fusaka!.trigger('click') + const fusaka = wrapper.find('[data-testid="hardfork-fusaka"]') + expect(fusaka.exists()).toBe(true) + await fusaka.trigger('click') expect(wrapper.find('[data-testid="hardfork-fusaka"]').attributes('aria-pressed')).toBe( 'true', ) diff --git a/src/explorations/forkCatalog.ts b/src/explorations/forkCatalog.ts index 0b5437b..2567663 100644 --- a/src/explorations/forkCatalog.ts +++ b/src/explorations/forkCatalog.ts @@ -17,3 +17,43 @@ export const MCP_FORK_IDS = [ ] as const export type McpForkId = (typeof MCP_FORK_IDS)[number] + +/** + * Community upgrade mascots ([EIP-8066](https://eips.ethereum.org/EIPS/eip-8066)). + * Berlin and London predate the formal process. Keep in sync with engine + * `mcp-execution-engine/src/forks/mascots.ts`. + */ +export const FORK_MASCOT_EMOJI: Partial> = { + paris: '🐼', + shapella: '🦉', + dencun: '🐡', + pectra: '🦒', + fusaka: '🦓', + glamsterdam: '🐻‍❄️', +} + +/** Display title with leading mascot when known. */ +export function forkDisplayLabel(id: string, title: string): string { + const emoji = FORK_MASCOT_EMOJI[id as McpForkId] + return emoji ? `${emoji} ${title}` : title +} + +/** Segmented hardfork toggle rows for Fusaka vs Glamsterdam widgets. */ +export function fypHardforkToggleOptions(): Array<{ + value: 'fusaka' | 'glamsterdam' + label: string + testId: string +}> { + return [ + { + value: 'glamsterdam', + label: forkDisplayLabel('glamsterdam', 'Glamsterdam'), + testId: 'hardfork-glamsterdam', + }, + { + value: 'fusaka', + label: forkDisplayLabel('fusaka', 'Fusaka'), + testId: 'hardfork-fusaka', + }, + ] +} diff --git a/src/views/TopicView.vue b/src/views/TopicView.vue index b9ff6a0..8e5add6 100644 --- a/src/views/TopicView.vue +++ b/src/views/TopicView.vue @@ -62,8 +62,12 @@ const filterSummary = computed(() => { if (activeTagValue.value) parts.push(`tag ${activeTagValue.value}`) if (activeTimelineId.value) { const tid = activeTimelineId.value as WebsiteTimelineId - const label = tid in TIMELINE ? TIMELINE[tid].title : activeTimelineId.value - parts.push(`timeline ${label}`) + if (tid in TIMELINE) { + const entry = TIMELINE[tid] + parts.push(`timeline ${entry.emoji} ${entry.title}`) + } else { + parts.push(`timeline ${activeTimelineId.value}`) + } } return parts.join(' · ') }) diff --git a/src/views/__tests__/homeCatalog.spec.ts b/src/views/__tests__/homeCatalog.spec.ts index 988d0f5..cea7d33 100644 --- a/src/views/__tests__/homeCatalog.spec.ts +++ b/src/views/__tests__/homeCatalog.spec.ts @@ -28,6 +28,6 @@ describe('homeCatalog', () => { }) it('lists fork titles from live explorations', () => { - expect(catalogForkLabels()).toEqual(['Fusaka', 'Glamsterdam']) + expect(catalogForkLabels()).toEqual(['🦓 Fusaka', '🐻‍❄️ Glamsterdam']) }) }) diff --git a/src/views/homeCatalog.ts b/src/views/homeCatalog.ts index 3f0b64d..0bf56d0 100644 --- a/src/views/homeCatalog.ts +++ b/src/views/homeCatalog.ts @@ -41,5 +41,5 @@ export function catalogForkLabels(): string[] { .map((id) => TIMELINE[id]) .filter((entry): entry is NonNullable => entry !== undefined) .sort((a, b) => a.order - b.order) - .map((entry) => entry.title) + .map((entry) => `${entry.emoji} ${entry.title}`) } diff --git a/website-docs/guide/architecture.md b/website-docs/guide/architecture.md index c3a52c0..0056670 100644 --- a/website-docs/guide/architecture.md +++ b/website-docs/guide/architecture.md @@ -46,7 +46,7 @@ When instructing an agent: pick the topic that best reflects the **primary conce ### Timeline -Forks that have explorations — ids in `TIMELINE.ts` match the MCP lineage catalog (`fusaka`, `glamsterdam`, …). From Shapella on, the combined upgrade name is canonical; the EL city name is an alias. Website nav is this subset only (do not add empty historical forks). Role is `historical` | `current` | `preview` (same as MCP `namedForks[].role`). Add a row when an exploration for that fork ships. Keep the row and the explorations after that fork is live on mainnet — role may later become `historical`; do not delete on activation. +Forks that have explorations — ids in `TIMELINE.ts` match the MCP lineage catalog (`fusaka`, `glamsterdam`, …). From Shapella on, the combined upgrade name is canonical; the EL city name is an alias. Website nav is this subset only (do not add empty historical forks). Role is `historical` | `current` | `preview` (same as MCP `namedForks[].role`). Upgrade mascots ([EIP-8066](https://eips.ethereum.org/EIPS/eip-8066)) live in `forkCatalog.ts` `FORK_MASCOT_EMOJI` (keep in sync with engine `forks/mascots.ts`) and surface on timeline pills, toggles, and MCP probe/provenance. Add a row when an exploration for that fork ships. Keep the row and the explorations after that fork is live on mainnet — role may later become `historical`; do not delete on activation. ### Tags