diff --git a/studio/src/components/layout/onboarding-layout.tsx b/studio/src/components/layout/onboarding-layout.tsx
index 4d7742232c..dcfb896da2 100644
--- a/studio/src/components/layout/onboarding-layout.tsx
+++ b/studio/src/components/layout/onboarding-layout.tsx
@@ -4,7 +4,15 @@ import { Stepper } from '../onboarding/stepper';
import { ONBOARDING_STEPS } from '../onboarding/onboarding-steps';
import { useOnboarding } from '@/hooks/use-onboarding';
-export const OnboardingLayout = ({ children, title }: { children?: React.ReactNode; title?: string }) => {
+export const OnboardingLayout = ({
+ children,
+ title,
+ bare = false,
+}: {
+ children?: React.ReactNode;
+ title?: string;
+ bare?: boolean;
+}) => {
const { currentStep } = useOnboarding();
return (
@@ -15,9 +23,13 @@ export const OnboardingLayout = ({ children, title }: { children?: React.ReactNo
-
- {children}
-
+ {bare ? (
+ {children}
+ ) : (
+
+ {children}
+
+ )}
);
diff --git a/studio/src/components/onboarding/federation-animation.tsx b/studio/src/components/onboarding/federation-animation.tsx
new file mode 100644
index 0000000000..7ad8d161c3
--- /dev/null
+++ b/studio/src/components/onboarding/federation-animation.tsx
@@ -0,0 +1,760 @@
+import { useEffect, useState } from 'react';
+import { motion } from 'framer-motion';
+import { Logo } from '../logo';
+
+// --- SVG animation layout ---
+const VB_W = 700;
+const VB_H = 300;
+const CARD_R = 8;
+const HEADER_H = 24;
+const FIELD_SIZE = 9;
+const FIELD_LINE_H = 13;
+const MONO_FONT = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace';
+const EASE_OUT: [number, number, number, number] = [0.25, 0.46, 0.45, 0.94];
+
+const PRODUCT_FIELDS = [
+ 'id: ID!',
+ 'title: String!',
+ 'description: String',
+ 'price: Price!',
+ 'category: ProductCategory',
+];
+const REVIEW_FIELDS = [
+ 'id: ID!',
+ 'author: String!',
+ 'email: String',
+ 'createdOn: Int!',
+ 'contents: String',
+ 'rating: Int!',
+];
+const COMPOSED_PRODUCT_FIELDS = [...PRODUCT_FIELDS, 'reviews: [Review]'];
+
+// Card sizes
+const SIDE_W = 190;
+const INNER_PAD = 8;
+const INNER_HEADER_H = 18;
+const INNER_R = 5;
+const INNER_FIELDS_H = (fields: string[]) => fields.length * FIELD_LINE_H + 8;
+const INNER_H = (fields: string[]) => INNER_HEADER_H + INNER_FIELDS_H(fields);
+const OUTER_H = (fields: string[]) => HEADER_H + INNER_PAD + INNER_H(fields) + INNER_PAD;
+const PRODUCTS_H = OUTER_H(PRODUCT_FIELDS);
+const REVIEWS_H = OUTER_H(REVIEW_FIELDS);
+const CENTER_W = 170;
+const SUPER_H = OUTER_H(COMPOSED_PRODUCT_FIELDS);
+
+// All cards share the same vertical center for straight horizontal lines
+const CENTER_Y = VB_H / 2;
+
+// Positions (products left, supergraph center, reviews right)
+const PRODUCTS_X = 15;
+const PRODUCTS_Y = CENTER_Y - PRODUCTS_H / 2;
+const REVIEWS_X = VB_W - SIDE_W - 15;
+const REVIEWS_Y = CENTER_Y - REVIEWS_H / 2;
+const SUPER_X = VB_W / 2 - CENTER_W / 2;
+const SUPER_Y = CENTER_Y - SUPER_H / 2;
+
+// Straight horizontal lines connecting cards
+const LEFT_LINE = `M ${PRODUCTS_X + SIDE_W} ${CENTER_Y} L ${SUPER_X} ${CENTER_Y}`;
+const RIGHT_LINE = `M ${SUPER_X + CENTER_W} ${CENTER_Y} L ${REVIEWS_X} ${CENTER_Y}`;
+
+// Expanded supergraph layout (with Review type added below Product)
+const REVIEW_INNER_H = INNER_H(REVIEW_FIELDS);
+const EXPANDED_SUPER_H = SUPER_H + INNER_PAD + REVIEW_INNER_H;
+const EXPANDED_SUPER_Y = Math.max(2, (VB_H - EXPANDED_SUPER_H) / 2);
+
+// Finished animation timing offsets (seconds after isFinished triggers)
+const FINISH_TEXT_DURATION = 0.6;
+const FINISH_DASH_DELAY = 0.3;
+const FINISH_DASH_DURATION = 0.6;
+const FINISH_BOX_DELAY = 0.7;
+const FINISH_BOX_DURATION = 0.5;
+const EXPAND_DELAY = 1.2;
+const EXPAND_DURATION = 0.5;
+
+// --- Helpers ---
+
+const ease = (duration: number, delay = 0) => ({ duration, delay, ease: EASE_OUT });
+
+// --- Shared SVG building blocks ---
+
+/** Generates staggered clip paths for a type label + per-field text wipe */
+const TypeBoxClipDefs = ({
+ prefix,
+ fields,
+ ix,
+ iy,
+ iw,
+ visible,
+ duration,
+ baseDelay,
+ stagger,
+}: {
+ prefix: string;
+ fields: string[];
+ ix: number;
+ iy: number;
+ iw: number;
+ visible: boolean;
+ duration: number;
+ baseDelay: number;
+ stagger: number;
+}) => (
+ <>
+
+
+
+ {fields.map((_, i) => (
+
+
+
+ ))}
+ >
+);
+
+/** Renders field text lines, each wrapped in its own clip path for staggered wipe */
+const ClippedFields = ({
+ clipPrefix,
+ fields,
+ ix,
+ iy,
+ textOpacity,
+ duration,
+ delay,
+}: {
+ clipPrefix: string;
+ fields: string[];
+ ix: number;
+ iy: number;
+ textOpacity: number;
+ duration: number;
+ delay: number;
+}) => (
+ <>
+ {fields.map((field, i) => (
+
+
+ {field}
+
+
+ ))}
+ >
+);
+
+// --- Node components ---
+
+const SubgraphNode = ({
+ x,
+ y,
+ w,
+ h,
+ name,
+ typeName,
+ fields,
+ delay,
+ isComposed,
+ isFinished,
+}: {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ name: string;
+ typeName: string;
+ fields: string[];
+ delay: number;
+ isComposed: boolean;
+ isFinished: boolean;
+}) => {
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const contentDelay = delay + 0.35;
+
+ // Inner type box position
+ const ix = x + INNER_PAD;
+ const iy = y + HEADER_H + INNER_PAD;
+ const iw = w - INNER_PAD * 2;
+ const ih = INNER_H(fields);
+ const clipId = `field-clip-${name}`;
+
+ // Clip timing switches between wipe-in (normal) and wipe-out (finished)
+ const clipDuration = isFinished ? FINISH_TEXT_DURATION * 0.6 : 0.4;
+ const clipBaseDelay = isFinished ? 0 : delay + 1.0;
+ const clipStagger = isFinished ? 0.06 : 0.08;
+
+ return (
+
+
+ {/* Subgraph title clip */}
+
+
+
+
+
+
+ {/* Outer subgraph card — shrinks to center when finished */}
+
+
+ {/* Subgraph name header — clipped for wipe */}
+
+
+ {name}
+
+
+ {/* Header separator — expands from center, collapses back on finish */}
+
+ {/* Inner type box */}
+
+ {/* Type name label — clipped for wipe */}
+
+
+ {typeName}
+
+
+ {/* Inner header separator */}
+
+
+
+
+ );
+};
+
+const SupergraphNode = ({
+ x,
+ y,
+ w,
+ h,
+ delay,
+ isComposed,
+ isFinished,
+}: {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ delay: number;
+ isComposed: boolean;
+ isFinished: boolean;
+}) => {
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const contentDelay = delay + 0.35;
+
+ // Inner Product type box
+ const ix = x + INNER_PAD;
+ const iy = y + HEADER_H + INNER_PAD;
+ const iw = w - INNER_PAD * 2;
+ const ih = INNER_H(COMPOSED_PRODUCT_FIELDS);
+
+ // Review type box (below Product, appears on finish)
+ const reviewIy = iy + ih + INNER_PAD;
+ const reviewIh = REVIEW_INNER_H;
+ const reviewCx = ix + iw / 2;
+ const reviewCy = reviewIy + reviewIh / 2;
+ const reviewContentDelay = EXPAND_DELAY + EXPAND_DURATION;
+
+ // Y shift for the whole group when expanding
+ const groupShiftY = EXPANDED_SUPER_Y - y;
+
+ return (
+
+ {/* Outer box — expands height on finish */}
+
+
+ {/* Logo + title — always visible */}
+
+
+
+
+
+
+ supergraph
+
+
+ {/* Header separator */}
+
+
+ {/* Inner Product type box */}
+
+
+ {/* Product type label */}
+
+ Product
+
+
+ {/* Inner header separator */}
+
+
+ {/* Shimmer gradient for skeleton bars */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Skeleton bars — show first, then crossfade to text when composed */}
+ {COMPOSED_PRODUCT_FIELDS.map((_, i) => {
+ const skeletonWidths = [40, 55, 70, 45, 80, 65];
+ const fieldY = iy + INNER_HEADER_H + 4 + i * FIELD_LINE_H + FIELD_SIZE / 2;
+ return (
+
+ );
+ })}
+
+ {/* Fields — appear after skeletons fade, reviews field highlighted */}
+ {COMPOSED_PRODUCT_FIELDS.map((field, i) => {
+ const isReviewsField = field.startsWith('reviews');
+ return (
+
+ {field}
+
+ );
+ })}
+
+ {/* ── Review type box (appears after subgraph boxes disappear) ── */}
+
+
+
+
+
+ {/* Review inner box — zooms in from center */}
+
+
+ {/* Review type label — clipped for wipe */}
+
+
+ Review
+
+
+
+ {/* Review inner header separator — expands from center */}
+
+
+
+
+ );
+};
+
+const AnimatedCurve = ({
+ d,
+ delay,
+ isComposed,
+ isFinished,
+ reverse,
+ clipId,
+}: {
+ d: string;
+ delay: number;
+ isComposed: boolean;
+ isFinished: boolean;
+ reverse?: boolean;
+ clipId: string;
+}) => (
+
+
+
+);
+
+export const FederationAnimation = ({ status }: { status: 'pending' | 'ok' | 'fail' | 'error' }) => {
+ const isComposed = status === 'ok';
+ const [isFinished, setIsFinished] = useState(false);
+
+ useEffect(() => {
+ if (!isComposed) {
+ setIsFinished(false);
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ setIsFinished(true);
+ }, 2500);
+
+ return () => clearTimeout(timer);
+ }, [isComposed]);
+
+ // Line clip regions — shrink from supergraph toward subgraph
+ const leftLineX1 = PRODUCTS_X + SIDE_W;
+ const leftLineX2 = SUPER_X;
+ const leftLineW = leftLineX2 - leftLineX1;
+
+ const rightLineX1 = SUPER_X + CENTER_W;
+ const rightLineX2 = REVIEWS_X;
+ const rightLineW = rightLineX2 - rightLineX1;
+
+ return (
+
+
+
+ );
+};
diff --git a/studio/src/components/onboarding/metrics-monitor.tsx b/studio/src/components/onboarding/metrics-monitor.tsx
new file mode 100644
index 0000000000..8a2416a7dd
--- /dev/null
+++ b/studio/src/components/onboarding/metrics-monitor.tsx
@@ -0,0 +1,314 @@
+import { useMemo } from 'react';
+import { motion } from 'framer-motion';
+import type { OnboardingStatus } from './status-icon';
+
+// --- Layout ---
+const VB_W = 700;
+const VB_H = 200;
+const MID_Y = VB_H / 2;
+const PAD_X = 20;
+const LINE_W = VB_W - PAD_X * 2;
+
+// --- Colors ---
+const MUTED = 'hsl(var(--muted-foreground))';
+const PRIMARY = 'hsl(var(--primary))';
+const DESTRUCTIVE = 'hsl(var(--destructive))';
+
+// --- Paths ---
+const NUM_POINTS = 12;
+
+function buildPoints(flat: boolean): [number, number][] {
+ const points: [number, number][] = [];
+ const startX = PAD_X;
+ const endX = PAD_X + LINE_W;
+ const cx = startX + LINE_W / 2;
+
+ if (flat) {
+ for (let i = 0; i < NUM_POINTS; i++) {
+ points.push([startX + (i * LINE_W) / (NUM_POINTS - 1), MID_Y]);
+ }
+ } else {
+ points.push([startX, MID_Y]);
+ points.push([cx - 60, MID_Y]);
+ points.push([cx - 30, MID_Y]);
+ points.push([cx - 18, MID_Y - 14]);
+ points.push([cx - 8, MID_Y]);
+ points.push([cx, MID_Y - 70]);
+ points.push([cx + 8, MID_Y + 30]);
+ points.push([cx + 18, MID_Y]);
+ points.push([cx + 32, MID_Y - 18]);
+ points.push([cx + 50, MID_Y]);
+ points.push([cx + 80, MID_Y]);
+ points.push([endX, MID_Y]);
+ }
+
+ return points;
+}
+
+function pointsToPath(points: [number, number][]): string {
+ const [first, ...rest] = points;
+ return `M ${first[0]} ${first[1]} ` + rest.map(([x, y]) => `L ${x} ${y}`).join(' ');
+}
+
+/** Normalized cumulative distance for each point (0→1) */
+function cumulativeProgress(points: [number, number][]): number[] {
+ let total = 0;
+ const dists = [0];
+ for (let i = 1; i < points.length; i++) {
+ const dx = points[i][0] - points[i - 1][0];
+ const dy = points[i][1] - points[i - 1][1];
+ total += Math.sqrt(dx * dx + dy * dy);
+ dists.push(total);
+ }
+ return dists.map((d) => d / total);
+}
+
+const FLAT_POINTS = buildPoints(true);
+const BEAT_POINTS = buildPoints(false);
+const FLAT_PATH = pointsToPath(FLAT_POINTS);
+const BEAT_PATH = pointsToPath(BEAT_POINTS);
+const FLAT_PROGRESS = cumulativeProgress(FLAT_POINTS);
+const BEAT_PROGRESS = cumulativeProgress(BEAT_POINTS);
+
+// --- Animation timing ---
+const SCAN_DURATION = 3;
+const ALIVE_DURATION = 2.2;
+// Beam trail length as fraction of path
+const SCAN_TRAIL = 0.35;
+const ALIVE_TRAIL = 0.45;
+
+type MonitorState = 'scanning' | 'alive' | 'failed';
+
+function statusToState(status: OnboardingStatus): MonitorState {
+ switch (status) {
+ case 'pending':
+ return 'scanning';
+ case 'ok':
+ return 'alive';
+ case 'fail':
+ case 'error':
+ return 'failed';
+ }
+}
+
+export const MetricsMonitor = ({ status }: { status: OnboardingStatus }) => {
+ const state = statusToState(status);
+
+ const isAlive = state === 'alive';
+ const isFailed = state === 'failed';
+ const shouldAnimate = !isFailed;
+
+ const points = isAlive ? BEAT_POINTS : FLAT_POINTS;
+ const path = isAlive ? BEAT_PATH : FLAT_PATH;
+ const progress = isAlive ? BEAT_PROGRESS : FLAT_PROGRESS;
+ const color = isAlive ? PRIMARY : isFailed ? DESTRUCTIVE : MUTED;
+
+ const trail = isAlive ? ALIVE_TRAIL : SCAN_TRAIL;
+ const drawDuration = isAlive ? ALIVE_DURATION : SCAN_DURATION;
+ const drawFraction = 1 / (1 + trail); // portion of cycle where dot moves (rest is tail exit)
+ const cycleDuration = drawDuration;
+
+ // Dot keyframes synced to stroke reveal
+ const dotKeyframes = useMemo(() => {
+ const xs: number[] = [];
+ const ys: number[] = [];
+ const times: number[] = [];
+
+ // Dot goes 0→1 in drawFraction, holds at end while tail exits
+ for (let i = 0; i < points.length; i++) {
+ xs.push(points[i][0]);
+ ys.push(points[i][1]);
+ times.push(progress[i] * drawFraction);
+ }
+ // Hold at end while tail exits
+ xs.push(points[points.length - 1][0]);
+ ys.push(points[points.length - 1][1]);
+ times.push(1);
+
+ // Gradient x1 (trailing edge, transparent) and x2 (leading edge, opaque)
+ const trailPx = trail * LINE_W;
+ const gx1: number[] = [];
+ const gx2: number[] = [];
+ for (let i = 0; i < xs.length; i++) {
+ gx2.push(xs[i]);
+ gx1.push(xs[i] - trailPx);
+ }
+
+ return { xs, ys, times, gx1, gx2 };
+ }, [points, progress, drawFraction, trail]);
+ const lineOpacity = isAlive ? 1 : 0.4;
+
+ return (
+
+
+
+ );
+};
diff --git a/studio/src/components/onboarding/onboarding-navigation.tsx b/studio/src/components/onboarding/onboarding-navigation.tsx
index 0f0ead164b..953d7dcf71 100644
--- a/studio/src/components/onboarding/onboarding-navigation.tsx
+++ b/studio/src/components/onboarding/onboarding-navigation.tsx
@@ -1,4 +1,5 @@
import { ArrowLeftIcon, ArrowRightIcon, InfoCircledIcon } from '@radix-ui/react-icons';
+import { cn } from '@/lib/utils';
import { Link } from '../ui/link';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -8,14 +9,16 @@ export const OnboardingNavigation = ({
forward,
forwardLabel = 'Next',
onSkip,
+ className,
}: {
backHref?: string;
forward: { href: string } | { onClick: () => void; isLoading?: boolean; disabled?: boolean };
forwardLabel?: string;
onSkip: () => void;
+ className?: string;
}) => {
return (
-
+