diff --git a/.github/screenshots/hover-card-aggregated.png b/.github/screenshots/hover-card-aggregated.png
new file mode 100644
index 0000000..d0fa7ef
Binary files /dev/null and b/.github/screenshots/hover-card-aggregated.png differ
diff --git a/.github/screenshots/hover-card-weighted-dark.png b/.github/screenshots/hover-card-weighted-dark.png
new file mode 100644
index 0000000..d1ebde3
Binary files /dev/null and b/.github/screenshots/hover-card-weighted-dark.png differ
diff --git a/.github/screenshots/legend-desktop.png b/.github/screenshots/legend-desktop.png
new file mode 100644
index 0000000..ac42873
Binary files /dev/null and b/.github/screenshots/legend-desktop.png differ
diff --git a/.github/screenshots/legend-mobile.png b/.github/screenshots/legend-mobile.png
new file mode 100644
index 0000000..81bd845
Binary files /dev/null and b/.github/screenshots/legend-mobile.png differ
diff --git a/web/package.json b/web/package.json
index 4bde556..0668460 100644
--- a/web/package.json
+++ b/web/package.json
@@ -26,6 +26,7 @@
"build-storybook": "storybook build"
},
"dependencies": {
+ "@floating-ui/react": "0.26.28",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@headlessui/react": "2.2.10",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 2e50de1..ba2a77e 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@floating-ui/react':
+ specifier: 0.26.28
+ version: 0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
diff --git a/web/src/components/AggregatedNode.tsx b/web/src/components/AggregatedNode.tsx
index ff263bf..bd073fa 100644
--- a/web/src/components/AggregatedNode.tsx
+++ b/web/src/components/AggregatedNode.tsx
@@ -3,56 +3,10 @@ import { memo } from 'react';
import { EyeIcon, CheckIcon, FlagIcon } from '@heroicons/react/20/solid';
import classNames from 'clsx';
+import HoverCard from '@components/HoverCard';
+import { AggregatedNodeCard } from '@components/NodeCards';
import ProgressCircle from '@components/ProgressCircle';
-
-/* Mirrors the WeightedNode status recipe: dim core, bright rim, soft glow. */
-const NODE_STYLES: Record<
- string,
- { fill: string; arc: string; track: string; label: string; glow: string }
-> = {
- canonical: {
- fill: 'bg-canonical-deep',
- arc: 'text-canonical-ring',
- track: 'text-canonical-edge',
- label: 'text-canonical',
- glow: 'shadow-[0_0_90px_-15px_var(--color-canonical-ring)]',
- },
- fork: {
- fill: 'bg-fork-deep',
- arc: 'text-fork-ring',
- track: 'text-fork-edge',
- label: 'text-fork',
- glow: 'shadow-[0_0_90px_-15px_var(--color-fork-ring)]',
- },
- finalized: {
- fill: 'bg-finalized-deep',
- arc: 'text-finalized-ring',
- track: 'text-finalized-edge',
- label: 'text-finalized',
- glow: 'shadow-[0_0_90px_-15px_var(--color-finalized-ring)]',
- },
- justified: {
- fill: 'bg-justified-deep',
- arc: 'text-justified-ring',
- track: 'text-justified-edge',
- label: 'text-justified',
- glow: 'shadow-[0_0_90px_-15px_var(--color-justified-ring)]',
- },
- invalid: {
- fill: 'bg-invalid-deep',
- arc: 'text-invalid-ring',
- track: 'text-invalid-edge',
- label: 'text-invalid',
- glow: 'shadow-[0_0_90px_-15px_var(--color-invalid-ring)]',
- },
- optimistic: {
- fill: 'bg-optimistic-deep',
- arc: 'text-optimistic-ring',
- track: 'text-optimistic-edge',
- label: 'text-optimistic',
- glow: 'shadow-[0_0_90px_-15px_var(--color-optimistic-ring)]',
- },
-};
+import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles';
function AggregatedNode({
id,
@@ -69,6 +23,8 @@ function AggregatedNode({
x,
y,
radius,
+ slot,
+ epoch,
className,
onClick,
}: {
@@ -86,10 +42,12 @@ function AggregatedNode({
x: number;
y: number;
radius: number;
+ slot?: number;
+ epoch?: number;
className?: string;
onClick?: (hash: string) => void;
}) {
- const [styleKey, title] = ((): [string, string] => {
+ const [styleKey, title] = ((): [NodeStyleKey, string] => {
if (valid !== seen) return ['invalid', `${seen - valid} NOT VALID`];
if (orphans > 0) return ['invalid', `${orphans} DETACHED`];
if (finalizedCheckpoints > 0) return ['finalized', 'FINALIZED'];
@@ -105,60 +63,97 @@ function AggregatedNode({
return ['canonical', type];
}
})();
- const styles = NODE_STYLES[styleKey] ?? NODE_STYLES.canonical;
+ const styles = NODE_STYLES[styleKey];
+
+ const handleActivate = () => onClick?.(hash);
return (
-
+ }
+ referenceProps={{
+ onClick: handleActivate,
+ onKeyDown: event => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleActivate();
+ }
+ },
}}
- onClick={() => onClick?.(hash)}
>
-
-
- {title}
-
-
- {hash.substring(0, 6)}…{hash.substring(hash.length - 4)}
-
-
-
- {finalizedCheckpoints > 0 || justifiedCheckpoints > 0 ? (
- <>
- {finalizedCheckpoints || justifiedCheckpoints}/{total}
- >
- ) : (
- <>
- {canonical}/{total}
- >
+ {referenceProps => (
+
-
- {seen}/{total}
-
-
-
+ style={{
+ left: `${x}px`,
+ top: `${y}px`,
+ width: `${radius * 2}px`,
+ height: `${radius * 2}px`,
+ }}
+ >
+
+
+ {title}
+
+
+ {hash.substring(0, 6)}…{hash.substring(hash.length - 4)}
+
+
+
+ {finalizedCheckpoints > 0 || justifiedCheckpoints > 0 ? (
+ <>
+ {finalizedCheckpoints || justifiedCheckpoints}/
+ {total}
+ >
+ ) : (
+ <>
+ {canonical}/{total}
+ >
+ )}
+
+
+ {seen}/{total}
+
+
+
+ )}
+
);
}
diff --git a/web/src/components/GraphPrimitives.stories.tsx b/web/src/components/GraphPrimitives.stories.tsx
index 3a7d1ed..dac6aa8 100644
--- a/web/src/components/GraphPrimitives.stories.tsx
+++ b/web/src/components/GraphPrimitives.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from '@storybook/tanstack-react';
+import { expect, userEvent, waitFor, within } from 'storybook/test';
import AggregatedNode from '@components/AggregatedNode';
import ConcatNode from '@components/ConcatNode';
import Edge from '@components/Edge';
+import { HoverCardPanel } from '@components/HoverCard';
+import { AggregatedNodeCard, TruncationCard, WeightedNodeCard } from '@components/NodeCards';
import ProgressCircle from '@components/ProgressCircle';
import SlotBoundary from '@components/SlotBoundary';
import TruncationMarker from '@components/TruncationMarker';
@@ -221,6 +224,325 @@ export const AggregatedNodeStates: Story = {
),
};
+/* Hover-card content for every weighted node state, rendered statically in
+ * the real card chrome (HoverCardPanel) so each variant is reviewable
+ * without pointer choreography. The styleKey/status pairs mirror what
+ * WeightedNode derives for the matching disc states above. */
+export const WeightedNodeCards: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* mainnet-scale numbers: 8-digit slot, 6-digit epoch, 15-digit
+ * weight — chips wrap below the status and the weight tile shows
+ * compact notation instead of overflowing */}
+
+
+
+
+ ),
+};
+
+/* Same coverage for the aggregated card: the styleKey/status pairs mirror
+ * AggregatedNode's priority cascade (not valid → detached → finalized →
+ * justified → optimistic → canonical/fork). */
+export const AggregatedNodeCards: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+/* Real pointer choreography: hover a node, the details card appears in a
+ * portal (outside the canvas element); Escape dismisses it. */
+export const WeightedNodeHoverInteraction: Story = {
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const body = within(document.body);
+
+ await userEvent.hover(canvas.getByRole('button'));
+ // the card fades in (opacity 0 → 1), so retry until the entrance
+ // transition lands
+ await waitFor(() => expect(body.getByText('Click the block for full details')).toBeVisible());
+ await expect(body.getByText('Branch share')).toBeVisible();
+ await expect(body.getByText('45%')).toBeVisible();
+ // slot/epoch render as chips with the number highlighted in a nested
+ // span, so match on each chip's assembled text content
+ await expect(body.getByText((_, element) => element?.textContent === 'slot 105')).toBeVisible();
+ await expect(body.getByText((_, element) => element?.textContent === 'epoch 3')).toBeVisible();
+
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() =>
+ expect(body.queryByText('Click the block for full details')).not.toBeInTheDocument(),
+ );
+ },
+};
+
+export const TruncationMarkerHoverInteraction: Story = {
+ render: () => (
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const body = within(document.body);
+
+ await userEvent.hover(canvas.getByText(/slots\s+hidden/i));
+ await waitFor(() => expect(body.getByText('Hidden range')).toBeVisible());
+ },
+};
+
export const EdgesBoundariesAndProgress: Story = {
render: () => (
diff --git a/web/src/components/HoverCard.tsx b/web/src/components/HoverCard.tsx
new file mode 100644
index 0000000..9fb6b2d
--- /dev/null
+++ b/web/src/components/HoverCard.tsx
@@ -0,0 +1,151 @@
+import { HTMLAttributes, HTMLProps, ReactNode, Ref, useRef, useState } from 'react';
+
+import {
+ FloatingArrow,
+ FloatingPortal,
+ arrow,
+ autoUpdate,
+ flip,
+ offset,
+ safePolygon,
+ shift,
+ useDismiss,
+ useFloating,
+ useFocus,
+ useHover,
+ useInteractions,
+ useRole,
+ useTransitionStyles,
+} from '@floating-ui/react';
+import classNames from 'clsx';
+
+import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles';
+
+/* The card chrome, exported on its own so Storybook can render every card
+ * variant statically without pointer choreography. Content panels stay
+ * solid (not glass) for readability, matching the design language. An
+ * accent ties the card to its node's status family: a hairline bar along
+ * the top edge and a soft halo in the status ring color. */
+export function HoverCardPanel({
+ ref,
+ accent,
+ className,
+ children,
+ ...rest
+}: HTMLAttributes
& { ref?: Ref; accent?: NodeStyleKey }) {
+ return (
+
+ {accent && (
+
+ )}
+ {children}
+
+ );
+}
+
+/* Hover/keyboard-focus details card for graph elements. Renders through a
+ * portal so it escapes the zoom-pan transform (it would scale and blur with
+ * the graph otherwise) and re-anchors every animation frame so it follows
+ * its node while the canvas pans. Enters with a quick ease-out fade/rise
+ * from the anchor side; an arrow ties it to the node. WCAG 1.4.13:
+ * hoverable (safePolygon lets the pointer travel onto the card),
+ * dismissible (Escape) and persistent (no auto-timeout). Pointer-only by
+ * design — tapping a node already opens the full details slide-over. */
+function HoverCard({
+ content,
+ accent,
+ referenceProps,
+ children,
+}: {
+ content: ReactNode;
+ accent?: NodeStyleKey;
+ referenceProps?: HTMLProps;
+ children: (props: Record) => ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+ const arrowRef = useRef(null);
+
+ const { refs, floatingStyles, context } = useFloating({
+ open,
+ onOpenChange: setOpen,
+ placement: 'top',
+ middleware: [
+ offset(16),
+ flip({ padding: 12 }),
+ shift({ padding: 12 }),
+ // react-hooks/refs: the middleware reads the ref during positioning
+ // (floating-ui's documented pattern), not during render
+ // eslint-disable-next-line react-hooks/refs
+ arrow({ element: arrowRef }),
+ ],
+ whileElementsMounted: (reference, floating, update) =>
+ autoUpdate(reference, floating, update, { animationFrame: true }),
+ });
+
+ const hover = useHover(context, {
+ delay: { open: 150, close: 75 },
+ handleClose: safePolygon(),
+ mouseOnly: true,
+ });
+ const focus = useFocus(context);
+ const dismiss = useDismiss(context, { referencePress: true });
+ const role = useRole(context, { role: 'tooltip' });
+ const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
+
+ const { isMounted, styles: transitionStyles } = useTransitionStyles(context, {
+ duration: { open: 180, close: 120 },
+ initial: ({ side }) => ({
+ opacity: 0,
+ transform: side === 'bottom' ? 'translateY(-8px) scale(0.96)' : 'translateY(8px) scale(0.96)',
+ }),
+ });
+
+ /* react-hooks/refs: floating-ui's refs.setReference/refs.setFloating are
+ * stable callback refs meant to be attached during render — not ref
+ * reads, so the rule's heuristic misfires here. */
+ return (
+ <>
+ {}
+ {children({ ref: refs.setReference, ...getReferenceProps(referenceProps) })}
+ {isMounted && (
+
+
+
+ )}
+ >
+ );
+}
+
+export default HoverCard;
diff --git a/web/src/components/Legend.stories.tsx b/web/src/components/Legend.stories.tsx
new file mode 100644
index 0000000..27b01e8
--- /dev/null
+++ b/web/src/components/Legend.stories.tsx
@@ -0,0 +1,92 @@
+import type { Meta, StoryObj } from '@storybook/tanstack-react';
+import { expect, userEvent, within } from 'storybook/test';
+
+import Legend from '@components/Legend';
+import { StorySurface } from '@app/stories/storybook';
+
+const meta = {
+ title: 'Components/Legend',
+ component: Legend,
+ parameters: {
+ layout: 'fullscreen',
+ },
+ render: args => (
+
+
+
+ ),
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const WeightedExpanded: Story = {
+ args: {
+ mode: 'weighted',
+ defaultExpanded: true,
+ },
+};
+
+export const AggregatedExpanded: Story = {
+ args: {
+ mode: 'aggregated',
+ defaultExpanded: true,
+ },
+};
+
+export const CollapsedPill: Story = {
+ args: {
+ mode: 'weighted',
+ },
+};
+
+export const ExpandAndCollapse: Story = {
+ args: {
+ mode: 'weighted',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole('button', { name: 'Show legend' }));
+ await expect(canvas.getByText('Canonical')).toBeVisible();
+ await expect(canvas.getByText('Detached')).toBeVisible();
+ await expect(
+ canvas.getByText('Ring: weight relative to the heaviest competing branch.'),
+ ).toBeVisible();
+ await userEvent.click(canvas.getByRole('button', { name: 'Hide legend' }));
+ await expect(canvas.getByRole('button', { name: 'Show legend' })).toBeVisible();
+ },
+};
+
+export const AggregatedCounters: Story = {
+ args: {
+ mode: 'aggregated',
+ defaultExpanded: true,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(
+ canvas.getByText('Ring: share of sources reporting the block canonical.'),
+ ).toBeVisible();
+ await expect(canvas.getByText('Sources that have seen the block')).toBeVisible();
+ },
+};
+
+export const MobileExpanded: Story = {
+ globals: {
+ viewport: { value: 'iphone14', isRotated: false },
+ },
+ args: {
+ mode: 'aggregated',
+ defaultExpanded: true,
+ },
+};
+
+export const MobileCollapsed: Story = {
+ globals: {
+ viewport: { value: 'iphone14', isRotated: false },
+ },
+ args: {
+ mode: 'weighted',
+ },
+};
diff --git a/web/src/components/Legend.tsx b/web/src/components/Legend.tsx
new file mode 100644
index 0000000..5e3f222
--- /dev/null
+++ b/web/src/components/Legend.tsx
@@ -0,0 +1,198 @@
+import { memo, useState } from 'react';
+
+import { CheckIcon, EyeIcon, FlagIcon } from '@heroicons/react/20/solid';
+import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid';
+import classNames from 'clsx';
+
+import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles';
+
+/* Stage legend for the fork-choice graph's visual vocabulary. Collapsed to
+ * a compact pill by default (mobile stays clean); expands into a glass
+ * panel mirroring the Sources panel chrome. Swatches render from the same
+ * NODE_STYLES table as the real nodes, so the legend cannot drift. Rows are
+ * mode-aware: the progress ring and the footer counters mean different
+ * things in weighted vs aggregated views. */
+
+type LegendMode = 'weighted' | 'aggregated';
+
+const BLOCK_ENTRIES: {
+ styleKey: NodeStyleKey;
+ name: string;
+ description: Record;
+}[] = [
+ {
+ styleKey: 'canonical',
+ name: 'Canonical',
+ description: {
+ weighted: 'On the heaviest (head) chain',
+ aggregated: 'Head chain across the sources',
+ },
+ },
+ {
+ styleKey: 'fork',
+ name: 'Fork',
+ description: {
+ weighted: 'Competing branch with less weight',
+ aggregated: 'Competing branch on some sources',
+ },
+ },
+ {
+ styleKey: 'optimistic',
+ name: 'Optimistic',
+ description: {
+ weighted: 'Execution payload not yet verified',
+ aggregated: 'Optimistic on some sources',
+ },
+ },
+ {
+ styleKey: 'justified',
+ name: 'Justified',
+ description: {
+ weighted: 'Latest justified checkpoint',
+ aggregated: 'Justified checkpoint on the sources',
+ },
+ },
+ {
+ styleKey: 'finalized',
+ name: 'Finalized',
+ description: {
+ weighted: 'Latest finalized checkpoint',
+ aggregated: 'Finalized checkpoint on the sources',
+ },
+ },
+ {
+ styleKey: 'invalid',
+ name: 'Invalid',
+ description: {
+ weighted: 'Failed (or unknown) validity',
+ aggregated: 'Not valid on some sources',
+ },
+ },
+ {
+ styleKey: 'invalid',
+ name: 'Detached',
+ description: {
+ weighted: 'Parent block missing from the view',
+ aggregated: 'Parent block missing on some sources',
+ },
+ },
+];
+
+const RING_NOTE: Record = {
+ weighted: 'Ring: weight relative to the heaviest competing branch.',
+ aggregated: 'Ring: share of sources reporting the block canonical.',
+};
+
+const COUNTER_ENTRIES = [
+ { Icon: CheckIcon, description: 'Sources with the block on their head chain' },
+ { Icon: FlagIcon, description: 'Sources reporting the checkpoint' },
+ { Icon: EyeIcon, description: 'Sources that have seen the block' },
+];
+
+function StatusSwatch({ styleKey, className }: { styleKey: NodeStyleKey; className?: string }) {
+ const styles = NODE_STYLES[styleKey];
+ return (
+
+ );
+}
+
+function Legend({
+ mode,
+ defaultExpanded = false,
+ className,
+}: {
+ mode: LegendMode;
+ defaultExpanded?: boolean;
+ className?: string;
+}) {
+ const [expanded, setExpanded] = useState(defaultExpanded);
+
+ if (!expanded) {
+ return (
+ setExpanded(true)}
+ >
+
+ {(['canonical', 'fork', 'optimistic', 'justified', 'finalized', 'invalid'] as const).map(
+ styleKey => (
+
+ ),
+ )}
+
+
+ Legend
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Legend
+
+ setExpanded(false)}
+ >
+
+
+
+
+
+ {BLOCK_ENTRIES.map(({ styleKey, name, description }) => (
+
+
+
+ {name}
+
+ {/* swatch + name carry the legend on phones; prose stays on sm+ */}
+ {description[mode]}
+
+ ))}
+
+
+ Hidden
+
+ Older slots collapsed; checkpoints stay pinned
+
+
+
+
+ {RING_NOTE[mode]}
+
+ {mode === 'aggregated' && (
+
+ {COUNTER_ENTRIES.map(({ Icon, description }) => (
+
+
+ {description}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default memo(Legend);
diff --git a/web/src/components/NodeCards.tsx b/web/src/components/NodeCards.tsx
new file mode 100644
index 0000000..db3caaf
--- /dev/null
+++ b/web/src/components/NodeCards.tsx
@@ -0,0 +1,407 @@
+import { ReactNode, useState } from 'react';
+
+import { CheckIcon, DocumentDuplicateIcon, EyeIcon, FlagIcon } from '@heroicons/react/20/solid';
+import classNames from 'clsx';
+
+import { truncateHash } from '@app/utils/strings';
+import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles';
+
+/* Read-only content for the graph hover cards (see HoverCard). One variant
+ * per graph primitive; all of them stay presentational so Storybook can
+ * render every state without pointer choreography. Hierarchy follows the
+ * dashboard-tooltip playbook: the hero numbers live in stat tiles, the
+ * supporting rows stay small and muted, and key values carry their status
+ * color. */
+
+/* Mainnet weights run to 15+ digits — too wide for a stat tile. Past a
+ * billion, display compact notation (e.g. 393.23T) and keep the exact
+ * value reachable via the title attribute. */
+function formatWeight(weight: string): { display: string; full: string } {
+ try {
+ const value = BigInt(weight);
+ const full = value.toLocaleString();
+ if (value >= 1_000_000_000n) {
+ return {
+ display: new Intl.NumberFormat('en', {
+ notation: 'compact',
+ maximumFractionDigits: 2,
+ }).format(Number(value)),
+ full,
+ };
+ }
+ return { display: full, full };
+ } catch {
+ return { display: weight, full: weight };
+ }
+}
+
+function validityStyleKey(validity: string): NodeStyleKey {
+ const normalized = validity.toLowerCase();
+ if (normalized === 'valid') return 'canonical';
+ if (normalized === 'optimistic') return 'optimistic';
+ return 'invalid';
+}
+
+function ValidityBadge({ validity }: { validity: string }) {
+ const styles = NODE_STYLES[validityStyleKey(validity)];
+ return (
+
+ {validity.toUpperCase()}
+
+ );
+}
+
+/* Inline weight-share meter echoing the node's progress ring. */
+function ShareMeter({ percentage, styleKey }: { percentage: number; styleKey: NodeStyleKey }) {
+ const styles = NODE_STYLES[styleKey];
+ const rounded = Math.round(percentage);
+ return (
+
+ {rounded}%
+
+
+
+
+ );
+}
+
+function SourceCount({
+ count,
+ total,
+ className,
+}: {
+ count: number;
+ total: number;
+ className?: string;
+}) {
+ return (
+ <>
+
+ {count}/{total}
+ {' '}
+ sources
+ >
+ );
+}
+
+function CopyButton({ value, label }: { value: string; label: string }) {
+ const [copied, setCopied] = useState(false);
+
+ return (
+ {
+ event.stopPropagation();
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1200);
+ } catch {
+ // clipboard unavailable (permissions/insecure context) — ignore
+ }
+ }}
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/* Inset stat tile for the card's hero numbers. */
+function CardStat({
+ label,
+ children,
+ valueClassName,
+}: {
+ label: ReactNode;
+ children: ReactNode;
+ valueClassName?: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
+
+function CardRow({ label, children }: { label: ReactNode; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ );
+}
+
+function CardShell({
+ styleKey,
+ status,
+ slot,
+ epoch,
+ blockRoot,
+ children,
+}: {
+ styleKey: NodeStyleKey;
+ status: string;
+ slot?: number;
+ epoch?: number;
+ blockRoot: string;
+ children: ReactNode;
+}) {
+ const styles = NODE_STYLES[styleKey];
+
+ return (
+
+ {/* the chips share the row with short statuses and wrap below on
+ * mainnet-scale slot/epoch numbers instead of overflowing */}
+
+
+
+
+ {status}
+
+
+ {slot !== undefined && (
+
+
+ slot{' '}
+ {slot.toLocaleString()}
+
+ {epoch !== undefined && (
+
+ epoch{' '}
+
+ {epoch.toLocaleString()}
+
+
+ )}
+
+ )}
+
+
+
{children}
+
+ Click the block for full details
+
+
+ esc
+
+ to dismiss
+
+
+
+ );
+}
+
+export function WeightedNodeCard({
+ styleKey,
+ status,
+ blockRoot,
+ parentRoot,
+ slot,
+ epoch,
+ validity,
+ weight,
+ weightPercentage,
+}: {
+ styleKey: NodeStyleKey;
+ status: string;
+ blockRoot: string;
+ parentRoot?: string;
+ slot?: number;
+ epoch?: number;
+ validity: string;
+ weight: string;
+ weightPercentage: number;
+}) {
+ const formattedWeight = weight && weight !== '0' ? formatWeight(weight) : undefined;
+
+ return (
+
+
+
+ {formattedWeight ? (
+ {formattedWeight.display}
+ ) : (
+ '—'
+ )}
+
+
+
+
+
+
+
+
+
+ {parentRoot && {truncateHash(parentRoot)} }
+
+
+ );
+}
+
+export function AggregatedNodeCard({
+ styleKey,
+ status,
+ blockRoot,
+ slot,
+ epoch,
+ seen,
+ canonical,
+ finalizedCheckpoints,
+ justifiedCheckpoints,
+ orphans,
+ valid,
+ optimistic,
+ total,
+}: {
+ styleKey: NodeStyleKey;
+ status: string;
+ blockRoot: string;
+ slot?: number;
+ epoch?: number;
+ seen: number;
+ canonical: number;
+ finalizedCheckpoints: number;
+ justifiedCheckpoints: number;
+ orphans: number;
+ valid: number;
+ optimistic: number;
+ total: number;
+}) {
+ const notValid = seen - valid;
+
+ return (
+
+
+
+ Canonical
+ >
+ }
+ valueClassName="text-canonical"
+ >
+ {canonical}/{total}
+
+
+ Seen by
+ >
+ }
+ >
+ {seen}/{total}
+
+
+ {(finalizedCheckpoints > 0 ||
+ justifiedCheckpoints > 0 ||
+ notValid > 0 ||
+ optimistic > 0 ||
+ orphans > 0) && (
+
+ {finalizedCheckpoints > 0 && (
+
+ Finalized votes
+ >
+ }
+ >
+
+ {finalizedCheckpoints}/{total}
+
+
+ )}
+ {justifiedCheckpoints > 0 && (
+
+ Justified votes
+ >
+ }
+ >
+
+ {justifiedCheckpoints}/{total}
+
+
+ )}
+ {notValid > 0 && (
+
+
+
+ )}
+ {optimistic > 0 && (
+
+
+
+ )}
+ {orphans > 0 && (
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+export function TruncationCard({ slots }: { slots: number }) {
+ return (
+
+
+ Hidden range
+
+
+
+ {slots.toLocaleString()}
+ {' '}
+ earlier slots are collapsed to keep the graph readable. The{' '}
+ finalized and{' '}
+ justified checkpoints stay pinned just
+ left of this gap.
+
+
+ );
+}
diff --git a/web/src/components/TruncationMarker.tsx b/web/src/components/TruncationMarker.tsx
index 987d2dc..6cb4cc1 100644
--- a/web/src/components/TruncationMarker.tsx
+++ b/web/src/components/TruncationMarker.tsx
@@ -2,6 +2,9 @@ import { memo } from 'react';
import classNames from 'clsx';
+import HoverCard from '@components/HoverCard';
+import { TruncationCard } from '@components/NodeCards';
+
/* Bridges a hidden range of the graph when the tail is truncated: rendered
* in the same circular footprint as graph nodes, in the neutral "gap"
* language (dashed ring, no status color). */
@@ -19,29 +22,33 @@ function TruncationMarker({
className?: string;
}) {
return (
-
}>
+ {referenceProps => (
+
+
+
+ {slots.toLocaleString()}
+
+
+ slots hidden
+
+
+
)}
- style={{
- left: `${x}px`,
- top: `${y}px`,
- width: `${radius * 2}px`,
- height: `${radius * 2}px`,
- }}
- title={`${slots.toLocaleString()} earlier slots hidden`}
- >
-
-
- {slots.toLocaleString()}
-
-
- slots hidden
-
-
-
+
);
}
diff --git a/web/src/components/WeightedNode.tsx b/web/src/components/WeightedNode.tsx
index f84f4f1..2405e8c 100644
--- a/web/src/components/WeightedNode.tsx
+++ b/web/src/components/WeightedNode.tsx
@@ -2,57 +2,10 @@ import { memo } from 'react';
import classNames from 'clsx';
+import HoverCard from '@components/HoverCard';
+import { WeightedNodeCard } from '@components/NodeCards';
import ProgressCircle from '@components/ProgressCircle';
-
-/* Status visual recipe: dim status-tinted core, bright status rim (the
- * progress arc) and a soft glow so state reads at any zoom level. */
-const NODE_STYLES: Record<
- string,
- { fill: string; arc: string; track: string; label: string; glow: string }
-> = {
- canonical: {
- fill: 'bg-canonical-deep',
- arc: 'text-canonical-ring',
- track: 'text-canonical-edge',
- label: 'text-canonical',
- glow: 'shadow-[0_0_90px_-15px_var(--color-canonical-ring)]',
- },
- fork: {
- fill: 'bg-fork-deep',
- arc: 'text-fork-ring',
- track: 'text-fork-edge',
- label: 'text-fork',
- glow: 'shadow-[0_0_90px_-15px_var(--color-fork-ring)]',
- },
- finalized: {
- fill: 'bg-finalized-deep',
- arc: 'text-finalized-ring',
- track: 'text-finalized-edge',
- label: 'text-finalized',
- glow: 'shadow-[0_0_90px_-15px_var(--color-finalized-ring)]',
- },
- justified: {
- fill: 'bg-justified-deep',
- arc: 'text-justified-ring',
- track: 'text-justified-edge',
- label: 'text-justified',
- glow: 'shadow-[0_0_90px_-15px_var(--color-justified-ring)]',
- },
- invalid: {
- fill: 'bg-invalid-deep',
- arc: 'text-invalid-ring',
- track: 'text-invalid-edge',
- label: 'text-invalid',
- glow: 'shadow-[0_0_90px_-15px_var(--color-invalid-ring)]',
- },
- optimistic: {
- fill: 'bg-optimistic-deep',
- arc: 'text-optimistic-ring',
- track: 'text-optimistic-edge',
- label: 'text-optimistic',
- glow: 'shadow-[0_0_90px_-15px_var(--color-optimistic-ring)]',
- },
-};
+import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles';
function WeightedNode({
id,
@@ -64,6 +17,9 @@ function WeightedNode({
y,
radius,
weightPercentageComparedToHeaviestNeighbor = 100,
+ slot,
+ epoch,
+ parentRoot,
className,
onClick,
}: {
@@ -76,61 +32,96 @@ function WeightedNode({
y: number;
radius: number;
weightPercentageComparedToHeaviestNeighbor?: number;
+ slot?: number;
+ epoch?: number;
+ parentRoot?: string;
className?: string;
onClick?: (hash: string) => void;
}) {
- const styleKey = (() => {
+ const styleKey = ((): NodeStyleKey => {
if (!['valid', 'optimistic'].includes(validity.toLowerCase())) return 'invalid';
if (type === 'detached') return 'invalid';
if (type === 'canonical' && validity.toLowerCase() === 'optimistic') return 'optimistic';
return type;
})();
- const styles = NODE_STYLES[styleKey] ?? NODE_STYLES.canonical;
+ const styles = NODE_STYLES[styleKey];
const label =
type === 'finalized' || type === 'justified' || type === 'detached'
? type.toUpperCase()
: validity.toUpperCase();
+ const handleActivate = () => onClick?.(hash);
+
return (
-
+ }
+ referenceProps={{
+ onClick: handleActivate,
+ onKeyDown: event => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleActivate();
+ }
+ },
}}
- onClick={() => onClick?.(hash)}
>
-
-
- {label}
-
-
- {hash.substring(0, 6)}…{hash.substring(hash.length - 4)}
-
-
- {weight && weight !== '0' ? weight : ' '}
-
-
+ {referenceProps => (
+
+
+
+ {label}
+
+
+ {hash.substring(0, 6)}…{hash.substring(hash.length - 4)}
+
+
+ {weight && weight !== '0' ? weight : ' '}
+
+
+ )}
+
);
}
diff --git a/web/src/parts/Graph.stories.tsx b/web/src/parts/Graph.stories.tsx
index fab206a..51924c0 100644
--- a/web/src/parts/Graph.stories.tsx
+++ b/web/src/parts/Graph.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/tanstack-react';
-import { expect, userEvent, within } from 'storybook/test';
+import { expect, userEvent, waitFor, within } from 'storybook/test';
import type { ForkChoice, ForkChoiceNode, Frame, FrameMetadata } from '@api';
import type { ProcessedData } from '@app/types/graph';
@@ -480,6 +480,52 @@ export const AggregatedTruncatedLongTail: Story = {
},
};
+export const SingleNodeLegendExpanded: Story = {
+ ...singleNodeStory(graphScenarios.chaosForks, 'legend-expanded'),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole('button', { name: 'Show legend' }));
+ await expect(canvas.getByText('Competing branch with less weight')).toBeVisible();
+ await expect(
+ canvas.getByText('Ring: weight relative to the heaviest competing branch.'),
+ ).toBeVisible();
+ // weighted views get no aggregated counter rows
+ await expect(canvas.queryByText('Sources that have seen the block')).not.toBeInTheDocument();
+ },
+};
+
+export const AggregatedLegendExpanded: Story = {
+ ...aggregatedStory(graphScenarios.chaosForks, 'legend-expanded'),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole('button', { name: 'Show legend' }));
+ await expect(
+ canvas.getByText('Ring: share of sources reporting the block canonical.'),
+ ).toBeVisible();
+ await expect(canvas.getByText('Sources that have seen the block')).toBeVisible();
+ await userEvent.click(canvas.getByRole('button', { name: 'Hide legend' }));
+ await expect(canvas.getByRole('button', { name: 'Show legend' })).toBeVisible();
+ },
+};
+
+export const SingleNodeHoverCard: Story = {
+ ...singleNodeStory(graphScenarios.happyPath, 'hover-card'),
+ play: async ({ canvasElement }) => {
+ // wait for the graph to mount and zoom to the head
+ await new Promise(resolve => setTimeout(resolve, 400));
+ const head = canvasElement.querySelector('#head');
+ if (!head) throw new Error('head node not rendered');
+
+ await userEvent.hover(head);
+ // the card renders through a portal outside the canvas element
+ const body = within(document.body);
+ // the card fades in (opacity 0 → 1), so retry until the entrance
+ // transition lands
+ await waitFor(() => expect(body.getByText('Click the block for full details')).toBeVisible());
+ await expect(body.getByText('Branch share')).toBeVisible();
+ },
+};
+
export const AggregatedWithStuckNode: Story = {
...aggregatedStory(graphScenarios.happyPath, 'stuck-node'),
args: {
diff --git a/web/src/parts/Graph.tsx b/web/src/parts/Graph.tsx
index 4268792..8297a3e 100644
--- a/web/src/parts/Graph.tsx
+++ b/web/src/parts/Graph.tsx
@@ -19,10 +19,12 @@ import { getFrameSlotsBehind } from '@app/utils/graph';
import { truncateHash } from '@app/utils/strings';
import AggregatedNode from '@components/AggregatedNode';
import Edge from '@components/Edge';
+import Legend from '@components/Legend';
import Share from '@components/Share';
import TruncationMarker from '@components/TruncationMarker';
import WeightedNode from '@components/WeightedNode';
import useEthereum from '@contexts/ethereum';
+import useFocus from '@contexts/focus';
import useSelection from '@contexts/selection';
import useGraph from '@hooks/useGraph';
import useWindowSize from '@hooks/useWindowSize';
@@ -53,6 +55,7 @@ function Graph({
const { setFrameId, setAggregatedFrameIds, setFrameBlock, setAggregatedFramesBlock } =
useSelection();
const { slotsPerEpoch } = useEthereum();
+ const { byo, frameId } = useFocus();
const ref = useRef(null);
const { nodes, edges, type, slotEnd, slotStart, head, truncation } = useGraph({
data,
@@ -191,6 +194,7 @@ function Graph({
checkpoints,
orphaned,
validities,
+ slot,
} = node.attributes as AggregatedNodeAttributes;
const type: 'canonical' | 'fork' = canonical ? 'canonical' : 'fork';
@@ -223,6 +227,8 @@ function Graph({
total={data.length}
type={type}
hash={blockRoot}
+ slot={slot}
+ epoch={Math.floor(slot / slotsPerEpoch)}
radius={RADIUS}
onClick={() => {
setAggregatedFramesBlock({
@@ -243,6 +249,8 @@ function Graph({
weight,
weightPercentageComparedToHeaviestNeighbor,
blockRoot,
+ parentRoot,
+ slot,
} = node.attributes as WeightedNodeAttributes;
let type: 'canonical' | 'fork' | 'finalized' | 'justified' | 'detached' | 'invalid' =
@@ -260,6 +268,9 @@ function Graph({
type={type}
validity={validity}
hash={blockRoot}
+ parentRoot={parentRoot}
+ slot={slot}
+ epoch={Math.floor(slot / slotsPerEpoch)}
radius={RADIUS}
onClick={() => {
setFrameBlock({
@@ -355,6 +366,23 @@ function Graph({
)}
)}
+ {(type === 'weighted' || type === 'aggregated') && (
+
+ )}
= {
+ canonical: {
+ fill: 'bg-canonical-deep',
+ arc: 'text-canonical-ring',
+ track: 'text-canonical-edge',
+ label: 'text-canonical',
+ glow: 'shadow-[0_0_90px_-15px_var(--color-canonical-ring)]',
+ cardGlow: 'shadow-[0_0_44px_-10px_var(--color-canonical-ring)]',
+ },
+ fork: {
+ fill: 'bg-fork-deep',
+ arc: 'text-fork-ring',
+ track: 'text-fork-edge',
+ label: 'text-fork',
+ glow: 'shadow-[0_0_90px_-15px_var(--color-fork-ring)]',
+ cardGlow: 'shadow-[0_0_44px_-10px_var(--color-fork-ring)]',
+ },
+ finalized: {
+ fill: 'bg-finalized-deep',
+ arc: 'text-finalized-ring',
+ track: 'text-finalized-edge',
+ label: 'text-finalized',
+ glow: 'shadow-[0_0_90px_-15px_var(--color-finalized-ring)]',
+ cardGlow: 'shadow-[0_0_44px_-10px_var(--color-finalized-ring)]',
+ },
+ justified: {
+ fill: 'bg-justified-deep',
+ arc: 'text-justified-ring',
+ track: 'text-justified-edge',
+ label: 'text-justified',
+ glow: 'shadow-[0_0_90px_-15px_var(--color-justified-ring)]',
+ cardGlow: 'shadow-[0_0_44px_-10px_var(--color-justified-ring)]',
+ },
+ invalid: {
+ fill: 'bg-invalid-deep',
+ arc: 'text-invalid-ring',
+ track: 'text-invalid-edge',
+ label: 'text-invalid',
+ glow: 'shadow-[0_0_90px_-15px_var(--color-invalid-ring)]',
+ cardGlow: 'shadow-[0_0_44px_-10px_var(--color-invalid-ring)]',
+ },
+ optimistic: {
+ fill: 'bg-optimistic-deep',
+ arc: 'text-optimistic-ring',
+ track: 'text-optimistic-edge',
+ label: 'text-optimistic',
+ glow: 'shadow-[0_0_90px_-15px_var(--color-optimistic-ring)]',
+ cardGlow: 'shadow-[0_0_44px_-10px_var(--color-optimistic-ring)]',
+ },
+};