diff --git a/frontend/common/stores/default-flags.ts b/frontend/common/stores/default-flags.ts
index cb8cb6f03fe0..ddecbc5e4db9 100644
--- a/frontend/common/stores/default-flags.ts
+++ b/frontend/common/stores/default-flags.ts
@@ -37,6 +37,18 @@ const defaultFlags = {
'perEnvironment': false,
'title': 'Code References',
},
+ 'data-warehouse': {
+ 'description':
+ 'Connect Snowflake, BigQuery, or Databricks to compute experiment metrics from your event data. Required for experimentation.',
+ 'docs': '/organisation/:organisationId/warehouse',
+ 'external': true,
+ 'image': '/static/images/integrations/data-warehouse.svg',
+ 'organisation': true,
+ 'perEnvironment': false,
+ 'project': true,
+ 'tags': ['analytics'],
+ 'title': 'Data Warehouse',
+ },
'datadog': {
'description':
'Sends events to Datadog for when flags are created, updated and removed. Logs are tagged with the environment they came from e.g. production.',
diff --git a/frontend/common/utils/motion.ts b/frontend/common/utils/motion.ts
new file mode 100644
index 000000000000..78a2e138e2dd
--- /dev/null
+++ b/frontend/common/utils/motion.ts
@@ -0,0 +1,143 @@
+// =============================================================================
+// Motion Presets — Reusable animation variants for the motion library
+// Import these instead of defining raw values in components.
+//
+// Usage:
+// import { motion, AnimatePresence } from 'motion/react'
+// import { fadeIn, staggerContainer } from 'common/utils/motion'
+//
+//
+// ...
+//
+// =============================================================================
+
+import type { Variants } from 'motion/react'
+
+// -----------------------------------------------------------------------------
+// Fade
+// -----------------------------------------------------------------------------
+
+/** Fade in with optional delay. Default 250ms. */
+export const fadeIn = (delay = 0): Variants => ({
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { delay, duration: 0.25, ease: [0.2, 0, 0.38, 0.9] },
+ },
+})
+
+/** Fade out. Matches --easing-exit. */
+export const fadeOut: Variants = {
+ exit: {
+ opacity: 0,
+ transition: { duration: 0.2, ease: [0.2, 0, 1, 0.9] },
+ },
+ visible: { opacity: 1 },
+}
+
+// -----------------------------------------------------------------------------
+// Slide
+// -----------------------------------------------------------------------------
+
+/** Slide in from right. 320ms, matches --easing-entrance. */
+export const slideInRight: Variants = {
+ exit: {
+ opacity: 0,
+ transition: { duration: 0.2, ease: [0.2, 0, 1, 0.9] },
+ x: -40,
+ },
+ hidden: { opacity: 0, x: 40 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.32, ease: [0.0, 0, 0.38, 0.9] },
+ x: 0,
+ },
+}
+
+/** Slide in from below. Subtler, for inline content. */
+export const slideInUp: Variants = {
+ hidden: { opacity: 0, y: 12 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.25, ease: [0.0, 0, 0.38, 0.9] },
+ y: 0,
+ },
+}
+
+// -----------------------------------------------------------------------------
+// Stagger
+// -----------------------------------------------------------------------------
+
+/** Container that staggers its children's entrance. */
+export const staggerContainer = (staggerDelay = 0.1): Variants => ({
+ hidden: {},
+ visible: { transition: { staggerChildren: staggerDelay } },
+})
+
+/** Individual child item for use inside a stagger container. */
+export const staggerItem: Variants = {
+ hidden: { opacity: 0, y: 8 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.3, ease: [0.0, 0, 0.38, 0.9] },
+ y: 0,
+ },
+}
+
+// -----------------------------------------------------------------------------
+// Spring / Bounce
+// -----------------------------------------------------------------------------
+
+/** Spring bounce — success checkmarks, icons appearing. */
+export const springBounce: Variants = {
+ hidden: { opacity: 0, scale: 0 },
+ visible: {
+ opacity: 1,
+ scale: 1,
+ transition: { damping: 15, stiffness: 300, type: 'spring' },
+ },
+}
+
+// -----------------------------------------------------------------------------
+// Shake
+// -----------------------------------------------------------------------------
+
+/** Horizontal shake — error states, validation failures. */
+export const shakeX: Variants = {
+ idle: { x: 0 },
+ shake: {
+ transition: { duration: 0.4, ease: 'easeInOut' },
+ x: [0, -8, 8, -4, 4, 0],
+ },
+}
+
+// -----------------------------------------------------------------------------
+// Badge / Chip
+// -----------------------------------------------------------------------------
+
+/** Badge slides down from above with fade. 250ms with 400ms delay. */
+export const badgeEntrance: Variants = {
+ hidden: { opacity: 0, y: -12 },
+ visible: {
+ opacity: 1,
+ transition: { delay: 0.4, duration: 0.25, ease: [0.0, 0, 0.38, 0.9] },
+ y: 0,
+ },
+}
+
+// -----------------------------------------------------------------------------
+// Page Transition (for AnimatePresence)
+// -----------------------------------------------------------------------------
+
+/** Crossfade between pages/states. Use with AnimatePresence mode="wait". */
+export const pageCrossfade: Variants = {
+ exit: {
+ opacity: 0,
+ transition: { duration: 0.15, ease: [0.2, 0, 1, 0.9] },
+ },
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.25, ease: [0.2, 0, 0.38, 0.9] },
+ },
+}
diff --git a/frontend/documentation/MotionPresets.stories.tsx b/frontend/documentation/MotionPresets.stories.tsx
new file mode 100644
index 000000000000..a2ccfc6587c6
--- /dev/null
+++ b/frontend/documentation/MotionPresets.stories.tsx
@@ -0,0 +1,318 @@
+import React, { useState } from 'react'
+import type { Meta, StoryObj } from 'storybook'
+import { motion, AnimatePresence } from 'motion/react'
+import {
+ fadeIn,
+ slideInRight,
+ slideInUp,
+ staggerContainer,
+ staggerItem,
+ springBounce,
+ shakeX,
+ badgeEntrance,
+ pageCrossfade,
+} from 'common/utils/motion'
+
+const meta: Meta = {
+ tags: ['autodocs'],
+ title: 'Design System/Motion Presets',
+}
+export default meta
+
+type Story = StoryObj
+
+const DemoBox = ({
+ children,
+ style,
+}: {
+ children: React.ReactNode
+ style?: React.CSSProperties
+}) => (
+
+ {children}
+
+)
+
+export const FadeIn: Story = {
+ decorators: [
+ () => {
+ const [key, setKey] = useState(0)
+ return (
+
+ setKey((k) => k + 1)}
+ >
+ Replay
+
+
+ fadeIn()
+
+
+ {'variants={fadeIn()} initial="hidden" animate="visible"'}
+
+
+ )
+ },
+ ],
+}
+
+export const SlideInRight: Story = {
+ decorators: [
+ () => {
+ const [key, setKey] = useState(0)
+ return (
+
+ setKey((k) => k + 1)}
+ >
+ Replay
+
+
+ slideInRight — page transitions
+
+
+ )
+ },
+ ],
+}
+
+export const SlideInUp: Story = {
+ decorators: [
+ () => {
+ const [key, setKey] = useState(0)
+ return (
+
+ setKey((k) => k + 1)}
+ >
+ Replay
+
+
+ slideInUp — inline content
+
+
+ )
+ },
+ ],
+}
+
+export const StaggeredList: Story = {
+ decorators: [
+ () => {
+ const [key, setKey] = useState(0)
+ return (
+
+ setKey((k) => k + 1)}
+ >
+ Replay
+
+
+ {[
+ 'Resolving hostname...',
+ 'Establishing TLS...',
+ 'Authenticating...',
+ 'Verifying schema...',
+ ].map((text) => (
+
+ {text}
+
+ ))}
+
+
+ )
+ },
+ ],
+}
+
+export const SpringBounce: Story = {
+ decorators: [
+ () => {
+ const [key, setKey] = useState(0)
+ return (
+
+ setKey((k) => k + 1)}
+ >
+ Replay
+
+
+
+ ✓ Success
+
+
+
+ )
+ },
+ ],
+}
+
+export const ShakeError: Story = {
+ decorators: [
+ () => {
+ const [shaking, setShaking] = useState(false)
+ return (
+
+ {
+ setShaking(false)
+ requestAnimationFrame(() => setShaking(true))
+ }}
+ >
+ Trigger shake
+
+
+
+ Error: Invalid credentials
+
+
+
+ )
+ },
+ ],
+}
+
+export const BadgeEntrance: Story = {
+ decorators: [
+ () => {
+ const [key, setKey] = useState(0)
+ return (
+
+ setKey((k) => k + 1)}
+ >
+ Replay
+
+
+ Connected
+
+
+ )
+ },
+ ],
+}
+
+export const PageCrossfade: Story = {
+ decorators: [
+ () => {
+ const [page, setPage] = useState(0)
+ const pages = ['Page A', 'Page B', 'Page C']
+ return (
+
+
+ {pages.map((label, i) => (
+ setPage(i)}
+ >
+ {label}
+
+ ))}
+
+
+
+
+ {pages[page]} — crossfade transition
+
+
+
+
+ )
+ },
+ ],
+}
diff --git a/frontend/web/components/IntegrationList.tsx b/frontend/web/components/IntegrationList.tsx
index dc15a68e3164..87a029ba718e 100644
--- a/frontend/web/components/IntegrationList.tsx
+++ b/frontend/web/components/IntegrationList.tsx
@@ -85,7 +85,10 @@ const Integration: FC = (props) => {
window.addEventListener('message', (event) => {
if (
event.source === childWindow &&
- (event.data?.hasOwnProperty('installationId') ||
+ (Object.prototype.hasOwnProperty.call(
+ event.data ?? {},
+ 'installationId',
+ ) ||
event.data.installationId)
) {
setWindowInstallationId(event.data.installationId)
@@ -129,6 +132,75 @@ const Integration: FC = (props) => {
activeIntegrations.length
)
+ const isInternalPath = typeof docs === 'string' && docs.startsWith('/')
+ const resolvedHref = isInternalPath
+ ? docs.replace(
+ ':organisationId',
+ String(AccountStore.getOrganisation()?.id ?? ''),
+ )
+ : docs
+
+ const renderAddCta = () => {
+ if (external && !isExternalInstallation) {
+ return (
+
+ Add Integration
+
+ )
+ }
+ if (
+ external &&
+ isExternalInstallation &&
+ (windowInstallationId || props.githubMeta.hasIntegrationWithGithub)
+ ) {
+ return (
+
+ Manage Integration
+
+ )
+ }
+ if (
+ external &&
+ !props.githubMeta.hasIntegrationWithGithub &&
+ isExternalInstallation
+ ) {
+ return (
+
+ Add Integration
+
+ )
+ }
+ return (
+
+ Add Integration
+
+ )
+ }
+
return (
@@ -168,57 +240,7 @@ const Integration: FC
= (props) => {
Delete Integration
))}
- {showAdd && (
- <>
- {external && !isExternalInstallation ? (
-
- Add Integration
-
- ) : external &&
- isExternalInstallation &&
- (windowInstallationId ||
- props.githubMeta.hasIntegrationWithGithub) ? (
-
- Manage Integration
-
- ) : external &&
- !props.githubMeta.hasIntegrationWithGithub &&
- isExternalInstallation ? (
-
- Add Integration
-
- ) : (
-
- Add Integration
-
- )}
- >
- )}
+ {showAdd && renderAddCta()}
@@ -267,6 +289,7 @@ const IntegrationList: FC
= (props) => {
useEffect(() => {
fetch()
fetchGithubIntegration()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchGithubIntegration = () => {
diff --git a/frontend/web/components/charts/LineChart.tsx b/frontend/web/components/charts/LineChart.tsx
new file mode 100644
index 000000000000..219f25ae729c
--- /dev/null
+++ b/frontend/web/components/charts/LineChart.tsx
@@ -0,0 +1,122 @@
+import React, { FC } from 'react'
+import {
+ CartesianGrid,
+ Legend,
+ Line,
+ LineChart as RawLineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts'
+import { colorTextSecondary } from 'common/theme/tokens'
+
+export type LineChartDataPoint = {
+ day: string | number
+} & Record
+
+type LineChartProps = {
+ data: LineChartDataPoint[]
+ series: string[]
+ colorMap: Record
+ /**
+ * Optional dataKey → display name map, used for the legend and tooltip when
+ * dataKeys differ from the label users should see.
+ */
+ seriesLabels?: Record
+ showLegend?: boolean
+ xAxisLabel?: string
+ yAxisFormatter?: (value: number) => string
+ tooltipValueFormatter?: (value: number) => string
+ tooltipLabelFormatter?: (label: string | number) => string
+ height?: number
+}
+
+const LineChart: FC = ({
+ colorMap,
+ data,
+ height = 320,
+ series,
+ seriesLabels,
+ showLegend = true,
+ tooltipLabelFormatter,
+ tooltipValueFormatter,
+ xAxisLabel,
+ yAxisFormatter,
+}) => {
+ return (
+
+
+
+
+
+ tooltipValueFormatter(value)
+ : undefined
+ }
+ labelFormatter={tooltipLabelFormatter}
+ contentStyle={{
+ background: 'var(--color-surface-default)',
+ border: '1px solid var(--color-border-default)',
+ borderRadius: 'var(--radius-md)',
+ fontSize: 12,
+ }}
+ />
+ {showLegend && (
+
+ seriesLabels?.[String(value)] ?? String(value)
+ }
+ />
+ )}
+ {series.map((label, index) => (
+
+ ))}
+
+
+ )
+}
+
+LineChart.displayName = 'LineChart'
+export default LineChart
diff --git a/frontend/web/components/experiments-v2/CreateExperimentPage.scss b/frontend/web/components/experiments-v2/CreateExperimentPage.scss
new file mode 100644
index 000000000000..0ac91952e5ab
--- /dev/null
+++ b/frontend/web/components/experiments-v2/CreateExperimentPage.scss
@@ -0,0 +1,12 @@
+.create-experiment-page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 32px 48px;
+ min-height: 100vh;
+
+ &__divider {
+ height: 1px;
+ background: var(--color-border-default);
+ }
+}
diff --git a/frontend/web/components/experiments-v2/CreateExperimentPage.tsx b/frontend/web/components/experiments-v2/CreateExperimentPage.tsx
new file mode 100644
index 000000000000..3439fba1ce75
--- /dev/null
+++ b/frontend/web/components/experiments-v2/CreateExperimentPage.tsx
@@ -0,0 +1,330 @@
+import React, { FC, useCallback, useMemo, useState } from 'react'
+import { useHistory, useParams } from 'react-router-dom'
+import WizardLayout from './wizard/WizardLayout'
+import WizardSidebar from './wizard/WizardSidebar'
+import WizardHeader from './wizard/WizardHeader'
+import WizardNavButtons from './wizard/WizardNavButtons'
+import ExperimentDetailsStep from './steps/ExperimentDetailsStep'
+import SelectMetricsStep from './steps/SelectMetricsStep'
+import FlagVariationsStep from './steps/FlagVariationsStep'
+import AudienceStep from './steps/AudienceStep'
+import ReviewLaunchStep from './steps/ReviewLaunchStep'
+import { buildExperimentArms, splitEvenly } from './steps/AudienceStep'
+import {
+ EXPERIMENT_WIZARD_STEPS,
+ ExperimentWizardState,
+ Metric,
+ MetricRole,
+ MIN_VARIATIONS_FOR_EXPERIMENT,
+ MOCK_FLAGS,
+ MOCK_METRICS,
+ MOCK_SEGMENTS,
+ MOCK_VARIATIONS,
+ Variation,
+} from './types'
+import './CreateExperimentPage.scss'
+
+const INITIAL_FLAG = MOCK_FLAGS[0]
+const INITIAL_ARMS = buildExperimentArms(
+ INITIAL_FLAG.controlValue,
+ INITIAL_FLAG.variations,
+)
+
+const toISODate = (d: Date) => d.toISOString().slice(0, 10)
+const DEFAULT_START = new Date()
+const DEFAULT_END = new Date(DEFAULT_START)
+DEFAULT_END.setDate(DEFAULT_END.getDate() + 14)
+
+const INITIAL_STATE: ExperimentWizardState = {
+ audience: {
+ samplePercentage: 100,
+ segmentId: 'seg-3',
+ weights: splitEvenly(INITIAL_ARMS.map((a) => a.id)),
+ },
+ controlValue: INITIAL_FLAG.controlValue,
+ currentStep: 0,
+ details: {
+ endDate: toISODate(DEFAULT_END),
+ hypothesis:
+ 'Redesigning the checkout button with a clearer CTA will increase conversion rates by at least 15% within 30 days',
+ name: 'Checkout Button Redesign',
+ startDate: toISODate(DEFAULT_START),
+ },
+ featureFlagId: INITIAL_FLAG.value,
+ metrics: [
+ MOCK_METRICS[0],
+ { ...MOCK_METRICS[1], role: 'secondary' },
+ { ...MOCK_METRICS[2], role: 'guardrail' },
+ ],
+ variations: MOCK_VARIATIONS,
+}
+
+const TOTAL_STEPS = EXPERIMENT_WIZARD_STEPS.length
+
+const CreateExperimentPage: FC = () => {
+ const history = useHistory()
+ const { environmentId, projectId } = useParams<{
+ projectId?: string
+ environmentId?: string
+ }>()
+ const experimentsUrl =
+ projectId && environmentId
+ ? `/project/${projectId}/environment/${environmentId}/experiments`
+ : '/experiments'
+ const [state, setState] = useState(INITIAL_STATE)
+
+ const goToStep = useCallback((step: number) => {
+ setState((prev) => ({ ...prev, currentStep: step }))
+ }, [])
+
+ const handleBack = useCallback(() => {
+ goToStep(Math.max(0, state.currentStep - 1))
+ }, [state.currentStep, goToStep])
+
+ const handleLaunch = useCallback(() => {
+ const segment = MOCK_SEGMENTS.find(
+ (s) => s.value === state.audience.segmentId,
+ )
+ const flag = MOCK_FLAGS.find((f) => f.value === state.featureFlagId)
+ const audienceLabel = segment?.label ?? 'all users in the environment'
+ openConfirm({
+ body: (
+
+ This will start serving variations of{' '}
+ {flag?.label ?? 'this flag'} to{' '}
+ {state.audience.samplePercentage}% of{' '}
+ {audienceLabel} . You can pause or stop the experiment
+ at any time.
+
+ ),
+ onYes: () => {
+ toast(`Experiment "${state.details.name}" launched`)
+ history.push(experimentsUrl)
+ },
+ title: 'Launch experiment?',
+ yesText: 'Launch',
+ })
+ }, [state, history, experimentsUrl])
+
+ const handleContinue = useCallback(() => {
+ if (state.currentStep < TOTAL_STEPS - 1) {
+ goToStep(state.currentStep + 1)
+ } else {
+ handleLaunch()
+ }
+ }, [state.currentStep, goToStep, handleLaunch])
+
+ const handleCancel = useCallback(() => {
+ history.goBack()
+ }, [history])
+
+ const handleToggleMetric = useCallback((metric: Metric) => {
+ setState((prev) => {
+ const exists = prev.metrics.find((m) => m.id === metric.id)
+ if (exists) {
+ return {
+ ...prev,
+ metrics: prev.metrics.filter((m) => m.id !== metric.id),
+ }
+ }
+ const hasPrimary = prev.metrics.some((m) => m.role === 'primary')
+ const role: MetricRole = hasPrimary ? 'secondary' : 'primary'
+ return { ...prev, metrics: [...prev.metrics, { ...metric, role }] }
+ })
+ }, [])
+
+ const handleSetMetricRole = useCallback(
+ (metricId: string, role: MetricRole) => {
+ setState((prev) => ({
+ ...prev,
+ metrics: prev.metrics.map((m) =>
+ m.id === metricId ? { ...m, role } : m,
+ ),
+ }))
+ },
+ [],
+ )
+
+ const isCurrentStepValid = useMemo(() => {
+ if (state.currentStep === 0) {
+ return (
+ state.details.name.trim().length > 0 &&
+ state.details.hypothesis.trim().length > 0
+ )
+ }
+ if (state.currentStep === 1) {
+ return (
+ !!state.featureFlagId &&
+ state.variations.length >= MIN_VARIATIONS_FOR_EXPERIMENT
+ )
+ }
+ if (state.currentStep === 3) {
+ const sum = (state.audience.weights ?? []).reduce(
+ (s, w) => s + w.weight,
+ 0,
+ )
+ return sum === 100 && state.audience.samplePercentage > 0
+ }
+ return true
+ }, [
+ state.currentStep,
+ state.details.name,
+ state.details.hypothesis,
+ state.featureFlagId,
+ state.variations.length,
+ state.audience.samplePercentage,
+ state.audience.weights,
+ ])
+
+ const stepsWithSummary = EXPERIMENT_WIZARD_STEPS.map((step, i) => {
+ if (i >= state.currentStep) return step
+
+ let completeSummary: string | undefined
+ switch (i) {
+ case 0:
+ completeSummary = state.details.name || undefined
+ break
+ case 1: {
+ const armCount = state.variations.length + 1
+ completeSummary = `${armCount} arm${armCount === 1 ? '' : 's'}`
+ break
+ }
+ case 2: {
+ const pCount = state.metrics.filter((m) => m.role === 'primary').length
+ const sCount = state.metrics.filter(
+ (m) => m.role === 'secondary',
+ ).length
+ const gCount = state.metrics.filter(
+ (m) => m.role === 'guardrail',
+ ).length
+ const parts = [
+ `${pCount} primary`,
+ `${sCount} secondary`,
+ gCount > 0 ? `${gCount} guardrail` : null,
+ ].filter(Boolean)
+ completeSummary = parts.join(' · ')
+ break
+ }
+ case 3: {
+ const splitParts = (state.audience.weights ?? [])
+ .filter((w) => w.weight > 0)
+ .map((w) => `${w.weight}%`)
+ const split = splitParts.length > 0 ? splitParts.join('/') : null
+ const audienceLabel = state.audience.segmentId
+ ? MOCK_SEGMENTS.find((s) => s.value === state.audience.segmentId)
+ ?.label ?? 'segment'
+ : 'All users'
+ const summaryParts = [
+ audienceLabel,
+ `${state.audience.samplePercentage}%`,
+ split,
+ ].filter(Boolean)
+ completeSummary = summaryParts.join(' · ')
+ break
+ }
+ default:
+ break
+ }
+ return { ...step, completeSummary }
+ })
+
+ const renderStepContent = () => {
+ switch (state.currentStep) {
+ case 0:
+ return (
+ setState((prev) => ({ ...prev, details }))}
+ />
+ )
+ case 1:
+ return (
+
+ setState((prev) => ({ ...prev, featureFlagId: flagId }))
+ }
+ onControlValueChange={(controlValue) =>
+ setState((prev) => ({ ...prev, controlValue }))
+ }
+ onVariationsChange={(variations: Variation[]) =>
+ setState((prev) => {
+ const arms = buildExperimentArms(prev.controlValue, variations)
+ return {
+ ...prev,
+ audience: {
+ ...prev.audience,
+ weights: splitEvenly(arms.map((a) => a.id)),
+ },
+ variations,
+ }
+ })
+ }
+ />
+ )
+ case 2:
+ return (
+
+ )
+ case 3: {
+ const flag =
+ MOCK_FLAGS.find((f) => f.value === state.featureFlagId) ?? null
+ return (
+ setState((prev) => ({ ...prev, audience }))}
+ />
+ )
+ }
+ case 4:
+ return
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ }
+ >
+ {renderStepContent()}
+
+
+
+
+ )
+}
+
+CreateExperimentPage.displayName = 'CreateExperimentPage'
+export default CreateExperimentPage
diff --git a/frontend/web/components/experiments-v2/EXPERIMENTATION_DESIGN.md b/frontend/web/components/experiments-v2/EXPERIMENTATION_DESIGN.md
new file mode 100644
index 000000000000..7ed02b1292aa
--- /dev/null
+++ b/frontend/web/components/experiments-v2/EXPERIMENTATION_DESIGN.md
@@ -0,0 +1,440 @@
+# Experiments v2 — Design Proposal
+
+Presentation notes for the Experiments v2 prototype. Explains the design
+choices, how they map onto Flagsmith's data model, and the references that
+back each decision.
+
+## Context
+
+Flagsmith already ships multi-variant flags, segment overrides, and identity
+targeting — everything you need to run an experiment. What's missing is the
+**workflow**: a guided way to set up a controlled experiment, pick metrics,
+split traffic, and read results. Experiments v2 fills that gap as a
+first-class product concept on top of the existing primitives.
+
+## Scope
+
+This prototype is **frontend-only**. The wizard is fully interactive with
+mock data; no backend endpoints, no real metrics pipeline, no statistical
+engine. See `NOTES.md` for the deferred items that require backend work
+(sample-size calculator, metric baseline data, segment traffic tracking).
+
+The prototype covers:
+
+- Create Experiment wizard (5 steps)
+- Experiments list + Metrics library (tabs)
+- Experiment results page (comparison table + trend chart)
+
+It does **not** cover:
+
+- Post-launch experiment state management (pause/stop/restart)
+- Real sample-size or power calculations
+- Historical metric data or baselines
+- Randomisation-unit override (we assume identity-based bucketing)
+
+## Vocabulary
+
+The audience of an experiment has three layers, each with its own term.
+Segments stay a Flagsmith primitive — they just stop pulling double duty.
+
+| Term we use | What it means | Notes |
+|---|---|---|
+| **Audience** | The whole step / concept: who's eligible, how many of them are in, and how they split | LD calls this "Audience allocation" |
+| **Targeting** (optional **Segment** filter) | Eligibility filter. Users matching the segment are eligible; everyone else is excluded. Empty = all identities in the environment | Segments stay a normal Flagsmith primitive — they just don't define the experiment audience by themselves |
+| **Sample size** | Percentage of eligible users actually sampled into the experiment. The rest see the flag's environment default | LD: "Percent of users in this experiment" |
+| **Variation split** | Among the sampled users, the per-arm weight distribution. Control takes a slot. Sum to 100 | LD: "Variation split" |
+| **Variation** / **Control value** | Variation = an entry in the flag's variation list; control value = a field on the flag itself | Existing Flagsmith concepts |
+| **Guardrail metric** | A safety-check role separate from primary/secondary | See [Metric roles](#metric-roles) |
+
+Internally we use `arm` as jargon for "control or variation" when doing
+weight math, but user-facing copy stays as "variation".
+
+### Why segments aren't the audience
+
+The earlier prototype used a segment as the experiment's audience: pick
+one segment, set per-arm weights, done. That conflated **targeting**
+(property-based filter) with **audience allocation** (random sample of
+eligible users). Two concrete failure modes:
+
+1. **No segment? No experiment.** Want to A/B test on the whole user
+ base? You'd need a fake "All users" segment. Segments are
+ property-based, not "everyone".
+2. **No way to ramp.** Want to start at 5% and ramp up? You'd need a
+ property-based segment that captures 5% of users — segments aren't
+ random samplers.
+
+Splitting the concept into three layers (Targeting / Sample size /
+Variation split) fixes both: targeting is *optional*, sampling is *its
+own knob*, and split is *just the variation distribution*.
+
+## Data model
+
+An experiment is a **first-class record** that holds the audience
+configuration plus the variation weights. The audience is layered —
+optional segment filter, sample percentage, and per-arm weights — and
+each layer has a backend equivalent.
+
+```
+Experiment {
+ // Audience
+ targeting_segment: | null // null = all identities in the environment
+ sample_percentage: 0–100 // fraction of eligible users sampled in
+ weights: [
+ { value: , weight: X },
+ { value: , weight: Y },
+ { value: , weight: Z },
+ ] // X + Y + Z = 100
+
+ // Forward-designed for later iterations
+ outside_variation: // served to eligible-but-not-sampled users
+ mutual_exclusion_group: null // layer ID; null means no exclusion
+}
+```
+
+**Implications:**
+
+1. **Targeting is optional.** No segment selected = the eligible pool is
+ every identity in the environment. This is the default.
+2. **Sample size is independent of variation split.** Setting sample_size
+ to 10% means 10% of eligible identities are in the experiment, and the
+ variation weights split *those* users. The other 90% see the flag's
+ environment default.
+3. **Weights must sum to 100% across the sampled pool**, including
+ control. Control is still a field on the flag, not a variation —
+ the wizard treats it as one of the weight slots.
+4. **One experiment per (segment, flag) pair.** If the chosen segment
+ already has an override on the flag, the wizard shows a conflict
+ banner. Existing overrides on other segments are unaffected; priority
+ ordering decides who wins for users matching multiple segments.
+5. **Assignment requires two hash dimensions.** Hash 1 (`experiment_id +
+ identity`) decides "in or out of the experiment". Hash 2
+ (`experiment_id + identity` with a salt) decides which variation. This
+ keeps "% in experiment" stable when you change the variation split,
+ and vice versa.
+
+### Forward-designed fields (`outside_variation`, `mutual_exclusion_group`)
+
+The data model includes two more fields the v1 UI doesn't surface:
+
+| Field | v1 default | When it lights up |
+|---|---|---|
+| `outside_variation` | flag's environment default | Let teams pick which variation eligible-but-not-sampled users see, decoupled from the flag's default. Useful when the experiment changes the flag default or when ramping without rolling back the default. |
+| `mutual_exclusion_group` | `null` | Group experiments into a "layer" so a user is in at most one experiment per layer. Prevents traffic overlap when several experiments target the same audience. |
+
+Both stay inert until the UI exposes them. Including them up front
+avoids a future schema migration on a live data model.
+
+## Wizard flow
+
+Five steps, in this order:
+
+1. **Experiment Details** — name, hypothesis (required), start and end dates
+2. **Flag & Variations** — pick a multi-variant flag, view its variations (read-only)
+3. **Select Metrics** — primary, secondary, guardrail roles
+4. **Audience** — optional segment filter, sample size, variation split
+5. **Review & Launch** — full summary with edit shortcuts
+
+### Why Details first, not Flag first
+
+Writing the hypothesis before picking the flag forces users to think about
+*what they're trying to learn* before getting lost in config. This is the
+LaunchDarkly pattern and Kohavi's recommendation in *Trustworthy Online
+Controlled Experiments*.
+
+**Counter-argument:** the wizard's shape depends on the flag (variation
+count → arm weights), so Flag-first could feel more natural. We went
+Details-first for the intent-setting benefit; the downside is users
+occasionally edit back when they realise their flag choice changes the arm
+count.
+
+### Why variations are read-only
+
+Variations live on the flag, not the experiment. Letting users add or
+remove variations from inside the wizard would mutate the flag config
+silently. Instead we show the existing variations and block progression if
+the flag has fewer than one (the minimum for a two-arm experiment: control
++ one variation).
+
+## Metric roles
+
+We support three roles:
+
+| Role | Purpose | Stat question |
+|---|---|---|
+| **Primary** | The metric the experiment's verdict is based on | "Did the change improve this?" |
+| **Secondary** | Other effects we want to observe | "What else moved?" |
+| **Guardrail** | Metrics we must not regress, even if primary wins | "Did anything I care about break?" |
+
+### Why three roles, not two
+
+**Guardrails are a distinct axis** — not a ranking of importance, but a
+different question entirely. A guardrail can be a metric that has nothing
+to do with what the experiment is testing (e.g. page latency on a checkout
+button redesign). Making it a separate role **forces the user to think
+about safety**, not just optimisation.
+
+This is the convention in rigorous experimentation platforms:
+- **Statsig** — dedicated *Guardrail Metrics* tier
+- **Eppo** — first-class guardrail role
+- **GrowthBook** — *Guardrail metrics* separate from *Goal metrics*
+- **Optimizely** — monitoring/impact metrics serve the same role
+- **Airbnb, Netflix, Microsoft ExP** — internal platforms all surface
+ guardrails (sometimes called "tripwires" or "health metrics")
+
+LaunchDarkly rolls this into secondary metrics today. We're taking the more
+rigorous convention — backed by the industry textbook.
+
+### Soft warning on multi-primary
+
+When a user picks more than one primary metric, we surface a warning about
+the multiple-comparisons problem without blocking. Best practice is one
+primary; the warning explains why and invites the user to demote the rest.
+
+## Data collection — how metrics get measured
+
+The prototype mocks all metric values, but the architecture question is the
+one the demo audience will ask first. Three production-ready approaches,
+and what Flagsmith is set up to do.
+
+### Option A — SDK-based event ingestion (what LaunchDarkly does)
+
+Customer calls `flagsmith.track('checkout_completed', { value: 49.99,
+identity: userId })` from their app. Flagsmith SDKs ship events to a
+collection endpoint. Flagsmith's backend joins each event with the flag
+evaluation the user saw (attribution), aggregates per arm, surfaces the
+roll-up on the results page.
+
+- **Pros**: simplest onboarding for customers — no external tooling needed.
+- **Cons**: biggest backend lift — we'd need an event ingestion service,
+ time-series storage, attribution pipeline, and aggregation. Doable on top
+ of the existing Edge/Task Processor infrastructure.
+
+### Option B — Warehouse-native (what this prototype shows)
+
+Events already live in the customer's data warehouse (Snowflake, BigQuery,
+Databricks). Customer defines a metric by pointing Flagsmith at a warehouse
+table + event name (or value column) + optional filter. Flagsmith pushes
+flag-evaluation records into the same warehouse and runs aggregation
+queries to compute per-arm results.
+
+This is the path shown in the current mocks:
+- `web/components/warehouse/WarehousePage.tsx` — connection config
+ (Snowflake live; BigQuery/Databricks marked "Coming Soon")
+- `ConnectionStats` mock shows `flagEvaluations24h: 1.28M` +
+ `customEvents24h: 84K` flowing into the connected warehouse
+- Metric Library's `CreateMetricForm` includes a **Data Source** section
+ mapping each metric to `{warehouse, table, eventName | valueColumn,
+ filter}`
+- Each mock metric in `MOCK_METRICS` carries a `source` that renders
+ inline in the library (e.g. *"EVENTS · checkout_completed"*)
+
+**Pros**: cheapest for Flagsmith to build — the customer's warehouse does
+the heavy aggregation. Customer owns their data.
+
+**Cons**: requires the customer to have a warehouse. Not viable for
+smaller customers running SaaS without a data pipeline.
+
+**Competitors that ship this model**: Eppo (primary), GrowthBook
+Enterprise, Statsig (warehouse mode).
+
+### Option C — Analytics tool integration
+
+Metric definition lives in the customer's existing analytics tool (Segment,
+Amplitude, Mixpanel, Heap, Google Analytics). Flagsmith pulls aggregates
+via those tools' APIs and attributes by variation.
+
+- **Pros**: leverages what customers already use; fastest to value for
+ teams that have analytics but not a warehouse.
+- **Cons**: every tool's API is different; rate limits, latency, and
+ attribution edge cases multiply per integration.
+
+### What the prototype demonstrates
+
+This build-out shows **Option B (warehouse-native)** end-to-end as mock UI:
+
+1. `Data Warehouse` page under the organisation nav — confirm the
+ connection is live, see stats on event throughput
+2. `Metrics` library — each metric shows its source mapping
+ (`EVENTS · checkout_completed`, `TRANSACTIONS · amount_usd WHERE
+ status = 'complete'`, etc.)
+3. Create / Edit Metric — a **Data Source** section maps the metric to the
+ warehouse table + event/column + optional filter
+4. Experiment Results — per-arm numbers that (in production) would come
+ from aggregation queries against the warehouse
+
+### Recommended sequencing
+
+If this were going from prototype to production:
+
+1. **First**: ship warehouse-native (Option B) — lowest infra risk, serves
+ the experimentation-mature segment. The mocks in this repo are a
+ working starting point.
+2. **Then**: add SDK `track()` (Option A) so customers without a warehouse
+ can still run experiments. Reuses the existing SDK analytics pipeline.
+3. **Later**: selective analytics integrations (Option C) — Segment first
+ because it's the universal fanout layer for the others.
+
+Captured separately in `NOTES.md`: the **sample-size calculator** and
+**guardrail semantic-flip** both depend on the collection pipeline above
+being real — they're the reason this section exists as design intent, not
+just a demo touch.
+
+## Traffic split mechanics
+
+Weights are edited via number inputs (0–100 each). When a user changes one
+arm, the remaining weight is **redistributed proportionally** across the
+others so the sum always lands on exactly 100.
+
+- With 2 arms: trivial — the other arm gets `100 − new`.
+- With 3+ arms: delta is distributed proportionally based on current
+ weights, preserving the ratio between the unchanged arms.
+- Integer rounding: a largest-fractional-part pass ensures the sum is
+ always exactly 100, never 99 or 101.
+
+An arm at 0 stays at 0 during rebalancing (a zero-weight arm is "excluded
+by intent"). Users hit **Split evenly** to reset.
+
+## Safety & conflict detection
+
+- **Conflict banner** — when the selected segment already has an override
+ on the target flag, we surface a red banner directly under the segment
+ dropdown with resolution paths ("pick a different segment, or remove the
+ existing override on the Features page first").
+- **Guardrail metrics** — see above.
+- **Launch confirmation modal** — no accidental launches. The modal names
+ both the flag and segment explicitly.
+
+## Presentation walkthrough
+
+Suggested narrative order for the live demo. Every step below has a
+matching URL or shortcut so you don't have to click through the full
+flow when you want to land on a specific state.
+
+### 1 — Frame the problem (30s)
+
+Open `/project/:id/environment/:id/experiments` (empty-ish or with mock
+list). Motivate: Flagsmith has flags, segments, and identity targeting
+— everything to *run* an experiment, but no workflow to *set one up*.
+This branch adds that.
+
+### 2 — Data collection story (45s)
+
+Open **Organisation Integrations** → show the **Data Warehouse** card
+alongside Jira, Grafana, etc. Click **Add Integration**.
+
+Use `?demo=1&state=empty` on the warehouse URL for the first landing.
+Then cycle the switcher to narrate:
+
+- **Empty** → "no warehouse connected"
+- **Configuring** → the connection form, call out the two-button Test
+ / Connect pattern (Test = verify only; Connect = commit)
+- **Testing** → the 4-step Connecting animation
+- **Connected** → live-stats card with 24h flag evaluations + custom
+ events flowing through, connection details grid
+- **Error** → the auth-failed state with resolution paths
+
+Anchor: *"this is where experiment metrics come from — the warehouse
+does the aggregation, Flagsmith reads the result."* Pair with the
+three-approach framing from the Data collection section above.
+
+### 3 — Metrics library (45s)
+
+Back to the experiments nav → **Metrics**. Point out:
+
+- Each metric shows its warehouse source inline (`EVENTS ·
+ checkout_completed`, `TRANSACTIONS · amount_usd WHERE status =
+ 'complete'`, etc.) — the direct handoff from the warehouse story
+- Role system (primary / secondary / guardrail) — highlight this is
+ where Flagsmith differs from LaunchDarkly
+- Create Metric → walk through measurement type cards, direction
+ picker, data source section
+
+### 4 — Create Experiment wizard (90s)
+
+From the Experiments list, **Create Experiment**. Walk the 5 steps:
+
+1. **Details** — name, hypothesis (required, with a real-example
+ placeholder), default dates (+14 days from today)
+2. **Flag** — multi-variant flag picker; read-only variations table.
+ Optional detour: pick `homepage_hero_redesign` (0 variations) to
+ show the blocking banner
+3. **Metrics** — pre-selected Conversion Rate (primary) + Revenue per
+ User (secondary) + Page Load Time (guardrail). Click one to show
+ the three-role segmented control. Add a second primary to surface
+ the soft multi-primary warning
+4. **Audience** — three sub-blocks: Targeting (leave empty for "all
+ users", or pick Premium Tier on flag-1 to trigger the inline
+ conflict banner), Sample size (toggle 100 → 10 to show the dial
+ independently from the variation split), Variation split (play
+ with the auto-balancing weights — change one, watch the others
+ rebalance proportionally)
+5. **Review & Launch** — full summary with per-section edit links.
+ Click **Launch** → confirmation modal → toast + redirect to list
+
+### 5 — Results page (45s)
+
+From the list, click a running experiment. Narrate top-down:
+
+- Stat cards + recommendation callout
+- Metrics comparison table — call out the primary-row emphasis,
+ guardrail badge, zero-centred lift bars
+- Scroll to the trend chart — metric selector + control vs treatment
+ line chart
+
+Anchor: *"this is the loop closed — metrics defined in the library,
+data streaming through the warehouse, results computed per arm."*
+
+### 6 — What's deferred (30s)
+
+Short list from the *What we're deferring* section. Keep it honest —
+this is a workflow prototype, not a production experimentation
+platform. Backend work is the next sprint.
+
+## What we're deferring
+
+Captured in `NOTES.md`:
+
+- **Sample-size / duration calculator** — needs backend infra (cached
+ segment counts + metric baseline data) and a new MDE input
+- **Demo URL param for empty metrics** — for showcasing the empty state
+ without clearing the mock catalog
+
+Not yet captured but known gaps:
+
+- Post-launch experiment management (pause, stop, re-launch)
+- Flag collision detection (flag already has a running experiment)
+- Randomisation unit selector (currently assumes identity-based)
+- Save-as-draft
+- Test-identity preview ("would user X be in this experiment?")
+
+## References
+
+### Academic / industry
+
+- Kohavi, R., Tang, D., & Xu, Y. (2020). *Trustworthy Online Controlled
+ Experiments: A Practical Guide to A/B Testing.* Cambridge University
+ Press. **Chapter 7 covers guardrail metrics explicitly.** This is the
+ definitive industry textbook.
+- Kohavi, R. et al. (2019). *Top Challenges from the first Practical Online
+ Controlled Experiments Summit.* SIGKDD. Documents shared practices
+ across Airbnb, Amazon, Google, LinkedIn, Microsoft, Netflix.
+- Kohavi, R. & Thomke, S. (2017). *The Surprising Power of Online
+ Experiments.* Harvard Business Review.
+
+### Vendor documentation
+
+- **Statsig** — Guardrail Metrics as a dedicated metric tier
+- **Eppo** — Guardrail metrics as a first-class role
+- **GrowthBook** — open source; guardrails separate from goal metrics
+- **Optimizely** — impact / monitoring metrics serve the same role
+- **LaunchDarkly** — health metrics are informal, not a distinct UI role
+ today (gap Flagsmith can close)
+
+### Engineering blogs
+
+- Netflix Tech Blog — multiple posts on the XP experimentation platform,
+ including guardrails for streaming quality and error rates
+- Airbnb Engineering — ERF (Experimentation Reporting Framework) papers
+ mention guardrail/tripwire metrics
+- Booking.com — experimentation culture posts calling out guardrails for
+ revenue metrics
diff --git a/frontend/web/components/experiments-v2/ExperimentResultsPage.scss b/frontend/web/components/experiments-v2/ExperimentResultsPage.scss
new file mode 100644
index 000000000000..e43bab52a698
--- /dev/null
+++ b/frontend/web/components/experiments-v2/ExperimentResultsPage.scss
@@ -0,0 +1,187 @@
+.experiment-results-page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 32px 32px 24px;
+
+ &__header {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ &__title-row {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ &__title {
+ font-size: var(--font-h4-size);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+ margin: 0;
+ }
+
+ &__action-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__subtitle {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__actions {
+ display: flex;
+ gap: 8px;
+
+ .btn svg {
+ margin-right: 4px;
+ }
+ }
+
+ // Timeline progress
+ &__timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 16px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ }
+
+ &__timeline-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__timeline-label {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__timeline-value {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__timeline-track {
+ height: 6px;
+ background: var(--color-surface-muted);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ }
+
+ &__timeline-fill {
+ height: 100%;
+ background: var(--color-surface-action);
+ border-radius: var(--radius-full);
+ transition: width var(--duration-slow) var(--easing-standard);
+ }
+
+ // Stat cards
+ &__cards {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ }
+
+ // Recommendation callout
+ &__recommendation {
+ display: flex;
+ gap: 12px;
+ padding: 16px 20px;
+ background: var(--color-surface-success);
+ border: 1px solid var(--color-border-success);
+ border-radius: var(--radius-lg);
+
+ svg {
+ color: var(--color-icon-success);
+ flex-shrink: 0;
+ margin-top: 2px;
+ }
+ }
+
+ &__recommendation-content {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__recommendation-title {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-success);
+ }
+
+ &__recommendation-text {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+ line-height: 1.5;
+ }
+
+ // Table section
+ &__table-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ &__section-title {
+ font-size: var(--font-h6-size);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+ margin: 0;
+ }
+
+ &__subsection-title {
+ margin: 16px 0 0 0;
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ // Config summary
+ &__config {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ &__config-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 12px;
+ }
+
+ &__config-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 16px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ }
+
+ &__config-label {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__config-value {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+
+ &--mono {
+ font-family: var(--font-family);
+ }
+ }
+}
diff --git a/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx b/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx
new file mode 100644
index 000000000000..513c944df8c1
--- /dev/null
+++ b/frontend/web/components/experiments-v2/ExperimentResultsPage.tsx
@@ -0,0 +1,148 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import Icon from 'components/icons/Icon'
+import StatusBadge from './shared/StatusBadge'
+import ExperimentStatCard from './shared/ExperimentStatCard'
+import MetricsComparisonTable from './shared/MetricsComparisonTable'
+import MetricsTrendChart from './shared/MetricsTrendChart'
+import { MOCK_EXPERIMENT_RESULT } from './types'
+import './ExperimentResultsPage.scss'
+
+const ExperimentResultsPage: FC = () => {
+ const result = MOCK_EXPERIMENT_RESULT
+ const progressPercent = Math.round(
+ (result.daysCurrent / result.daysTotal) * 100,
+ )
+
+ return (
+
+
+
+
{result.name}
+
+
+
+
+
+ Primary metric: {result.primaryMetric} · Last updated{' '}
+ {result.lastUpdated}
+
+
+
+ Stop Experiment
+
+
+
+
+ {/* Timeline progress */}
+
+
+
+ Experiment Timeline
+
+
+ Day {result.daysCurrent} of {result.daysTotal}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Recommendation callout */}
+
+
+
+
+ Recommendation
+
+
+ Treatment B is outperforming Control with 94.2% probability of being
+ the best variant. Consider rolling out Treatment B to 100% of
+ traffic after the experiment concludes on day {result.daysTotal}.
+
+
+
+
+
+
+ Metrics Comparison
+
+
+
+
+ Trend over time
+
+
+
+
+ {/* Experiment config summary */}
+
+
+ Experiment Configuration
+
+
+
+ Type
+
+ A/B Test
+
+
+
+
+ Feature Flag
+
+
+ checkout_button_redesign
+
+
+
+
+ Segments
+
+
+ All Users
+
+
+
+
+ Traffic
+
+
+ 50% / 50%
+
+
+
+
+
+ )
+}
+
+ExperimentResultsPage.displayName = 'ExperimentResultsPage'
+export default ExperimentResultsPage
diff --git a/frontend/web/components/experiments-v2/ExperimentsListPage.scss b/frontend/web/components/experiments-v2/ExperimentsListPage.scss
new file mode 100644
index 000000000000..4207473482a1
--- /dev/null
+++ b/frontend/web/components/experiments-v2/ExperimentsListPage.scss
@@ -0,0 +1,107 @@
+.experiments-list-page {
+ &__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+
+ h3 {
+ margin: 0;
+ }
+ }
+
+ &__controls {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+ gap: 16px;
+ }
+
+ &__tabs {
+ display: flex;
+ gap: 4px;
+ background: var(--color-surface-subtle);
+ border-radius: var(--radius-md);
+ padding: 3px;
+ }
+
+ &__tab {
+ padding: 6px 14px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--color-text-secondary);
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: all var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ color: var(--color-text-default);
+ }
+
+ &--active {
+ background: var(--color-surface-default);
+ color: var(--color-text-default);
+ box-shadow: var(--shadow-sm);
+ }
+ }
+
+ &__search {
+ min-width: 240px;
+ }
+
+ &__table {
+ width: 100%;
+ border-collapse: separate;
+ border-spacing: 0;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+
+ th {
+ padding: 10px 16px;
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ background: var(--color-surface-subtle);
+ text-align: left;
+ border-bottom: 1px solid var(--color-border-default);
+ }
+
+ td {
+ padding: 14px 16px;
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+ border-bottom: 1px solid var(--color-border-default);
+ }
+
+ tbody tr:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ &__row {
+ cursor: pointer;
+ transition: background var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ background: var(--color-surface-hover);
+ }
+ }
+
+ &__flag-name {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ background: var(--color-surface-subtle);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ }
+
+ &__footer {
+ padding: 12px 16px;
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+}
diff --git a/frontend/web/components/experiments-v2/ExperimentsListPage.tsx b/frontend/web/components/experiments-v2/ExperimentsListPage.tsx
new file mode 100644
index 000000000000..a56e01040c6d
--- /dev/null
+++ b/frontend/web/components/experiments-v2/ExperimentsListPage.tsx
@@ -0,0 +1,183 @@
+import React, { FC, useMemo, useState } from 'react'
+import { useHistory } from 'react-router-dom'
+import Button from 'components/base/forms/Button'
+import Input from 'components/base/forms/Input'
+import EmptyState from 'components/EmptyState'
+import StatusBadge from './shared/StatusBadge'
+import { ExperimentListItem, ExperimentStatus, MOCK_EXPERIMENTS } from './types'
+import './ExperimentsListPage.scss'
+
+type FilterTab = 'all' | ExperimentStatus
+
+const TABS: { label: string; value: FilterTab }[] = [
+ { label: 'All', value: 'all' },
+ { label: 'Running', value: 'running' },
+ { label: 'Draft', value: 'draft' },
+ { label: 'Completed', value: 'completed' },
+]
+
+const ExperimentsListPage: FC = () => {
+ const history = useHistory()
+ const [activeTab, setActiveTab] = useState('all')
+ const [search, setSearch] = useState('')
+
+ const filtered = useMemo(() => {
+ let items = MOCK_EXPERIMENTS
+ if (activeTab !== 'all') {
+ items = items.filter((e) => e.status === activeTab)
+ }
+ if (search) {
+ const lower = search.toLowerCase()
+ items = items.filter(
+ (e) =>
+ e.name.toLowerCase().includes(lower) ||
+ e.linkedFlag.toLowerCase().includes(lower),
+ )
+ }
+ return items
+ }, [activeTab, search])
+
+ const counts = useMemo(() => {
+ const c = { completed: 0, draft: 0, paused: 0, running: 0 }
+ MOCK_EXPERIMENTS.forEach((e) => {
+ c[e.status]++
+ })
+ return c
+ }, [])
+
+ const handleRowClick = (experiment: ExperimentListItem) => {
+ // Navigate to results page (mock: uses current route context)
+ history.push(
+ `${document.location.pathname.replace('/experiments', '')}/experiments/${
+ experiment.id
+ }`,
+ )
+ }
+
+ return (
+
+
+
Experiments
+
+ history.push(
+ `${document.location.pathname.replace(
+ '/experiments',
+ '',
+ )}/experiments/create`,
+ )
+ }
+ >
+ Create Experiment
+
+
+
+
+
+ {TABS.map((tab) => (
+ setActiveTab(tab.value)}
+ >
+ {tab.label}
+
+ ))}
+
+
+ ) =>
+ setSearch(e.target.value)
+ }
+ placeholder='Search experiments...'
+ search
+ size='small'
+ />
+
+
+
+ {filtered.length > 0 ? (
+ <>
+
+
+
+ Experiment Name
+ Linked Flag
+ Status
+ Variations
+ Primary Metric
+ Last Updated
+
+
+
+ {filtered.map((exp) => (
+ handleRowClick(exp)}
+ >
+ {exp.name}
+
+
+ {exp.linkedFlag}
+
+
+
+
+
+ {exp.variations}
+ {exp.primaryMetric}
+ {exp.lastUpdated}
+
+ ))}
+
+
+
+ {MOCK_EXPERIMENTS.length} experiments · {counts.running}{' '}
+ running · {counts.draft} draft · {counts.completed}{' '}
+ completed
+
+ >
+ ) : (
+
+ history.push(
+ `${document.location.pathname.replace(
+ '/experiments',
+ '',
+ )}/experiments/create`,
+ )
+ }
+ >
+ Create Experiment
+
+ ) : undefined
+ }
+ />
+ )}
+
+ )
+}
+
+export default ExperimentsListPage
diff --git a/frontend/web/components/experiments-v2/MetricsLibraryPage.scss b/frontend/web/components/experiments-v2/MetricsLibraryPage.scss
new file mode 100644
index 000000000000..c387174a8710
--- /dev/null
+++ b/frontend/web/components/experiments-v2/MetricsLibraryPage.scss
@@ -0,0 +1,210 @@
+.metrics-library-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ &__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ }
+
+ &__title {
+ margin: 0 0 4px 0;
+ }
+
+ &__subtitle {
+ margin: 0;
+ max-width: 640px;
+ }
+
+ &__source-banner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-subtle);
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+
+ svg,
+ .icon {
+ color: var(--color-icon-action);
+ flex-shrink: 0;
+ }
+
+ code {
+ font-family: var(--font-family);
+ background: transparent;
+ padding: 0;
+ color: var(--color-text-default);
+ }
+
+ strong {
+ color: var(--color-text-default);
+ }
+ }
+
+ &__source-banner-link {
+ margin-left: auto;
+ color: var(--color-text-action);
+ font-weight: var(--font-weight-medium);
+ text-decoration: none;
+ white-space: nowrap;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ &__toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ &__search {
+ flex: 1;
+
+ .input-container {
+ width: 100%;
+ }
+ }
+
+ &__table {
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+ }
+
+ &__head {
+ display: flex;
+ background: var(--color-surface-emphasis);
+ }
+
+ &__th {
+ padding: 12px 16px;
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+
+ &--name {
+ flex: 2;
+ }
+
+ &--desc {
+ flex: 3;
+ }
+
+ &--usage {
+ flex: 1;
+ }
+
+ &--updated {
+ flex: 1;
+ }
+
+ &--actions {
+ flex: 0 0 96px;
+ }
+ }
+
+ &__row {
+ display: flex;
+ border-top: 1px solid var(--color-border-default);
+ transition: background var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ background: var(--color-surface-hover);
+ }
+ }
+
+ &__td {
+ padding: 14px 16px;
+ font-size: var(--font-body-size);
+ color: var(--color-text-default);
+ display: flex;
+ align-items: center;
+
+ &--name {
+ flex: 2;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ &--desc {
+ flex: 3;
+ color: var(--color-text-secondary);
+ }
+
+ &--usage {
+ flex: 1;
+ color: var(--color-text-secondary);
+ }
+
+ &--updated {
+ flex: 1;
+ color: var(--color-text-secondary);
+ font-size: var(--font-caption-size);
+ }
+
+ &--actions {
+ flex: 0 0 96px;
+ gap: 4px;
+ }
+ }
+
+ &__metric-name {
+ font-weight: var(--font-weight-semibold);
+ }
+
+ &__source {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+
+ svg,
+ .icon {
+ color: var(--color-icon-secondary);
+ flex-shrink: 0;
+ }
+
+ &--missing {
+ color: var(--color-text-warning, #b36b00);
+ }
+ }
+
+ &__source-code {
+ font-family: var(--font-family);
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ background: transparent;
+ padding: 0;
+ }
+
+ &__action-btn {
+ background: transparent;
+ border: none;
+ padding: 6px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ color: var(--color-icon-default);
+ transition: background var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ background: var(--color-surface-hover);
+ color: var(--color-icon-emphasis);
+ }
+
+ &--danger:hover {
+ color: var(--color-icon-danger);
+ }
+ }
+}
diff --git a/frontend/web/components/experiments-v2/MetricsLibraryPage.tsx b/frontend/web/components/experiments-v2/MetricsLibraryPage.tsx
new file mode 100644
index 000000000000..3ed6a03447d1
--- /dev/null
+++ b/frontend/web/components/experiments-v2/MetricsLibraryPage.tsx
@@ -0,0 +1,248 @@
+import React, { FC, useMemo, useState } from 'react'
+import AccountStore from 'common/stores/account-store'
+import Button from 'components/base/forms/Button'
+import EmptyState from 'components/EmptyState'
+import Icon from 'components/icons/Icon'
+import Input from 'components/base/forms/Input'
+import CreateMetricForm from './shared/CreateMetricForm'
+import { Metric, MOCK_METRICS } from './types'
+import './MetricsLibraryPage.scss'
+
+type Mode =
+ | { kind: 'list' }
+ | { kind: 'create' }
+ | { kind: 'edit'; metric: Metric }
+
+const MetricsLibraryPage: FC = () => {
+ const [metrics, setMetrics] = useState(MOCK_METRICS)
+ const [search, setSearch] = useState('')
+ const [mode, setMode] = useState({ kind: 'list' })
+ const organisationId = AccountStore.getOrganisation()?.id
+ const warehouseUrl = organisationId
+ ? `/organisation/${organisationId}/warehouse`
+ : undefined
+
+ const filtered = useMemo(() => {
+ if (!search) return metrics
+ const lower = search.toLowerCase()
+ return metrics.filter(
+ (m) =>
+ m.name.toLowerCase().includes(lower) ||
+ m.description.toLowerCase().includes(lower),
+ )
+ }, [metrics, search])
+
+ const handleCreateOrEditSubmit = (metric: Metric) => {
+ setMetrics((prev) => {
+ const existing = prev.findIndex((m) => m.id === metric.id)
+ if (existing >= 0) {
+ const next = [...prev]
+ next[existing] = metric
+ return next
+ }
+ return [metric, ...prev]
+ })
+ setMode({ kind: 'list' })
+ }
+
+ const handleDelete = (metric: Metric) => {
+ openConfirm({
+ body: (
+
+ Delete {metric.name} ?{' '}
+ {metric.usageCount > 0 ? (
+ <>
+ This metric is used by {metric.usageCount} {' '}
+ experiment
+ {metric.usageCount === 1 ? '' : 's'} — deleting it will remove it
+ from those experiments.
+ >
+ ) : (
+ 'This metric is not used by any experiments.'
+ )}
+
+ ),
+ destructive: true,
+ onYes: () => setMetrics((prev) => prev.filter((m) => m.id !== metric.id)),
+ title: 'Delete metric?',
+ yesText: 'Delete',
+ })
+ }
+
+ if (mode.kind === 'create' || mode.kind === 'edit') {
+ return (
+
+ setMode({ kind: 'list' })}
+ onSubmit={handleCreateOrEditSubmit}
+ />
+
+ )
+ }
+
+ let listContent: React.ReactNode
+ if (metrics.length === 0) {
+ listContent = (
+ setMode({ kind: 'create' })}
+ >
+ Create Metric
+
+ }
+ />
+ )
+ } else if (filtered.length === 0) {
+ listContent = (
+
+ )
+ } else {
+ listContent = (
+
+
+
+ Name
+
+
+ Description
+
+
+ Used in
+
+
+ Last updated
+
+
+
+
+ {filtered.map((metric) => (
+
+
+
+ {metric.name}
+
+ {metric.source ? (
+
+
+
+ {metric.source.table}
+ {metric.source.eventName
+ ? ` · ${metric.source.eventName}`
+ : ''}
+ {metric.source.valueColumn
+ ? ` · ${metric.source.valueColumn}`
+ : ''}
+ {metric.source.filter ? ` · ${metric.source.filter}` : ''}
+
+
+ ) : (
+
+ Source not configured
+
+ )}
+
+
+ {metric.description}
+
+
+ {metric.usageCount === 0
+ ? 'Not in use'
+ : `${metric.usageCount} experiment${
+ metric.usageCount === 1 ? '' : 's'
+ }`}
+
+
+ {metric.lastUpdated}
+
+
+ setMode({ kind: 'edit', metric })}
+ type='button'
+ aria-label={`Edit ${metric.name}`}
+ >
+
+
+ handleDelete(metric)}
+ type='button'
+ aria-label={`Delete ${metric.name}`}
+ >
+
+
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
+
Metrics
+
+ Metrics track the outcomes you measure across experiments. Primary
+ and secondary metrics drive experiment verdicts; guardrails flag
+ regressions.
+
+
+
+
+
+
+
+ Metrics are computed from your Snowflake warehouse (
+ FLAGSMITH_PROD.PUBLIC).{' '}
+
+ {warehouseUrl && (
+
+ Manage connection →
+
+ )}
+
+
+
+
+ ) =>
+ setSearch(e.target.value)
+ }
+ placeholder='Search metrics...'
+ search
+ />
+
+
setMode({ kind: 'create' })}
+ >
+ Create Metric
+
+
+
+ {listContent}
+
+ )
+}
+
+MetricsLibraryPage.displayName = 'MetricsLibraryPage'
+export default MetricsLibraryPage
diff --git a/frontend/web/components/experiments-v2/NOTES.md b/frontend/web/components/experiments-v2/NOTES.md
new file mode 100644
index 000000000000..ae52bf16bd16
--- /dev/null
+++ b/frontend/web/components/experiments-v2/NOTES.md
@@ -0,0 +1,182 @@
+# Experiments v2 — Prototype Notes
+
+Prototype-only decisions and deferred items that aren't worth putting in
+production code but should survive a context handover.
+
+## Demo shortcuts
+
+Query-param affordances baked into the prototype for presenting and
+screenshotting. None of them are visible in normal usage — no query param,
+no demo UI.
+
+### Data Warehouse state shortcuts
+
+Defined in `web/components/warehouse/WarehousePage.tsx`.
+
+- **`?state=`** — jumps directly to a specific connection state
+ without clicking through the full flow. Values:
+ - `empty` (default)
+ - `configuring` — the form with Snowflake / BigQuery / Databricks picker
+ and the two-button Test / Connect footer
+ - `testing` — the 4-step "Connecting…" animation
+ - `connected` — the live-stats + connection-details page
+ - `error` — the auth-failed state with retry / edit actions
+- **`?demo=1`** — shows an inline state-switcher pill bar above the
+ content. Click any pill to switch state live. The bar has a
+ warning-tinted dashed border so it reads unmistakably as demo-only.
+- Combine them: `?demo=1&state=connected` opens on the connected state
+ with the switcher ready for live clicks.
+
+### Experiments empty-metrics shortcut (proposed, not built)
+
+See *Deferred: demo URL param for empty metrics* below.
+
+## Deferred: demo URL param for empty metrics
+
+**Context:** the Select Metrics step (`steps/SelectMetricsStep.tsx`) has an
+empty state for when the metric catalog is empty — useful for showcasing the
+"create your first metric" flow. It's not reachable by default because
+`MOCK_METRICS` always has 5 entries, and the wizard's `INITIAL_STATE`
+pre-selects three of them.
+
+**Decision:** gate empty-state demo behind a URL param rather than flipping
+the default mock. Keeps the happy-path demo intact.
+
+**Proposed param:** `?demoEmptyMetrics=1` on the create-experiment route.
+When present:
+
+- Pass `availableMetrics={[]}` to `SelectMetricsStep` (overrides the
+ `MOCK_METRICS` default).
+- Skip the three pre-selected metrics in `CreateExperimentPage`'s
+ `INITIAL_STATE` so `state.metrics` starts empty too.
+
+**Parse via:** `Utils.fromParam()` in `CreateExperimentPage` (same pattern
+used elsewhere in the codebase — e.g. `web/main.js`, `IntegrationList.tsx`).
+
+**Why not built yet:** low priority for the current prototype milestone;
+designers can still see the empty state via the Storybook story or by
+temporarily clearing `MOCK_METRICS`.
+
+## Design-system note: 1220px rail vs. full-width surfaces
+
+**Context:** every existing Flagsmith page wraps its content in
+``, which caps at `max-width:
+1220px` (see `web/styles/project/_layout.scss:10`). That cap is a legacy
+choice sized for ~1280px monitors; on a modern 2560-wide display the
+content floats in the centre with ~670px of dead space on each side.
+
+The new Warehouse page is deliberately **not** wrapped in `app-container
+container` — it spans the viewport (with sensible padding on
+`.warehouse-page`). The stats row, connection-details card, and form all
+breathe naturally.
+
+**This is intentional**, not a consistency bug:
+
+- The 1220px rail is design debt from the existing app, not a target.
+- Modern SaaS (Linear, Vercel, Stripe, Statsig) lets the outer container
+ breathe and constrains inner blocks per-content-type — forms/prose cap
+ at 720–800px for readability, tables and dashboards span wider.
+- The warehouse page is showing what "right" looks like; the rest of the
+ app needs to follow, not the reverse.
+
+**Talking point for the demo:** "Notice how this new surface uses the
+full viewport? That's intentional — it's part of the wider design-system
+refresh we want to do. The 1220px cap on every other page is on our
+audit list (#6606)."
+
+**Follow-up if accepted as a direction:**
+
+- Audit which pages benefit from full-width (usage, analytics, tables,
+ dashboards) vs. narrow (settings forms, prose).
+- Replace the blanket `app-container` cap with per-page width policies, or
+ introduce a `.app-container--wide` / `.app-container--narrow` modifier.
+- Track as part of the design-system audit (#6606), not a one-off warehouse
+ change.
+
+## Deferred: "Connected" indicator on the Integrations list card
+
+**Context:** the Data Warehouse entry in `integration_data` uses `external:
+true` with a same-origin link (`/organisation/:organisationId/warehouse`),
+so it renders via `IntegrationList.tsx` lines 144–155 — a plain `
` CTA
+with no active-integration row beneath. The card looks identical whether or
+not a warehouse is connected.
+
+Drawer-based integrations (Datadog, Segment, Slack) show a connected row
+via `IntegrationList.tsx` lines 316–354, but that path requires a real
+backend endpoint (`GET /organisations/:id/integrations/:key/`) which the
+warehouse prototype doesn't have. Other `external: true` integrations
+(Jira) have the same gap — it's not specific to us.
+
+**Implication for the demo:** returning users can't tell from the
+Integrations list whether they've already set up a warehouse. They have to
+click through to the Warehouse page to see connection status. Acceptable
+for the prototype; the presentation flow clicks through anyway.
+
+**Two follow-up options if this ships:**
+
+1. **Mock-only:** add `connected?: boolean` to the integration JSON and
+ render a green "Connected" pill in the card header. Cheapest, no backend.
+2. **Real:** add `GET /organisations/:id/integrations/data-warehouse/`
+ returning the stored config, wire `IntegrationList` to fetch it like the
+ drawer-based ones. Matches existing infra and gives us a proper active
+ row (with Edit / Delete controls inline on the card).
+
+## Deferred: sample-size / duration calculator
+
+**Context:** the Audience step (`steps/AudienceStep.tsx`) currently shows
+a soft one-liner under the variation split: *"Sampling N% (~M users) into
+the experiment, split as Control ~X, Treatment ~Y. Actual
+time-to-significance depends on traffic, baseline rate, and the lift
+you're trying to detect."*
+
+The earlier prototype showed a fabricated *"Est. 14 days to significance"*
+— removed because it had no statistical basis and would set false
+expectations.
+
+**What a real implementation needs:**
+
+1. **Segment traffic estimate** — not just "how many identities match this
+ segment" but "how many of them hit this flag per day". Options:
+ - Cached daily segment counts (nightly cron, stale ≤24h) — cheapest MVP.
+ - Historical impressions from a new table tracking flag-evaluation
+ events per segment — what LaunchDarkly does. More infra but better
+ signal.
+2. **Metric baseline rate (`p`)** — e.g. "conversion is 7% today". Either
+ the user types it, or we derive from historical metric data (needs a
+ metrics warehouse we don't have yet).
+3. **Minimum Detectable Effect (MDE) input** — user specifies the smallest
+ lift they care about. Dedicated form field in the wizard.
+4. **Power calculation** — standard formula for proportion metrics:
+ `n_per_arm ≈ 16 × p(1-p) / MDE²`; duration =
+ `n_per_arm × arms / daily_traffic`.
+
+**Why not built yet:** real feature, not a polish item. Needs backend
+infra for (1) + (2) and a new wizard input for (3). Worth a separate
+sprint once the rest of Experiments v2 ships.
+
+## Deferred: guardrail semantic-flip on the results page
+
+**Context:** metric roles (primary / secondary / guardrail) are now rendered
+on the Experiment Results page via `MetricsComparisonTable`. Primary and
+secondary metrics use the existing lift/significance colouring — positive
+lift renders green, negative renders red.
+
+Guardrails should flip this: a **significant regression** on a guardrail
+(positive lift on a lower-better metric, negative lift on a higher-better
+metric) is a **failure** and should render as danger/warning, not neutral.
+Example: page-load-time goes up 8% with p<0.01 — today that'd render as
+"positive" because the lift bar only looks at `liftValue`'s sign; it should
+render as a red "regression" warning.
+
+**What's needed:**
+1. Propagate `MetricDirection` ('higher-better' | 'lower-better' | 'neither')
+ from `Metric` onto `MetricComparison` (it stops at the wizard today).
+2. Compute "is this a regression?" per metric:
+ `role === 'guardrail' && isSignificant && sign(liftValue) !== wantedDirection`.
+3. When true, override the lift-bar colour and significance label to a
+ danger tone, and consider adding a warning icon in the significance
+ column.
+
+**Why not built yet:** demo showed guardrails as a distinct visual category
+which is the main UX win; the semantic-flip is a correctness refinement that
+needs extra type work and would stretch tomorrow's scope.
diff --git a/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss
new file mode 100644
index 000000000000..08ad7ee556f9
--- /dev/null
+++ b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.scss
@@ -0,0 +1,141 @@
+.linked-experiment-section {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ .btn svg {
+ margin-right: 4px;
+ }
+ }
+
+ &__title {
+ font-size: var(--font-h6-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ // Experiment card (inlined from LinkedExperimentCard)
+ &__card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-action);
+ border-radius: var(--radius-lg);
+ }
+
+ &__card-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__card-name {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__details {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ &__detail-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ svg,
+ .icon {
+ color: var(--color-icon-secondary);
+ flex-shrink: 0;
+ }
+ }
+
+ &__detail-label {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__detail-value {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__progress {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ &__progress-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__progress-label {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__progress-value {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__progress-track {
+ height: 6px;
+ background: var(--color-surface-muted);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ }
+
+ &__progress-fill {
+ height: 100%;
+ background: var(--color-surface-action);
+ border-radius: var(--radius-full);
+ transition: width var(--duration-slow) var(--easing-standard);
+ }
+
+ // Empty state
+ &__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+ padding: 40px 20px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ text-align: center;
+
+ svg,
+ .icon {
+ color: var(--color-icon-secondary);
+ }
+ }
+
+ &__empty-title {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__empty-desc {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ line-height: 1.5;
+ max-width: 400px;
+ }
+}
diff --git a/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx
new file mode 100644
index 000000000000..5b2babe18174
--- /dev/null
+++ b/frontend/web/components/experiments-v2/flag-detail/LinkedExperimentSection.tsx
@@ -0,0 +1,119 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import Icon from 'components/icons/Icon'
+import StatusBadge from 'components/experiments-v2/shared/StatusBadge'
+import { LinkedExperiment } from 'components/experiments-v2/types'
+import './LinkedExperimentSection.scss'
+
+type LinkedExperimentSectionProps = {
+ experiment?: LinkedExperiment | null
+ onCreateExperiment?: () => void
+ onViewResults?: () => void
+}
+
+const LinkedExperimentSection: FC = ({
+ experiment,
+ onCreateExperiment,
+ onViewResults,
+}) => {
+ const progressPercent = experiment
+ ? Math.round((experiment.sampleProgress / experiment.sampleTarget) * 100)
+ : 0
+
+ return (
+
+
+
+ Linked Experiment
+
+ {experiment && (
+
+ View Results
+
+ )}
+
+
+ {experiment ? (
+
+
+
+ {experiment.name}
+
+
+
+
+
+
+
+
+ Primary Metric:
+
+
+ {experiment.primaryMetric}
+
+
+
+
+
+ Running Since:
+
+
+ {experiment.runningSince}
+
+
+
+
+
+ Traffic:
+
+
+ {experiment.trafficSplit}
+
+
+
+
+
+
+
+ Sample Progress
+
+
+ {progressPercent}% ({experiment.sampleProgress.toLocaleString()}{' '}
+ / {experiment.sampleTarget.toLocaleString()})
+
+
+
+
+
+ ) : (
+
+
+
+ No experiment linked
+
+
+ Create an experiment to test variations of this feature flag and
+ measure their impact on your key metrics.
+
+
+ Create Experiment
+
+
+ )}
+
+ )
+}
+
+LinkedExperimentSection.displayName = 'LinkedExperimentSection'
+export default LinkedExperimentSection
diff --git a/frontend/web/components/experiments-v2/shared/CreateMetricForm.scss b/frontend/web/components/experiments-v2/shared/CreateMetricForm.scss
new file mode 100644
index 000000000000..1c2980e8f2fa
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/CreateMetricForm.scss
@@ -0,0 +1,193 @@
+.create-metric-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+ background: var(--color-surface-default);
+ padding: 24px;
+ box-shadow: var(--shadow-sm);
+
+ &__header {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--color-border-default);
+ }
+
+ &__title {
+ font-size: var(--font-heading-sm-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__subtitle {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ &__label {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ margin: 0;
+ }
+
+ &__measurement-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ }
+
+ &__measurement-card {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 14px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-default);
+ text-align: left;
+ cursor: pointer;
+ transition: all var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ border-color: var(--color-border-action);
+ }
+
+ &--selected {
+ border-color: var(--color-border-action);
+ background: var(--color-surface-action-subtle);
+ box-shadow: 0 0 0 1px var(--color-border-action);
+ }
+ }
+
+ &__measurement-title {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__measurement-desc {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__measurement-ex {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ font-style: italic;
+ margin-top: 4px;
+ }
+
+ &__direction-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ &__direction-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-full);
+ cursor: pointer;
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+ background: var(--color-surface-default);
+ transition: all var(--duration-fast) var(--easing-standard);
+
+ input {
+ display: none;
+ }
+
+ &:hover {
+ border-color: var(--color-border-action);
+ }
+
+ &--selected {
+ border-color: var(--color-border-action);
+ background: var(--color-surface-action-subtle);
+ color: var(--color-text-action);
+ font-weight: var(--font-weight-semibold);
+ }
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding-top: 8px;
+ }
+
+ &__hint {
+ color: var(--color-text-secondary);
+ }
+
+ &__sublabel {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 4px;
+ }
+
+ &__source {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-subtle);
+ }
+
+ &__source-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ }
+
+ &__source-col {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__source-pill {
+ padding: 8px 12px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-default);
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+ font-weight: var(--font-weight-medium);
+ }
+
+ &__source-pill-path {
+ font-family: var(--font-family);
+ color: var(--color-text-secondary);
+ font-weight: var(--font-weight-regular);
+ }
+
+ &__source-select {
+ width: 100%;
+ padding: 8px 12px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-default);
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+ font-family: var(--font-family);
+ }
+}
diff --git a/frontend/web/components/experiments-v2/shared/CreateMetricForm.tsx b/frontend/web/components/experiments-v2/shared/CreateMetricForm.tsx
new file mode 100644
index 000000000000..58fa27d42400
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/CreateMetricForm.tsx
@@ -0,0 +1,355 @@
+import React, { FC, useState } from 'react'
+import Button from 'components/base/forms/Button'
+import Input from 'components/base/forms/Input'
+import {
+ MeasurementType,
+ Metric,
+ MetricDirection,
+ MetricSource,
+} from 'components/experiments-v2/types'
+import './CreateMetricForm.scss'
+
+const WAREHOUSE_TABLES = [
+ 'EVENTS',
+ 'PAGE_VIEWS',
+ 'SESSIONS',
+ 'TRANSACTIONS',
+ 'USERS',
+]
+
+type CreateMetricFormProps = {
+ initialValues?: Metric
+ onCancel: () => void
+ onSubmit: (metric: Metric) => void
+}
+
+const MEASUREMENT_OPTIONS: {
+ value: MeasurementType
+ title: string
+ description: string
+ example: string
+}[] = [
+ {
+ description: 'Number of times an event occurred',
+ example: 'ex: Number of purchases made',
+ title: 'Count',
+ value: 'count',
+ },
+ {
+ description: 'Whether an event is seen at least once',
+ example: 'ex: Signup completion',
+ title: 'Occurrence',
+ value: 'occurrence',
+ },
+ {
+ description: 'Magnitude of an event with an associated number',
+ example: 'ex: Latency or purchase value',
+ title: 'Value / Size',
+ value: 'value',
+ },
+]
+
+const DIRECTION_OPTIONS: { value: MetricDirection; label: string }[] = [
+ { label: 'Higher is better', value: 'higher-better' },
+ { label: 'Lower is better', value: 'lower-better' },
+ { label: 'Neither — informational only', value: 'neither' },
+]
+
+const CreateMetricForm: FC = ({
+ initialValues,
+ onCancel,
+ onSubmit,
+}) => {
+ const isEdit = !!initialValues
+ const [name, setName] = useState(initialValues?.name ?? '')
+ const [description, setDescription] = useState(
+ initialValues?.description ?? '',
+ )
+ const [measurementType, setMeasurementType] = useState(
+ initialValues?.measurementType ?? 'occurrence',
+ )
+ const [direction, setDirection] = useState(
+ initialValues?.direction ?? 'higher-better',
+ )
+ const [sourceTable, setSourceTable] = useState(
+ initialValues?.source?.table ?? 'EVENTS',
+ )
+ const [sourceEventName, setSourceEventName] = useState(
+ initialValues?.source?.eventName ?? '',
+ )
+ const [sourceValueColumn, setSourceValueColumn] = useState(
+ initialValues?.source?.valueColumn ?? '',
+ )
+ const [sourceFilter, setSourceFilter] = useState(
+ initialValues?.source?.filter ?? '',
+ )
+
+ const canSubmit = name.trim().length > 0 && description.trim().length > 0
+
+ const buildSource = (): MetricSource | undefined => {
+ const table = sourceTable.trim()
+ if (!table) return undefined
+ const source: MetricSource = { table, warehouse: 'snowflake' }
+ const needsEventName =
+ measurementType === 'count' || measurementType === 'occurrence'
+ if (needsEventName && sourceEventName.trim()) {
+ source.eventName = sourceEventName.trim()
+ }
+ if (measurementType === 'value' && sourceValueColumn.trim()) {
+ source.valueColumn = sourceValueColumn.trim()
+ }
+ if (sourceFilter.trim()) {
+ source.filter = sourceFilter.trim()
+ }
+ return source
+ }
+
+ const handleSubmit = () => {
+ if (!canSubmit) return
+ const source = buildSource()
+ if (initialValues) {
+ onSubmit({
+ ...initialValues,
+ description: description.trim(),
+ direction,
+ lastUpdated: 'Just now',
+ measurementType,
+ name: name.trim(),
+ source,
+ })
+ } else {
+ onSubmit({
+ description: description.trim(),
+ direction,
+ id: `metric-${Date.now()}`,
+ lastUpdated: 'Just now',
+ measurementType,
+ name: name.trim(),
+ role: 'secondary',
+ source,
+ usageCount: 0,
+ })
+ }
+ }
+
+ return (
+
+
+
+ {isEdit ? 'Edit Metric' : 'Create Metric'}
+
+
+ Metrics capture the outcomes your experiments measure.
+
+
+
+
+
+ Name
+
+ ) =>
+ setName(e.target.value)
+ }
+ placeholder='e.g. Signup Completion Rate'
+ />
+
+
+
+
+ Description
+
+ ) =>
+ setDescription(e.target.value)
+ }
+ placeholder='What does this metric measure?'
+ />
+
+
+
+
+ What do you want to measure?
+
+
+ {MEASUREMENT_OPTIONS.map((opt) => (
+ setMeasurementType(opt.value)}
+ >
+
+ {opt.title}
+
+
+ {opt.description}
+
+
+ {opt.example}
+
+
+ ))}
+
+
+
+
+
Direction
+
+ {DIRECTION_OPTIONS.map((opt) => (
+
+ setDirection(opt.value)}
+ />
+ {opt.label}
+
+ ))}
+
+
+
+
+
Data Source
+
+ Where this metric is collected from. Reads from your connected
+ warehouse.
+
+
+
+
+
+ Warehouse
+
+
+ Snowflake ·{' '}
+
+ FLAGSMITH_PROD.PUBLIC
+
+
+
+
+
+ Table
+
+ setSourceTable(e.target.value)}
+ >
+ {WAREHOUSE_TABLES.map((t) => (
+
+ {t}
+
+ ))}
+
+
+
+
+ {(measurementType === 'count' ||
+ measurementType === 'occurrence') && (
+
+
+ Event name
+
+ ) =>
+ setSourceEventName(e.target.value)
+ }
+ placeholder='e.g. checkout_completed'
+ />
+
+ )}
+
+ {measurementType === 'value' && (
+
+
+ Value column
+
+ ) =>
+ setSourceValueColumn(e.target.value)
+ }
+ placeholder='e.g. amount_usd'
+ />
+
+ )}
+
+
+
+ Filter (optional)
+
+ ) =>
+ setSourceFilter(e.target.value)
+ }
+ placeholder="e.g. status = 'complete'"
+ />
+
+
+
+
+
+
+ Cancel
+
+
+ {isEdit ? 'Save Changes' : 'Create Metric'}
+
+
+
+ )
+}
+
+CreateMetricForm.displayName = 'CreateMetricForm'
+export default CreateMetricForm
diff --git a/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss
new file mode 100644
index 000000000000..2258cfb90661
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.scss
@@ -0,0 +1,40 @@
+.experiment-stat-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 20px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ flex: 1;
+
+ &__label {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__value {
+ font-family: var(--font-family);
+ font-size: var(--font-h3-size);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+
+ &--positive {
+ color: var(--color-text-success);
+ }
+
+ &--negative {
+ color: var(--color-text-danger);
+ }
+
+ &--neutral {
+ color: var(--color-text-secondary);
+ }
+ }
+
+ &__subtitle {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+}
diff --git a/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx
new file mode 100644
index 000000000000..5eef91c2a119
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/ExperimentStatCard.tsx
@@ -0,0 +1,36 @@
+import React, { FC } from 'react'
+import { LiftDirection } from 'components/experiments-v2/types'
+import './ExperimentStatCard.scss'
+
+type ExperimentStatCardProps = {
+ label: string
+ value: string | number
+ subtitle?: string
+ trend?: LiftDirection
+}
+
+const ExperimentStatCard: FC = ({
+ label,
+ subtitle,
+ trend,
+ value,
+}) => {
+ return (
+
+ {label}
+
+ {typeof value === 'number' ? value.toLocaleString() : value}
+
+ {subtitle && (
+ {subtitle}
+ )}
+
+ )
+}
+
+ExperimentStatCard.displayName = 'ExperimentStatCard'
+export default ExperimentStatCard
diff --git a/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss
new file mode 100644
index 000000000000..f7b961b6f4d4
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.scss
@@ -0,0 +1,178 @@
+.metrics-comparison-table {
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+
+ &__head {
+ display: flex;
+ background: var(--color-surface-emphasis);
+ }
+
+ &__th {
+ flex: 1;
+ padding: 12px 16px;
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+
+ &--lift {
+ flex: 2;
+ }
+ }
+
+ &__row {
+ display: flex;
+ border-top: 1px solid var(--color-border-default);
+ transition: background var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ background: var(--color-surface-hover);
+ }
+
+ &--primary {
+ background: var(--color-surface-action-subtle);
+
+ &:hover {
+ background: var(--color-surface-action-subtle);
+ }
+
+ .metrics-comparison-table__metric-name {
+ font-weight: var(--font-weight-semibold);
+ }
+ }
+ }
+
+ &__td {
+ flex: 1;
+ padding: 14px 16px;
+ font-size: var(--font-body-size);
+ color: var(--color-text-default);
+
+ &--mono {
+ font-family: var(--font-family);
+ }
+
+ &--name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &--lift {
+ flex: 2;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ &--significance {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ &--significant {
+ color: var(--color-text-success);
+ }
+ }
+
+ &__metric-name {
+ flex: 1;
+ }
+
+ &__role {
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-semibold);
+ padding: 2px 8px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+
+ &--primary {
+ background: var(--color-surface-action);
+ color: var(--color-surface-default);
+ }
+
+ &--secondary {
+ border: 1px solid var(--color-border-default);
+ color: var(--color-text-secondary);
+ }
+
+ &--guardrail {
+ background: var(--color-surface-warning-subtle, #fff7e6);
+ color: var(--color-text-warning, #b36b00);
+ border: 1px solid var(--color-border-warning, #f2c078);
+ }
+ }
+
+ &__lift-bar {
+ position: relative;
+ flex: 1;
+ height: 8px;
+ min-width: 80px;
+ background: var(--color-surface-emphasis);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ }
+
+ &__lift-bar-axis {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 1px;
+ background: var(--color-border-default);
+ transform: translateX(-0.5px);
+ }
+
+ &__lift-bar-fill {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: var(--lift-bar-width, 0%);
+ border-radius: var(--radius-sm);
+ transition: width var(--duration-base) var(--easing-standard);
+
+ .metrics-comparison-table__lift-bar--side-right & {
+ left: 50%;
+ }
+
+ .metrics-comparison-table__lift-bar--side-left & {
+ right: 50%;
+ }
+
+ .metrics-comparison-table__lift-bar--tone-positive & {
+ background: var(--color-text-success);
+ }
+
+ .metrics-comparison-table__lift-bar--tone-negative & {
+ background: var(--color-text-danger);
+ }
+
+ .metrics-comparison-table__lift-bar--tone-neutral & {
+ background: var(--color-text-secondary);
+ opacity: 0.5;
+ }
+ }
+
+ &__lift-label {
+ font-family: var(--font-family);
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ min-width: 56px;
+ text-align: right;
+
+ &--positive {
+ color: var(--color-text-success);
+ }
+
+ &--negative {
+ color: var(--color-text-danger);
+ }
+
+ &--neutral {
+ color: var(--color-text-secondary);
+ font-weight: var(--font-weight-regular);
+ }
+ }
+}
diff --git a/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx
new file mode 100644
index 000000000000..5f95f1f27d35
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/MetricsComparisonTable.tsx
@@ -0,0 +1,123 @@
+import React, { CSSProperties, FC } from 'react'
+import Icon from 'components/icons/Icon'
+import { MetricComparison, MetricRole } from 'components/experiments-v2/types'
+import './MetricsComparisonTable.scss'
+
+type MetricsComparisonTableProps = {
+ metrics: MetricComparison[]
+}
+
+type LiftBarStyle = CSSProperties & { '--lift-bar-width': string }
+
+const ROLE_ORDER: Record = {
+ guardrail: 2,
+ primary: 0,
+ secondary: 1,
+}
+
+const ROLE_LABEL: Record = {
+ guardrail: 'Guardrail',
+ primary: 'Primary',
+ secondary: 'Secondary',
+}
+
+const MetricsComparisonTable: FC = ({
+ metrics,
+}) => {
+ const maxMagnitude = metrics.reduce(
+ (max, m) => Math.max(max, Math.abs(m.liftValue)),
+ 0,
+ )
+
+ const ordered = [...metrics].sort(
+ (a, b) => ROLE_ORDER[a.role] - ROLE_ORDER[b.role],
+ )
+
+ return (
+
+
+ Metric
+ Control
+ Treatment B
+
+ Lift
+
+ Significance
+
+
+ {ordered.map((metric) => {
+ const barWidthPercent =
+ maxMagnitude === 0
+ ? 0
+ : (Math.abs(metric.liftValue) / maxMagnitude) * 50
+ const barStyle: LiftBarStyle = {
+ '--lift-bar-width': `${barWidthPercent}%`,
+ }
+ const side = metric.liftValue >= 0 ? 'right' : 'left'
+ let tone: 'positive' | 'negative' | 'neutral' = 'neutral'
+ if (metric.isSignificant) {
+ tone = metric.liftDirection === 'negative' ? 'negative' : 'positive'
+ }
+
+ return (
+
+
+
+ {metric.name}
+
+
+ {ROLE_LABEL[metric.role]}
+
+
+
+ {metric.control}
+
+
+ {metric.treatment}
+
+
+
+
+
+
+
+ {metric.lift}
+
+
+
+ {metric.significance}
+ {metric.isSignificant && (
+ <>
+ {' '}
+
+ >
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+MetricsComparisonTable.displayName = 'MetricsComparisonTable'
+export default MetricsComparisonTable
diff --git a/frontend/web/components/experiments-v2/shared/MetricsTrendChart.scss b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.scss
new file mode 100644
index 000000000000..3930767afaa7
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.scss
@@ -0,0 +1,24 @@
+.metrics-trend-chart {
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-sm);
+ padding: 20px;
+
+ &__controls {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ &__label {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ margin: 0;
+ }
+
+ &__select {
+ flex: 0 0 280px;
+ }
+}
diff --git a/frontend/web/components/experiments-v2/shared/MetricsTrendChart.tsx b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.tsx
new file mode 100644
index 000000000000..5b95e77a9a1e
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/MetricsTrendChart.tsx
@@ -0,0 +1,79 @@
+import React, { FC, useMemo, useState } from 'react'
+import SearchableSelect, {
+ OptionType,
+} from 'components/base/select/SearchableSelect'
+import LineChart from 'components/charts/LineChart'
+import { MetricTrend } from 'components/experiments-v2/types'
+import './MetricsTrendChart.scss'
+
+type MetricsTrendChartProps = {
+ trends: MetricTrend[]
+}
+
+const SERIES = ['control', 'treatment']
+const SERIES_LABELS: Record = {
+ control: 'Control',
+ treatment: 'Treatment B',
+}
+const COLOR_MAP: Record = {
+ control: 'var(--green-500)',
+ treatment: 'var(--purple-500)',
+}
+
+const formatValue = (unit: MetricTrend['unit'], value: number): string => {
+ if (unit === '$') return `$${value.toFixed(2)}`
+ if (unit === '%') return `${value.toFixed(1)}%`
+ return `${value.toFixed(2)}s`
+}
+
+const formatAxis = (unit: MetricTrend['unit'], value: number): string => {
+ if (unit === '$') return `$${value.toFixed(0)}`
+ return `${value.toFixed(1)}${unit}`
+}
+
+const MetricsTrendChart: FC = ({ trends }) => {
+ const [selectedMetric, setSelectedMetric] = useState(
+ trends[0]?.metricName ?? '',
+ )
+
+ const options = useMemo(
+ () => trends.map((t) => ({ label: t.metricName, value: t.metricName })),
+ [trends],
+ )
+
+ const selected = trends.find((t) => t.metricName === selectedMetric)
+
+ if (!selected) {
+ return null
+ }
+
+ return (
+
+
+
Metric
+
+ setSelectedMetric(String(opt.value))}
+ options={options}
+ placeholder='Select a metric...'
+ />
+
+
+
+
formatAxis(selected.unit, value)}
+ tooltipValueFormatter={(value) => formatValue(selected.unit, value)}
+ tooltipLabelFormatter={(label) => `Day ${label}`}
+ />
+
+ )
+}
+
+MetricsTrendChart.displayName = 'MetricsTrendChart'
+export default MetricsTrendChart
diff --git a/frontend/web/components/experiments-v2/shared/SelectableCard.scss b/frontend/web/components/experiments-v2/shared/SelectableCard.scss
new file mode 100644
index 000000000000..4167d78e709d
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/SelectableCard.scss
@@ -0,0 +1,143 @@
+.selectable-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px;
+ border-radius: var(--radius-xl);
+ border: 2px solid transparent;
+ box-shadow: inset 0 0 0 1px var(--color-border-default);
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+ width: 100%;
+ transition:
+ border-color var(--duration-fast) var(--easing-standard),
+ box-shadow var(--duration-fast) var(--easing-standard),
+ background var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ box-shadow: inset 0 0 0 1px var(--color-border-strong);
+ }
+
+ &--selected {
+ border-color: var(--color-border-action);
+ box-shadow: none;
+ background: var(--color-surface-action-subtle);
+
+ svg,
+ .icon {
+ color: var(--color-icon-action);
+ }
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ svg,
+ .icon {
+ color: var(--color-icon-secondary);
+ margin-bottom: 4px;
+ }
+ }
+
+ &__title {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__description {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__tags {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ margin-top: 4px;
+ }
+
+ &__tag {
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-medium);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ background: var(--color-surface-emphasis);
+ color: var(--color-text-secondary);
+ font-family: var(--font-family);
+ }
+
+ &__aside {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ }
+
+ &__role-group {
+ display: inline-flex;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ background: var(--color-surface-default);
+ }
+
+ &__role-pill {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-medium);
+ padding: 4px 10px;
+ border: 0;
+ background: transparent;
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition:
+ background var(--duration-fast) var(--easing-standard),
+ color var(--duration-fast) var(--easing-standard);
+
+ + .selectable-card__role-pill {
+ border-left: 1px solid var(--color-border-default);
+ }
+
+ &:hover {
+ background: var(--color-surface-action-subtle);
+ color: var(--color-text-default);
+ }
+
+ &--active {
+ background: var(--color-surface-action);
+ color: var(--color-surface-default);
+
+ &:hover {
+ background: var(--color-surface-action);
+ color: var(--color-surface-default);
+ }
+ }
+ }
+
+ &__badge {
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-semibold);
+ padding: 4px 12px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+
+ &--primary {
+ background: var(--color-surface-action);
+ color: var(--color-surface-default);
+ }
+
+ &--secondary {
+ border: 1px solid var(--color-border-default);
+ color: var(--color-text-secondary);
+ }
+
+ &--guardrail {
+ background: var(--color-surface-warning-subtle, #fff7e6);
+ color: var(--color-text-warning, #b36b00);
+ border: 1px solid var(--color-border-warning, #f2c078);
+ }
+ }
+}
diff --git a/frontend/web/components/experiments-v2/shared/SelectableCard.tsx b/frontend/web/components/experiments-v2/shared/SelectableCard.tsx
new file mode 100644
index 000000000000..10a0799ac7eb
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/SelectableCard.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import Icon, { IconName } from 'components/icons/Icon'
+import './SelectableCard.scss'
+
+type BadgeVariant = 'primary' | 'secondary' | 'guardrail'
+
+type RoleOption = { label: string; value: V }
+
+type SelectableCardProps = {
+ selected: boolean
+ onClick: () => void
+ icon?: IconName
+ title: string
+ description: string
+ badge?: { label: string; variant: BadgeVariant }
+ /** Small info tags rendered under the title (e.g. measurement type). */
+ tags?: string[]
+ /** Segmented role picker rendered on the right when selected. Mutually
+ * exclusive with `badge`. */
+ roleSelector?: {
+ value: V
+ options: RoleOption[]
+ onChange: (value: V) => void
+ }
+}
+
+const SelectableCard = ({
+ badge,
+ description,
+ icon,
+ onClick,
+ roleSelector,
+ selected,
+ tags,
+ title,
+}: SelectableCardProps) => {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onClick()
+ }
+ }
+ return (
+
+
+ {icon &&
}
+
{title}
+
{description}
+ {tags && tags.length > 0 && (
+
+ {tags.map((t) => (
+
+ {t}
+
+ ))}
+
+ )}
+
+
+ {roleSelector ? (
+
+ {roleSelector.options.map((opt) => (
+ {
+ e.stopPropagation()
+ roleSelector.onChange(opt.value)
+ }}
+ aria-checked={roleSelector.value === opt.value}
+ role='radio'
+ >
+ {opt.label}
+
+ ))}
+
+ ) : (
+ badge && (
+
+ {badge.label}
+
+ )
+ )}
+
+
+ )
+}
+
+SelectableCard.displayName = 'SelectableCard'
+export default SelectableCard
diff --git a/frontend/web/components/experiments-v2/shared/StatusBadge.scss b/frontend/web/components/experiments-v2/shared/StatusBadge.scss
new file mode 100644
index 000000000000..75e181002a67
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/StatusBadge.scss
@@ -0,0 +1,42 @@
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: var(--radius-full);
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-semibold);
+
+ &__dot {
+ width: 6px;
+ height: 6px;
+ border-radius: var(--radius-full);
+ }
+
+ &--running {
+ background: var(--color-surface-success);
+ color: var(--color-text-success);
+
+ .status-badge__dot {
+ background: var(--color-text-success);
+ }
+ }
+
+ &--paused {
+ background: var(--color-surface-warning);
+ color: var(--color-text-warning);
+
+ .status-badge__dot {
+ background: var(--color-text-warning);
+ }
+ }
+
+ &--completed {
+ background: var(--color-surface-muted);
+ color: var(--color-text-secondary);
+
+ .status-badge__dot {
+ background: var(--color-text-secondary);
+ }
+ }
+}
diff --git a/frontend/web/components/experiments-v2/shared/StatusBadge.tsx b/frontend/web/components/experiments-v2/shared/StatusBadge.tsx
new file mode 100644
index 000000000000..d3f9777e7b97
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/StatusBadge.tsx
@@ -0,0 +1,25 @@
+import React, { FC } from 'react'
+import { ExperimentStatus } from 'components/experiments-v2/types'
+import './StatusBadge.scss'
+
+type StatusBadgeProps = {
+ status: ExperimentStatus
+}
+
+const STATUS_LABELS: Record = {
+ completed: 'Completed',
+ paused: 'Paused',
+ running: 'Running',
+}
+
+const StatusBadge: FC = ({ status }) => {
+ return (
+
+
+ {STATUS_LABELS[status]}
+
+ )
+}
+
+StatusBadge.displayName = 'StatusBadge'
+export default StatusBadge
diff --git a/frontend/web/components/experiments-v2/shared/VariationTable.scss b/frontend/web/components/experiments-v2/shared/VariationTable.scss
new file mode 100644
index 000000000000..6504fddff3b2
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/VariationTable.scss
@@ -0,0 +1,121 @@
+.variation-table {
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+
+ &__head {
+ display: flex;
+ padding: 12px 20px;
+ background: var(--color-surface-muted);
+ gap: 16px;
+ }
+
+ &__th {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+
+ &--name,
+ &--desc {
+ flex: 1;
+ }
+
+ &--value {
+ width: 100px;
+ }
+
+ &--actions {
+ width: 40px;
+ }
+ }
+
+ &__row {
+ display: flex;
+ padding: 16px 20px;
+ gap: 16px;
+ border-top: 1px solid var(--color-border-default);
+ align-items: center;
+ transition: background var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ background: var(--color-surface-hover);
+ }
+ }
+
+ &__cell {
+ &--name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ }
+
+ &--desc {
+ flex: 1;
+ }
+
+ &--value {
+ width: 100px;
+ }
+
+ &--actions {
+ width: 40px;
+ display: flex;
+ justify-content: center;
+ }
+ }
+
+ &__dot {
+ width: 10px;
+ height: 10px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+ }
+
+ &__name-text {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__control-tag {
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ background: var(--color-surface-muted);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ }
+
+ &__desc-text {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ line-height: 1.4;
+ }
+
+ &__value-badge {
+ font-family: var(--font-family);
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+ background: var(--color-surface-muted);
+ padding: 6px 12px;
+ border-radius: var(--radius-md);
+ }
+
+ &__action-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ color: var(--color-icon-secondary);
+ border-radius: var(--radius-sm);
+ transition: color var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ color: var(--color-icon-danger);
+ }
+ }
+}
diff --git a/frontend/web/components/experiments-v2/shared/VariationTable.tsx b/frontend/web/components/experiments-v2/shared/VariationTable.tsx
new file mode 100644
index 000000000000..87452c93ea7f
--- /dev/null
+++ b/frontend/web/components/experiments-v2/shared/VariationTable.tsx
@@ -0,0 +1,102 @@
+import React, { FC } from 'react'
+import Icon from 'components/icons/Icon'
+import { CONTROL_COLOUR, Variation } from 'components/experiments-v2/types'
+import './VariationTable.scss'
+
+type VariationTableProps = {
+ variations: Variation[]
+ /**
+ * Renders an implicit "Control" row above the variations, reflecting that
+ * control is the flag's base value rather than a peer variation.
+ */
+ controlValue?: string
+ editable?: boolean
+ onRemove?: (id: string) => void
+}
+
+const VariationTable: FC = ({
+ controlValue,
+ editable = false,
+ onRemove,
+ variations,
+}) => {
+ return (
+
+
+
+ Name
+
+
+ Description
+
+
+ Value
+
+ {editable && (
+
+ )}
+
+
+ {controlValue !== undefined && (
+
+
+
+ Control
+ control
+
+
+
+ Flag's base value — the baseline for comparison
+
+
+
+ {controlValue}
+
+ {editable && (
+
+ )}
+
+ )}
+
+ {variations.map((variation) => (
+
+
+
+ {variation.name}
+
+
+
+ {variation.description}
+
+
+
+
+ {variation.value}
+
+
+ {editable && (
+
+ onRemove?.(variation.id)}
+ type='button'
+ aria-label={`Remove ${variation.name}`}
+ >
+
+
+
+ )}
+
+ ))}
+
+ )
+}
+
+VariationTable.displayName = 'VariationTable'
+export default VariationTable
diff --git a/frontend/web/components/experiments-v2/steps/AudienceStep.scss b/frontend/web/components/experiments-v2/steps/AudienceStep.scss
new file mode 100644
index 000000000000..315760fb58b3
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/AudienceStep.scss
@@ -0,0 +1,226 @@
+.audience-step {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ &__env {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 10px 14px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ font-size: var(--font-body-sm-size);
+ }
+
+ &__env-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__env-divider {
+ width: 1px;
+ align-self: stretch;
+ background: var(--color-border-default);
+ }
+
+ &__env-label {
+ color: var(--color-text-secondary);
+ }
+
+ &__env-value {
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__section {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 20px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+ background: var(--color-surface-default);
+ }
+
+ &__section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__section-title {
+ margin: 0;
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__optional-badge {
+ margin-left: 8px;
+ padding: 2px 6px;
+ font-size: var(--font-body-xs-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ background: var(--color-surface-subtle);
+ border-radius: var(--radius-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+
+ &__label {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__segment-link {
+ color: var(--color-text-action);
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ &__hint {
+ color: var(--color-text-secondary);
+ }
+
+ &__targeting-toggle {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ }
+
+ &__targeting-clear {
+ margin-left: auto;
+ }
+
+ &__sample-presets {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-top: 4px;
+ }
+
+ &__sample-preset {
+ padding: 8px 14px;
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ background: var(--color-surface-default);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--duration-fast) var(--easing-standard);
+
+ &:hover:not(&--active) {
+ background: var(--color-surface-subtle);
+ }
+
+ &--active {
+ background: var(--color-surface-action-subtle);
+ border-color: var(--color-border-action);
+ color: var(--color-text-action);
+ }
+ }
+
+ &__sample-custom {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 8px;
+
+ input {
+ width: 80px;
+ padding: 8px 10px;
+ font-family: var(--font-family);
+ font-size: var(--font-body-size);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-sm);
+ text-align: right;
+ }
+ }
+
+ &__arms {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 4px;
+ }
+
+ &__arm-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ background: var(--color-surface-default);
+ }
+
+ &__arm-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+ }
+
+ &__arm-name {
+ flex: 1;
+ font-size: var(--font-body-size);
+ color: var(--color-text-default);
+ }
+
+ &__arm-input {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ input {
+ width: 64px;
+ padding: 6px 8px;
+ font-family: var(--font-family);
+ font-size: var(--font-body-size);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-sm);
+ text-align: right;
+ }
+ }
+
+ &__bar {
+ display: flex;
+ height: 10px;
+ width: 100%;
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ background: var(--color-surface-emphasis);
+ margin-top: 4px;
+ }
+
+ &__bar-segment {
+ height: 100%;
+ transition: width var(--duration-fast) var(--easing-standard);
+ }
+
+ &__sample-estimate {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+
+ svg,
+ .icon {
+ color: var(--color-icon-action);
+ flex-shrink: 0;
+ }
+ }
+}
diff --git a/frontend/web/components/experiments-v2/steps/AudienceStep.tsx b/frontend/web/components/experiments-v2/steps/AudienceStep.tsx
new file mode 100644
index 000000000000..a66dd25173ba
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/AudienceStep.tsx
@@ -0,0 +1,382 @@
+import React, { FC, useMemo } from 'react'
+import { useParams } from 'react-router-dom'
+import Banner from 'components/Banner/Banner'
+import Button from 'components/base/forms/Button'
+import Icon from 'components/icons/Icon'
+import SearchableSelect from 'components/base/select/SearchableSelect'
+import { OptionType } from 'components/base/select/SearchableSelect'
+import {
+ ArmWeight,
+ AudienceConfig,
+ CONTROL_ARM_ID,
+ CONTROL_COLOUR,
+ FlagOption,
+ MOCK_ENVIRONMENT_USER_COUNT,
+ MOCK_SEGMENTS,
+ SAMPLE_SIZE_PRESETS,
+ Variation,
+} from 'components/experiments-v2/types'
+import './AudienceStep.scss'
+
+type Arm = {
+ id: string
+ name: string
+ value: string
+ colour: string
+}
+
+type AudienceStepProps = {
+ audience: AudienceConfig
+ flag: FlagOption | null
+ controlValue: string
+ variations: Variation[]
+ environmentName: string
+ onChange: (audience: AudienceConfig) => void
+}
+
+export const buildExperimentArms = (
+ controlValue: string,
+ variations: Variation[],
+): Arm[] => [
+ {
+ colour: CONTROL_COLOUR,
+ id: CONTROL_ARM_ID,
+ name: 'Control',
+ value: controlValue,
+ },
+ ...variations.map((v) => ({
+ colour: v.colour,
+ id: v.id,
+ name: v.name,
+ value: v.value,
+ })),
+]
+
+export const splitEvenly = (armIds: string[]): ArmWeight[] => {
+ if (armIds.length === 0) return []
+ const base = Math.floor(100 / armIds.length)
+ const remainder = 100 - base * armIds.length
+ return armIds.map((id, i) => ({
+ armId: id,
+ weight: base + (i < remainder ? 1 : 0),
+ }))
+}
+
+const getWeight = (weights: ArmWeight[] | undefined, armId: string): number =>
+ (weights ?? []).find((w) => w.armId === armId)?.weight ?? 0
+
+const clampSamplePercentage = (value: number): number =>
+ Math.max(1, Math.min(100, Math.round(value)))
+
+const AudienceStep: FC = ({
+ audience,
+ controlValue,
+ environmentName,
+ flag,
+ onChange,
+ variations,
+}) => {
+ const { projectId } = useParams<{ projectId?: string }>()
+ const segmentsUrl = projectId ? `/project/${projectId}/segments` : undefined
+
+ const arms = useMemo(
+ () => buildExperimentArms(controlValue, variations),
+ [controlValue, variations],
+ )
+
+ const selectedSegment = MOCK_SEGMENTS.find(
+ (s) => s.value === audience.segmentId,
+ )
+
+ const handleSegmentChange = (segmentId: string | null) => {
+ onChange({ ...audience, segmentId })
+ }
+
+ const handleSamplePercentageChange = (next: number) => {
+ onChange({ ...audience, samplePercentage: clampSamplePercentage(next) })
+ }
+
+ const handleWeightChange = (armId: string, value: string) => {
+ const newWeight = Math.max(0, Math.min(100, Number(value) || 0))
+ const others = arms.filter((a) => a.id !== armId)
+ if (others.length === 0) {
+ onChange({ ...audience, weights: [{ armId, weight: 100 }] })
+ return
+ }
+ const targetOthersTotal = 100 - newWeight
+ const currentOthers = others.map((a) => ({
+ armId: a.id,
+ weight: getWeight(audience.weights, a.id),
+ }))
+ const othersSum = currentOthers.reduce((s, o) => s + o.weight, 0)
+
+ let rebalanced: ArmWeight[]
+ if (othersSum === 0) {
+ const base = Math.floor(targetOthersTotal / others.length)
+ const remainder = targetOthersTotal - base * others.length
+ rebalanced = currentOthers.map((o, i) => ({
+ armId: o.armId,
+ weight: base + (i < remainder ? 1 : 0),
+ }))
+ } else {
+ const scaled = currentOthers.map((o) => {
+ const exact = (o.weight / othersSum) * targetOthersTotal
+ return { armId: o.armId, exact, floor: Math.floor(exact) }
+ })
+ const floorSum = scaled.reduce((s, o) => s + o.floor, 0)
+ const toDistribute = targetOthersTotal - floorSum
+ const byFrac = [...scaled]
+ .map((s) => ({ ...s, frac: s.exact - s.floor }))
+ .sort((a, b) => b.frac - a.frac)
+ byFrac.forEach((s, i) => {
+ s.floor += i < toDistribute ? 1 : 0
+ })
+ const byId = new Map(byFrac.map((s) => [s.armId, s.floor]))
+ rebalanced = currentOthers.map((o) => ({
+ armId: o.armId,
+ weight: byId.get(o.armId) ?? 0,
+ }))
+ }
+
+ const nextWeights = arms.map((a) => {
+ if (a.id === armId) return { armId: a.id, weight: newWeight }
+ return (
+ rebalanced.find((r) => r.armId === a.id) ?? { armId: a.id, weight: 0 }
+ )
+ })
+ onChange({ ...audience, weights: nextWeights })
+ }
+
+ const handleSplitEvenly = () => {
+ onChange({
+ ...audience,
+ weights: splitEvenly(arms.map((a) => a.id)),
+ })
+ }
+
+ const eligibleUsers =
+ selectedSegment?.estimatedUsers ?? MOCK_ENVIRONMENT_USER_COUNT
+ const sampledUsers = Math.round(
+ (eligibleUsers * audience.samplePercentage) / 100,
+ )
+
+ const armEstimates = arms.map((a) => ({
+ arm: a,
+ users: Math.round((sampledUsers * getWeight(audience.weights, a.id)) / 100),
+ }))
+
+ const conflictingOverride = audience.segmentId
+ ? flag?.existingSegmentOverrides.find(
+ (o) => o.segmentId === audience.segmentId,
+ )
+ : undefined
+
+ const isCustomSamplePercentage = !SAMPLE_SIZE_PRESETS.includes(
+ audience.samplePercentage as (typeof SAMPLE_SIZE_PRESETS)[number],
+ )
+
+ return (
+
+
+
+ Environment
+ {environmentName}
+
+
+
+ Bucketed by
+ identity
+
+
+
+
+
+
+ Targeting
+ Optional
+
+ {audience.segmentId && (
+ handleSegmentChange(null)}
+ >
+ Clear filter
+
+ )}
+
+
+ Filter the experiment to a specific segment of users. Leave empty to
+ run on all identities in the environment.
+
+ handleSegmentChange(opt.value)}
+ options={MOCK_SEGMENTS}
+ placeholder='All users in this environment'
+ />
+ {segmentsUrl && (
+
+ Need a different filter (combination of traits, geography, plan)?{' '}
+
+ Create a new segment on the Segments page
+
+ .
+
+ )}
+ {conflictingOverride && flag && (
+
+
+ {conflictingOverride.segmentLabel} already has an
+ override on {flag.label} . Pick a different
+ segment, or remove the existing override on the Features page
+ first.
+
+
+ )}
+
+
+
+
+
Sample size
+
+
+ What percentage of eligible users enters the experiment? The rest keep
+ the flag's environment default and aren't part of the
+ result.
+
+
+ {SAMPLE_SIZE_PRESETS.map((preset) => {
+ const isActive =
+ !isCustomSamplePercentage && audience.samplePercentage === preset
+ return (
+ handleSamplePercentageChange(preset)}
+ >
+ {preset}%
+
+ )
+ })}
+ handleSamplePercentageChange(75)}
+ >
+ Custom
+
+
+ {isCustomSamplePercentage && (
+
+
+ handleSamplePercentageChange(Number(e.target.value) || 1)
+ }
+ />
+ %
+
+ )}
+
+
+
+
+
Variation split
+
+ Split evenly
+
+
+
+ Distribute sampled users across control and treatment variations.
+ Control takes one of the slots; weights must sum to 100.
+
+
+
+ {arms.map((arm) => (
+
+
+
{arm.name}
+
+ handleWeightChange(arm.id, e.target.value)}
+ />
+ %
+
+
+ ))}
+
+
+
+ {arms.map((arm) => {
+ const w = getWeight(audience.weights, arm.id)
+ if (w === 0) return null
+ return (
+
+ )
+ })}
+
+
+
+
+
+
+ {selectedSegment ? (
+ <>
+ {selectedSegment.label} has{' '}
+ {eligibleUsers.toLocaleString()} eligible users.{' '}
+ >
+ ) : (
+ <>
+ {eligibleUsers.toLocaleString()} eligible users in this
+ environment.{' '}
+ >
+ )}
+ Sampling {audience.samplePercentage}% (~
+ {sampledUsers.toLocaleString()}) into the experiment, split as{' '}
+ {armEstimates
+ .filter((e) => getWeight(audience.weights, e.arm.id) > 0)
+ .map((e, i, arr) => (
+
+ {i > 0 && (i === arr.length - 1 ? ' and ' : ', ')}
+ {e.arm.name} ~{e.users.toLocaleString()}
+
+ ))}
+ . Actual time-to-significance depends on traffic, baseline rate, and
+ the lift you're trying to detect.
+
+
+
+ )
+}
+
+AudienceStep.displayName = 'AudienceStep'
+export default AudienceStep
diff --git a/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss
new file mode 100644
index 000000000000..a2269befc1a6
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.scss
@@ -0,0 +1,63 @@
+.experiment-details-step {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 20px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+
+ &__field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ &__label {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__required {
+ color: var(--color-text-danger);
+ }
+
+ &__hint {
+ color: var(--color-text-secondary);
+ }
+
+ &__textarea {
+ width: 100%;
+ min-height: 80px;
+ padding: 10px 14px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ background: transparent;
+ color: var(--color-text-default);
+ font-size: var(--font-body-size);
+ font-family: inherit;
+ resize: vertical;
+ transition: border-color var(--duration-fast) var(--easing-standard);
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-border-action);
+ }
+ }
+
+ &__type-cards {
+ display: flex;
+ gap: 12px;
+ }
+
+ &__date-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+ }
+}
diff --git a/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx
new file mode 100644
index 000000000000..30e728642593
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/ExperimentDetailsStep.tsx
@@ -0,0 +1,76 @@
+import React, { FC } from 'react'
+import Input from 'components/base/forms/Input'
+import { ExperimentDetails } from 'components/experiments-v2/types'
+import './ExperimentDetailsStep.scss'
+
+type ExperimentDetailsStepProps = {
+ details: ExperimentDetails
+ onChange: (details: ExperimentDetails) => void
+}
+
+const ExperimentDetailsStep: FC = ({
+ details,
+ onChange,
+}) => {
+ return (
+
+
+
+ Experiment Name{' '}
+ *
+
+ ) =>
+ onChange({ ...details, name: e.target.value })
+ }
+ placeholder='e.g. Checkout Flow Redesign'
+ />
+
+
+
+
+ Hypothesis{' '}
+ *
+
+
+
+
+
+ )
+}
+
+ExperimentDetailsStep.displayName = 'ExperimentDetailsStep'
+export default ExperimentDetailsStep
diff --git a/frontend/web/components/experiments-v2/steps/FlagVariationsStep.scss b/frontend/web/components/experiments-v2/steps/FlagVariationsStep.scss
new file mode 100644
index 000000000000..6a8e23f9f27a
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/FlagVariationsStep.scss
@@ -0,0 +1,17 @@
+.flag-variations-step {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ &__field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__label {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+}
diff --git a/frontend/web/components/experiments-v2/steps/FlagVariationsStep.tsx b/frontend/web/components/experiments-v2/steps/FlagVariationsStep.tsx
new file mode 100644
index 000000000000..5fe96478805d
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/FlagVariationsStep.tsx
@@ -0,0 +1,105 @@
+import React, { FC, useMemo } from 'react'
+import Banner from 'components/Banner/Banner'
+import EmptyState from 'components/EmptyState'
+import SearchableSelect from 'components/base/select/SearchableSelect'
+import VariationTable from 'components/experiments-v2/shared/VariationTable'
+import { OptionType } from 'components/base/select/SearchableSelect'
+import {
+ MIN_VARIATIONS_FOR_EXPERIMENT,
+ MOCK_FLAGS,
+ Variation,
+} from 'components/experiments-v2/types'
+import './FlagVariationsStep.scss'
+
+type FlagVariationsStepProps = {
+ featureFlagId: string | null
+ controlValue: string
+ variations: Variation[]
+ onFlagChange: (flagId: string) => void
+ onControlValueChange: (value: string) => void
+ onVariationsChange: (variations: Variation[]) => void
+}
+
+const FlagVariationsStep: FC = ({
+ controlValue,
+ featureFlagId,
+ onControlValueChange,
+ onFlagChange,
+ onVariationsChange,
+ variations,
+}) => {
+ const eligibleFlags = useMemo(
+ () => MOCK_FLAGS.filter((f) => f.isMultiVariant),
+ [],
+ )
+
+ if (eligibleFlags.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ const handleFlagChange = (flagId: string) => {
+ onFlagChange(flagId)
+ const flag = MOCK_FLAGS.find((f) => f.value === flagId)
+ onControlValueChange(flag?.controlValue ?? '')
+ onVariationsChange(flag?.variations ?? [])
+ }
+
+ const selectedFlag = eligibleFlags.find((f) => f.value === featureFlagId)
+
+ const hasInsufficientVariations =
+ !!featureFlagId && variations.length < MIN_VARIATIONS_FOR_EXPERIMENT
+
+ return (
+
+
+ Feature Flag
+ handleFlagChange(opt.value)}
+ options={eligibleFlags}
+ placeholder='Select a feature flag...'
+ />
+
+ Only multi-variant flags can be experimented on.
+
+
+
+ {hasInsufficientVariations && (
+
+
+ This flag has no variations beyond the control value. Experiments
+ need at least {MIN_VARIATIONS_FOR_EXPERIMENT} variation to run — add
+ one on the flag page to make it eligible.
+
+
+ )}
+
+
+
Variations
+ {featureFlagId ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ )
+}
+
+FlagVariationsStep.displayName = 'FlagVariationsStep'
+export default FlagVariationsStep
diff --git a/frontend/web/components/experiments-v2/steps/ReviewLaunchStep.scss b/frontend/web/components/experiments-v2/steps/ReviewLaunchStep.scss
new file mode 100644
index 000000000000..026bfb91b0f6
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/ReviewLaunchStep.scss
@@ -0,0 +1,233 @@
+.review-launch-step {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ &__section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 20px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ transition: border-color var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ border-color: var(--color-border-strong);
+ }
+ }
+
+ &__section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__section-title {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+
+ &--block {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+ }
+
+ &__label {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__value {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ text-align: right;
+
+ &--mono {
+ font-family: var(--font-family);
+ }
+ }
+
+ &__hypothesis {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+ line-height: 1.5;
+ padding: 12px;
+ background: var(--color-surface-muted);
+ border-radius: var(--radius-md);
+ width: 100%;
+ }
+
+ &__type-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-action);
+ background: var(--color-surface-action-subtle);
+ padding: 4px 12px;
+ border-radius: var(--radius-full);
+
+ svg {
+ color: var(--color-icon-action);
+ }
+ }
+
+ // Metrics
+ &__metric-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 10px 12px;
+ border-radius: var(--radius-md);
+ background: var(--color-surface-muted);
+ }
+
+ &__metric-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+
+ &__metric-name {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__metric-desc {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__badge {
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-medium);
+ padding: 2px 10px;
+ border-radius: var(--radius-full);
+ text-transform: capitalize;
+ flex-shrink: 0;
+
+ &--primary {
+ background: var(--color-surface-action);
+ color: var(--color-surface-default);
+ }
+
+ &--secondary {
+ border: 1px solid var(--color-border-default);
+ color: var(--color-text-secondary);
+ }
+
+ &--guardrail {
+ background: var(--color-surface-warning-subtle, #fff7e6);
+ color: var(--color-text-warning, #b36b00);
+ border: 1px solid var(--color-border-warning, #f2c078);
+ }
+ }
+
+ // Variations
+ &__variations {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__variation {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: var(--radius-md);
+ background: var(--color-surface-muted);
+ }
+
+ &__variation-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+ }
+
+ &__variation-name {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ flex: 1;
+ }
+
+ &__variation-value {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ background: var(--color-surface-subtle);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ }
+
+ // Traffic split
+ &__traffic-split {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__traffic-bar {
+ display: flex;
+ height: 8px;
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ background: var(--color-surface-muted);
+ }
+
+ &__traffic-segment {
+ height: 100%;
+
+ &:first-child {
+ border-radius: var(--radius-full) 0 0 var(--radius-full);
+ }
+
+ &:last-child {
+ border-radius: 0 var(--radius-full) var(--radius-full) 0;
+ }
+ }
+
+ &__traffic-labels {
+ display: flex;
+ gap: 16px;
+ }
+
+ &__traffic-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__traffic-label-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+ }
+
+ &__empty {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-disabled);
+ font-style: italic;
+ }
+}
diff --git a/frontend/web/components/experiments-v2/steps/ReviewLaunchStep.tsx b/frontend/web/components/experiments-v2/steps/ReviewLaunchStep.tsx
new file mode 100644
index 000000000000..c963429d6f65
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/ReviewLaunchStep.tsx
@@ -0,0 +1,197 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import {
+ CONTROL_ARM_ID,
+ CONTROL_COLOUR,
+ ExperimentWizardState,
+ MOCK_FLAGS,
+ MOCK_SEGMENTS,
+ Variation,
+} from 'components/experiments-v2/types'
+import './ReviewLaunchStep.scss'
+
+type ReviewLaunchStepProps = {
+ wizardState: ExperimentWizardState
+ onEditStep: (step: number) => void
+}
+
+const ReviewLaunchStep: FC = ({
+ onEditStep,
+ wizardState,
+}) => {
+ const flagLabel =
+ MOCK_FLAGS.find((f) => f.value === wizardState.featureFlagId)?.label ?? '—'
+ const targetingLabel = wizardState.audience.segmentId
+ ? MOCK_SEGMENTS.find((s) => s.value === wizardState.audience.segmentId)
+ ?.label ?? '—'
+ : 'All users in this environment'
+
+ const controlArm: Variation = {
+ colour: CONTROL_COLOUR,
+ description: "Flag's base value — the baseline for comparison",
+ id: CONTROL_ARM_ID,
+ name: 'Control',
+ value: wizardState.controlValue,
+ }
+ const arms: Variation[] = [controlArm, ...wizardState.variations]
+ const weightFor = (armId: string) =>
+ wizardState.audience.weights.find((w) => w.armId === armId)?.weight ?? 0
+
+ return (
+
+ {/* Step 1: Flag & Variations */}
+
+
+
+ Flag & Variations
+
+ onEditStep(0)}>
+ Edit
+
+
+
+ Feature Flag
+
+ {flagLabel}
+
+
+
+ {arms.map((v) => (
+
+
+
+ {v.name}
+
+
+ {v.value}
+
+
+ ))}
+
+
+
+ {/* Step 2: Experiment Details */}
+
+
+
+ Experiment Details
+
+ onEditStep(1)}>
+ Edit
+
+
+
+ Name
+
+ {wizardState.details.name || '—'}
+
+
+ {wizardState.details.hypothesis && (
+
+ Hypothesis
+
+ {wizardState.details.hypothesis}
+
+
+ )}
+ {(wizardState.details.startDate || wizardState.details.endDate) && (
+
+ Dates
+
+ {wizardState.details.startDate || '—'} →{' '}
+ {wizardState.details.endDate || '—'}
+
+
+ )}
+
+
+ {/* Step 3: Metrics */}
+
+
+
+ Metrics ({wizardState.metrics.length})
+
+ onEditStep(2)}>
+ Edit
+
+
+ {wizardState.metrics.length > 0 ? (
+ wizardState.metrics.map((m) => (
+
+
+
+ {m.name}
+
+
+ {m.description}
+
+
+
+ {m.role}
+
+
+ ))
+ ) : (
+
No metrics selected
+ )}
+
+
+ {/* Step 4: Audience */}
+
+
+ Audience
+ onEditStep(3)}>
+ Edit
+
+
+
+ Targeting
+ {targetingLabel}
+
+
+ Sample size
+
+ {wizardState.audience.samplePercentage}% of eligible users
+
+
+
+
+ {arms.map((v) => {
+ const w = weightFor(v.id)
+ if (w === 0) return null
+ return (
+
+ )
+ })}
+
+
+ {arms.map((v) => (
+
+
+ {v.name}: {weightFor(v.id)}%
+
+ ))}
+
+
+
+
+ )
+}
+
+ReviewLaunchStep.displayName = 'ReviewLaunchStep'
+export default ReviewLaunchStep
diff --git a/frontend/web/components/experiments-v2/steps/SelectMetricsStep.scss b/frontend/web/components/experiments-v2/steps/SelectMetricsStep.scss
new file mode 100644
index 000000000000..2f2abb323be4
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/SelectMetricsStep.scss
@@ -0,0 +1,58 @@
+.select-metrics-step {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-xl);
+
+ &__toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ &__search {
+ flex: 1;
+
+ .input-container {
+ width: 100%;
+ }
+ }
+
+ &__list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+ padding: 48px 24px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ text-align: center;
+
+ svg,
+ .icon {
+ color: var(--color-icon-disabled);
+ }
+ }
+
+ &__empty-title {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__empty-desc {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+}
diff --git a/frontend/web/components/experiments-v2/steps/SelectMetricsStep.tsx b/frontend/web/components/experiments-v2/steps/SelectMetricsStep.tsx
new file mode 100644
index 000000000000..ea5b610d654e
--- /dev/null
+++ b/frontend/web/components/experiments-v2/steps/SelectMetricsStep.tsx
@@ -0,0 +1,189 @@
+import React, { FC, useMemo, useState } from 'react'
+import Banner from 'components/Banner/Banner'
+import Input from 'components/base/forms/Input'
+import Button from 'components/base/forms/Button'
+import EmptyState from 'components/EmptyState'
+import CreateMetricForm from 'components/experiments-v2/shared/CreateMetricForm'
+import SelectableCard from 'components/experiments-v2/shared/SelectableCard'
+import {
+ Metric,
+ MetricDirection,
+ MetricRole,
+ MOCK_METRICS,
+} from 'components/experiments-v2/types'
+
+const directionTag = (d: MetricDirection): string => {
+ if (d === 'higher-better') return '↑ higher is better'
+ if (d === 'lower-better') return '↓ lower is better'
+ return '— neither'
+}
+import './SelectMetricsStep.scss'
+
+type SelectMetricsStepProps = {
+ availableMetrics?: Metric[]
+ selectedMetrics: Metric[]
+ onToggleMetric: (metric: Metric) => void
+ onSetRole?: (metricId: string, role: MetricRole) => void
+}
+
+const SelectMetricsStep: FC = ({
+ availableMetrics: availableMetricsProp = MOCK_METRICS,
+ onSetRole,
+ onToggleMetric,
+ selectedMetrics,
+}) => {
+ const [search, setSearch] = useState('')
+ const [isCreating, setIsCreating] = useState(false)
+ const [createdMetrics, setCreatedMetrics] = useState([])
+
+ const availableMetrics = useMemo(
+ () => [...availableMetricsProp, ...createdMetrics],
+ [availableMetricsProp, createdMetrics],
+ )
+
+ const filteredMetrics = useMemo(() => {
+ if (!search) return availableMetrics
+ const lower = search.toLowerCase()
+ return availableMetrics.filter(
+ (m) =>
+ m.name.toLowerCase().includes(lower) ||
+ m.description.toLowerCase().includes(lower),
+ )
+ }, [search, availableMetrics])
+
+ const isSelected = (metric: Metric) =>
+ selectedMetrics.some((m) => m.id === metric.id)
+
+ const getRole = (metric: Metric) =>
+ selectedMetrics.find((m) => m.id === metric.id)?.role
+
+ const getRoleSelector = (metric: Metric) => {
+ const role = getRole(metric)
+ if (!isSelected(metric) || !role || !onSetRole) return undefined
+ return {
+ onChange: (v: MetricRole) => onSetRole(metric.id, v),
+ options: [
+ { label: 'Primary', value: 'primary' as MetricRole },
+ { label: 'Secondary', value: 'secondary' as MetricRole },
+ { label: 'Guardrail', value: 'guardrail' as MetricRole },
+ ],
+ value: role,
+ }
+ }
+
+ const primaryCount = selectedMetrics.filter(
+ (m) => m.role === 'primary',
+ ).length
+
+ const handleCreate = (metric: Metric) => {
+ setCreatedMetrics((prev) => [...prev, metric])
+ onToggleMetric(metric)
+ setIsCreating(false)
+ setSearch('')
+ }
+
+ if (isCreating) {
+ return (
+
+ setIsCreating(false)}
+ onSubmit={handleCreate}
+ />
+
+ )
+ }
+
+ if (availableMetrics.length === 0) {
+ return (
+
+ setIsCreating(true)}
+ >
+ Create Metric
+
+ }
+ />
+
+ )
+ }
+
+ return (
+
+
+
+ ) =>
+ setSearch(e.target.value)
+ }
+ placeholder='Search metrics...'
+ search
+ />
+
+
setIsCreating(true)}
+ >
+ Create Metric
+
+
+
+ {primaryCount > 1 && (
+
+
+ You have {primaryCount} primary metrics. Best practice is{' '}
+ one primary metric to avoid multiple-comparisons
+ issues — pick the single metric you most want to move and demote the
+ rest to secondary.
+
+
+ )}
+
+ {filteredMetrics.length > 0 ? (
+
+ {filteredMetrics.map((metric) => (
+ onToggleMetric(metric)}
+ />
+ ))}
+
+ ) : (
+
setIsCreating(true)}
+ >
+ Create Metric
+
+ }
+ />
+ )}
+
+ )
+}
+
+SelectMetricsStep.displayName = 'SelectMetricsStep'
+export default SelectMetricsStep
diff --git a/frontend/web/components/experiments-v2/types.ts b/frontend/web/components/experiments-v2/types.ts
new file mode 100644
index 000000000000..87d1841981d4
--- /dev/null
+++ b/frontend/web/components/experiments-v2/types.ts
@@ -0,0 +1,626 @@
+// =============================================================================
+// Experiments V2 — Types & Mock Data
+// =============================================================================
+
+// -----------------------------------------------------------------------------
+// Enums & Primitives
+// -----------------------------------------------------------------------------
+
+export type ExperimentStatus = 'running' | 'paused' | 'completed' | 'draft'
+
+export type WizardStepStatus = 'done' | 'active' | 'upcoming'
+
+export type MetricRole = 'primary' | 'secondary' | 'guardrail'
+
+export type LiftDirection = 'positive' | 'negative' | 'neutral'
+
+/** Mock total identity count for the current environment, used to express
+ * each segment's share as a percentage of the environment. */
+export const MOCK_ENVIRONMENT_USER_COUNT = 100000
+
+// -----------------------------------------------------------------------------
+// Wizard State
+// -----------------------------------------------------------------------------
+
+export type ExperimentDetails = {
+ name: string
+ hypothesis: string
+ startDate: string
+ endDate: string
+}
+
+export type MeasurementType = 'count' | 'occurrence' | 'value'
+export type MetricDirection = 'higher-better' | 'lower-better' | 'neither'
+
+/**
+ * How a metric is actually collected from the data warehouse.
+ *
+ * In the prototype this is mock — the warehouse path is:
+ * `{type}://{database}.{schema}.{table}` with an optional event name
+ * (for count/occurrence metrics) or value column (for value metrics),
+ * plus an optional SQL-style filter.
+ *
+ * When the metric is first created it may be left unconfigured — in that
+ * case the library shows a "Source not configured" warning.
+ */
+export type MetricSource = {
+ warehouse: 'snowflake' | 'bigquery' | 'databricks'
+ /** e.g. "EVENTS" or "PAGE_VIEWS" inside the connected schema. */
+ table: string
+ /** For count/occurrence metrics — the event name to match. */
+ eventName?: string
+ /** For value metrics — the numeric column to aggregate. */
+ valueColumn?: string
+ /** Optional SQL-style WHERE clause shown as plain text. */
+ filter?: string
+}
+
+export type Metric = {
+ id: string
+ name: string
+ description: string
+ role: MetricRole
+ measurementType: MeasurementType
+ direction: MetricDirection
+ usageCount: number
+ lastUpdated: string
+ source?: MetricSource
+}
+
+export type Variation = {
+ id: string
+ name: string
+ description: string
+ value: string
+ colour: string
+}
+
+/** Sentinel armId used for the implicit control arm in weight maps. */
+export const CONTROL_ARM_ID = '__control__'
+
+export type ArmWeight = {
+ /** CONTROL_ARM_ID for control, otherwise a variation id. */
+ armId: string
+ weight: number
+}
+
+/** Preset values for the sample-size dial (% of eligible audience in
+ * the experiment). 100 means the full eligible audience is in. */
+export const SAMPLE_SIZE_PRESETS = [5, 10, 25, 50, 100] as const
+
+export type AudienceConfig = {
+ /** Optional targeting filter. null = all identities in the environment.
+ * When set, only users matching the segment are eligible for the
+ * experiment; everyone else sees the flag's environment default. */
+ segmentId: string | null
+ /** Percentage of eligible identities sampled into the experiment.
+ * 100 means the full eligible audience is in; lower values let you
+ * ramp without exposing every user. Defaulted to 100 in v1. */
+ samplePercentage: number
+ /** Per-arm weights for sampled users. Must sum to 100. */
+ weights: ArmWeight[]
+}
+
+export type ExistingSegmentOverride = {
+ segmentId: string
+ segmentLabel: string
+ priority: number
+ weights: ArmWeight[]
+}
+
+export type ExperimentWizardState = {
+ currentStep: number
+ details: ExperimentDetails
+ metrics: Metric[]
+ featureFlagId: string | null
+ controlValue: string
+ variations: Variation[]
+ audience: AudienceConfig
+}
+
+// -----------------------------------------------------------------------------
+// Wizard Step Definition (for sidebar)
+// -----------------------------------------------------------------------------
+
+export type WizardStepDef = {
+ title: string
+ subtitle: string
+ completeSummary?: string
+}
+
+// -----------------------------------------------------------------------------
+// Results Page
+// -----------------------------------------------------------------------------
+
+export type MetricComparison = {
+ name: string
+ role: MetricRole
+ control: string
+ treatment: string
+ lift: string
+ liftValue: number
+ liftDirection: LiftDirection
+ significance: string
+ isSignificant: boolean
+}
+
+export type MetricTrendPoint = {
+ day: number
+ control: number
+ treatment: number
+}
+
+export type MetricTrend = {
+ metricName: string
+ unit: '%' | '$' | 's'
+ data: MetricTrendPoint[]
+}
+
+export type ExperimentResultSummary = {
+ id: string
+ name: string
+ status: ExperimentStatus
+ daysCurrent: number
+ daysTotal: number
+ primaryMetric: string
+ lastUpdated: string
+ usersEnrolled: number
+ winningVariation: string
+ probabilityToBest: number
+ liftVsControl: number
+ metrics: MetricComparison[]
+ metricTrends: MetricTrend[]
+}
+
+// -----------------------------------------------------------------------------
+// Experiments List / Table
+// -----------------------------------------------------------------------------
+
+export type ExperimentListItem = {
+ id: string
+ name: string
+ linkedFlag: string
+ status: ExperimentStatus
+ variations: number
+ primaryMetric: string
+ lastUpdated: string
+}
+
+export const MOCK_EXPERIMENTS: ExperimentListItem[] = [
+ {
+ id: 'exp-1',
+ lastUpdated: '2 hours ago',
+ linkedFlag: 'checkout_redesign',
+ name: 'Checkout Flow v2',
+ primaryMetric: 'Checkout Conversion',
+ status: 'running',
+ variations: 2,
+ },
+ {
+ id: 'exp-2',
+ lastUpdated: '1 day ago',
+ linkedFlag: 'pricing_cta_test',
+ name: 'Pricing Page CTA',
+ primaryMetric: 'Revenue per User',
+ status: 'paused',
+ variations: 3,
+ },
+ {
+ id: 'exp-3',
+ lastUpdated: '3 days ago',
+ linkedFlag: 'onboard_wizard_v3',
+ name: 'Onboarding Wizard',
+ primaryMetric: 'Signup Completion',
+ status: 'completed',
+ variations: 2,
+ },
+ {
+ id: 'exp-4',
+ lastUpdated: '5 days ago',
+ linkedFlag: 'search_algo_v2',
+ name: 'Search Algorithm',
+ primaryMetric: 'Click-through Rate',
+ status: 'running',
+ variations: 3,
+ },
+ {
+ id: 'exp-5',
+ lastUpdated: '1 week ago',
+ linkedFlag: 'dark_mode_flag',
+ name: 'Dark Mode Default',
+ primaryMetric: 'Page Load Time',
+ status: 'draft',
+ variations: 2,
+ },
+ {
+ id: 'exp-6',
+ lastUpdated: '2 weeks ago',
+ linkedFlag: 'notif_timing',
+ name: 'Notification Timing',
+ primaryMetric: 'Open Rate',
+ status: 'completed',
+ variations: 2,
+ },
+ {
+ id: 'exp-7',
+ lastUpdated: '3 weeks ago',
+ linkedFlag: 'trial_banner_exp',
+ name: 'Free Trial Banner',
+ primaryMetric: 'Trial Conversion',
+ status: 'completed',
+ variations: 2,
+ },
+]
+
+// -----------------------------------------------------------------------------
+// Flag Detail — Linked Experiment
+// -----------------------------------------------------------------------------
+
+export type LinkedExperiment = {
+ id: string
+ name: string
+ status: ExperimentStatus
+ primaryMetric: string
+ runningSince: string
+ trafficSplit: string
+ sampleProgress: number
+ sampleTarget: number
+}
+
+// -----------------------------------------------------------------------------
+// Dropdown Options
+// -----------------------------------------------------------------------------
+
+export type FlagOption = {
+ value: string
+ label: string
+ // Experiments can only run on multi-variant flags — single-variant flags
+ // are filtered out of the selector.
+ isMultiVariant: boolean
+ // The flag's base value. In Flagsmith's model, control is a single field
+ // on the flag, not an entry in the variations list.
+ controlValue: string
+ // Additional values the flag splits across. Experiments compare these
+ // against the control value.
+ variations: Variation[]
+ // Environment default weights for this flag. When no segment override
+ // matches, identities hit these weights.
+ envDefaultWeights: ArmWeight[]
+ // Existing segment overrides already configured on this flag. An experiment
+ // adds another override on top of these — priority order matters.
+ existingSegmentOverrides: ExistingSegmentOverride[]
+}
+
+export type SegmentOption = {
+ value: string
+ label: string
+ description?: string
+ estimatedUsers: number
+}
+
+// =============================================================================
+// Mock Data
+// =============================================================================
+
+export const MOCK_METRICS: Metric[] = [
+ {
+ description: 'Percentage of users completing checkout',
+ direction: 'higher-better',
+ id: 'met-1',
+ lastUpdated: '2 days ago',
+ measurementType: 'occurrence',
+ name: 'Checkout Conversion Rate',
+ role: 'primary',
+ source: {
+ eventName: 'checkout_completed',
+ table: 'EVENTS',
+ warehouse: 'snowflake',
+ },
+ usageCount: 3,
+ },
+ {
+ description: 'Average revenue generated per user session',
+ direction: 'higher-better',
+ id: 'met-2',
+ lastUpdated: '1 week ago',
+ measurementType: 'value',
+ name: 'Revenue per User',
+ role: 'secondary',
+ source: {
+ filter: "status = 'complete'",
+ table: 'TRANSACTIONS',
+ valueColumn: 'amount_usd',
+ warehouse: 'snowflake',
+ },
+ usageCount: 5,
+ },
+ {
+ description: 'Average page load time in milliseconds',
+ direction: 'lower-better',
+ id: 'met-3',
+ lastUpdated: '3 days ago',
+ measurementType: 'value',
+ name: 'Page Load Time',
+ role: 'secondary',
+ source: {
+ table: 'PAGE_VIEWS',
+ valueColumn: 'load_time_ms',
+ warehouse: 'snowflake',
+ },
+ usageCount: 2,
+ },
+ {
+ description: 'Percentage of users who abandon the checkout flow',
+ direction: 'lower-better',
+ id: 'met-4',
+ lastUpdated: '2 weeks ago',
+ measurementType: 'occurrence',
+ name: 'Cart Abandonment Rate',
+ role: 'secondary',
+ source: {
+ eventName: 'cart_abandoned',
+ table: 'EVENTS',
+ warehouse: 'snowflake',
+ },
+ usageCount: 1,
+ },
+ {
+ description: 'Average time users spend in a single session',
+ direction: 'higher-better',
+ id: 'met-5',
+ lastUpdated: '1 month ago',
+ measurementType: 'value',
+ name: 'Session Duration',
+ role: 'secondary',
+ source: {
+ table: 'SESSIONS',
+ valueColumn: 'duration_seconds',
+ warehouse: 'snowflake',
+ },
+ usageCount: 0,
+ },
+]
+
+export const MOCK_VARIATIONS: Variation[] = [
+ {
+ colour: 'var(--purple-500)',
+ description:
+ 'New checkout button with updated styling and CTA copy for improved conversion',
+ id: 'var-2',
+ name: 'Treatment B',
+ value: 'false',
+ },
+]
+
+const MOCK_SEARCH_VARIATIONS: Variation[] = [
+ {
+ colour: 'var(--purple-500)',
+ description: 'ML-ranked results with personalised signals',
+ id: 'var-search-2',
+ name: 'Treatment B',
+ value: 'v2',
+ },
+ {
+ colour: 'var(--orange-500)',
+ description: 'Hybrid ranker combining popularity and personalisation',
+ id: 'var-search-3',
+ name: 'Treatment C',
+ value: 'v3',
+ },
+]
+
+export const MOCK_FLAGS: FlagOption[] = [
+ {
+ controlValue: 'true',
+ envDefaultWeights: [
+ { armId: CONTROL_ARM_ID, weight: 100 },
+ { armId: 'var-2', weight: 0 },
+ ],
+ existingSegmentOverrides: [
+ {
+ priority: 1,
+ segmentId: 'seg-3',
+ segmentLabel: 'Premium Tier',
+ weights: [
+ { armId: CONTROL_ARM_ID, weight: 0 },
+ { armId: 'var-2', weight: 100 },
+ ],
+ },
+ ],
+ isMultiVariant: true,
+ label: 'checkout_button_redesign',
+ value: 'flag-1',
+ variations: MOCK_VARIATIONS,
+ },
+ {
+ controlValue: 'false',
+ envDefaultWeights: [{ armId: CONTROL_ARM_ID, weight: 100 }],
+ existingSegmentOverrides: [],
+ isMultiVariant: false,
+ label: 'new_pricing_page',
+ value: 'flag-2',
+ variations: [],
+ },
+ {
+ controlValue: 'v1',
+ envDefaultWeights: [
+ { armId: CONTROL_ARM_ID, weight: 100 },
+ { armId: 'var-search-2', weight: 0 },
+ { armId: 'var-search-3', weight: 0 },
+ ],
+ existingSegmentOverrides: [],
+ isMultiVariant: true,
+ label: 'search_algorithm_v2',
+ value: 'flag-3',
+ variations: MOCK_SEARCH_VARIATIONS,
+ },
+ {
+ controlValue: 'false',
+ envDefaultWeights: [{ armId: CONTROL_ARM_ID, weight: 100 }],
+ existingSegmentOverrides: [],
+ isMultiVariant: false,
+ label: 'onboarding_flow',
+ value: 'flag-4',
+ variations: [],
+ },
+ {
+ controlValue: 'current',
+ envDefaultWeights: [{ armId: CONTROL_ARM_ID, weight: 100 }],
+ existingSegmentOverrides: [],
+ isMultiVariant: true,
+ label: 'homepage_hero_redesign',
+ value: 'flag-5',
+ variations: [],
+ },
+]
+
+export const CONTROL_COLOUR = 'var(--green-500)'
+export const MIN_VARIATIONS_FOR_EXPERIMENT = 1
+
+export const MOCK_SEGMENTS: SegmentOption[] = [
+ {
+ description: 'Users accessing the product from a mobile device',
+ estimatedUsers: 48000,
+ label: 'Mobile Users',
+ value: 'seg-2',
+ },
+ {
+ description: 'Accounts on the Premium plan',
+ estimatedUsers: 12000,
+ label: 'Premium Tier',
+ value: 'seg-3',
+ },
+ {
+ description: 'Opt-in early access testers',
+ estimatedUsers: 3200,
+ label: 'Beta Testers',
+ value: 'seg-4',
+ },
+]
+
+const seedNoise = (seed: number): number => {
+ const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
+ return x - Math.floor(x)
+}
+
+const buildSeries = (
+ baseControl: number,
+ baseTreatment: number,
+ variance: number,
+ precision: number,
+ days: number,
+ offset: number,
+): MetricTrendPoint[] =>
+ Array.from({ length: days }, (_, i) => {
+ const controlNoise = (seedNoise(i + offset) - 0.5) * 2 * variance
+ const treatmentNoise = (seedNoise(i + offset + 500) - 0.5) * 2 * variance
+ const round = (v: number) =>
+ Math.round(v * 10 ** precision) / 10 ** precision
+ return {
+ control: round(baseControl + baseControl * controlNoise),
+ day: i + 1,
+ treatment: round(baseTreatment + baseTreatment * treatmentNoise),
+ }
+ })
+
+const buildMockTrends = (): MetricTrend[] => [
+ {
+ data: buildSeries(7.2, 8.3, 0.08, 2, 23, 10),
+ metricName: 'Conversion Rate',
+ unit: '%',
+ },
+ {
+ data: buildSeries(24.1, 27.5, 0.07, 2, 23, 20),
+ metricName: 'Revenue per User',
+ unit: '$',
+ },
+ {
+ data: buildSeries(2.4, 2.3, 0.05, 2, 23, 30),
+ metricName: 'Page Load Time',
+ unit: 's',
+ },
+]
+
+export const MOCK_EXPERIMENT_RESULT: ExperimentResultSummary = {
+ daysCurrent: 23,
+ daysTotal: 30,
+ id: 'exp-1',
+ lastUpdated: '2 min ago',
+ liftVsControl: 18.3,
+ metricTrends: buildMockTrends(),
+ metrics: [
+ {
+ control: '7.2%',
+ isSignificant: true,
+ lift: '+15.3%',
+ liftDirection: 'positive',
+ liftValue: 15.3,
+ name: 'Conversion Rate',
+ role: 'primary',
+ significance: 'p<0.01',
+ treatment: '8.3%',
+ },
+ {
+ control: '$24.10',
+ isSignificant: true,
+ lift: '+14.1%',
+ liftDirection: 'positive',
+ liftValue: 14.1,
+ name: 'Revenue per User',
+ role: 'secondary',
+ significance: 'p<0.05',
+ treatment: '$27.50',
+ },
+ {
+ control: '2.4s',
+ isSignificant: false,
+ lift: '-4.2%',
+ liftDirection: 'neutral',
+ liftValue: -4.2,
+ name: 'Page Load Time',
+ role: 'guardrail',
+ significance: 'not significant',
+ treatment: '2.3s',
+ },
+ ],
+ name: 'Checkout Button Redesign',
+ primaryMetric: 'Conversion Rate',
+ probabilityToBest: 94.2,
+ status: 'running',
+ usersEnrolled: 12847,
+ winningVariation: 'Treatment B',
+}
+
+export const MOCK_LINKED_EXPERIMENT: LinkedExperiment = {
+ id: 'exp-1',
+ name: 'Checkout Button A/B Test',
+ primaryMetric: 'Conversion Rate',
+ runningSince: 'Mar 15, 2026 (23 days)',
+ sampleProgress: 6800,
+ sampleTarget: 10000,
+ status: 'running',
+ trafficSplit: '50% / 50%',
+}
+
+export const EXPERIMENT_WIZARD_STEPS: WizardStepDef[] = [
+ {
+ subtitle: 'Name your experiment and describe your hypothesis',
+ title: 'Experiment Details',
+ },
+ {
+ subtitle: 'Select the multi-variant flag to experiment on',
+ title: 'Flag & Variations',
+ },
+ {
+ subtitle: 'Choose primary and secondary metrics to measure',
+ title: 'Select Metrics',
+ },
+ {
+ subtitle: 'Choose the audience and how to split traffic',
+ title: 'Audience',
+ },
+ {
+ subtitle: 'Review your configuration and launch',
+ title: 'Review & Launch',
+ },
+]
diff --git a/frontend/web/components/experiments-v2/wizard/WizardHeader.scss b/frontend/web/components/experiments-v2/wizard/WizardHeader.scss
new file mode 100644
index 000000000000..790d870c60f9
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardHeader.scss
@@ -0,0 +1,39 @@
+.wizard-header {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ &__breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ &__breadcrumb-item {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+
+ &:last-child {
+ color: var(--color-text-default);
+ }
+ }
+
+ &__breadcrumb-separator {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-disabled);
+ }
+
+ &__title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__title {
+ font-family: var(--font-family);
+ font-size: var(--font-h4-size);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+ margin: 0;
+ }
+}
diff --git a/frontend/web/components/experiments-v2/wizard/WizardHeader.tsx b/frontend/web/components/experiments-v2/wizard/WizardHeader.tsx
new file mode 100644
index 000000000000..a5a85cf358f1
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardHeader.tsx
@@ -0,0 +1,44 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import './WizardHeader.scss'
+
+type BreadcrumbItem = {
+ label: string
+ href?: string
+}
+
+type WizardHeaderProps = {
+ breadcrumbs: BreadcrumbItem[]
+ title: string
+ onCancel: () => void
+}
+
+const WizardHeader: FC = ({
+ breadcrumbs,
+ onCancel,
+ title,
+}) => {
+ return (
+
+
+ {breadcrumbs.map((item, index) => (
+
+ {item.label}
+ {index < breadcrumbs.length - 1 && (
+ /
+ )}
+
+ ))}
+
+
+
{title}
+
+ Cancel
+
+
+
+ )
+}
+
+WizardHeader.displayName = 'WizardHeader'
+export default WizardHeader
diff --git a/frontend/web/components/experiments-v2/wizard/WizardLayout.scss b/frontend/web/components/experiments-v2/wizard/WizardLayout.scss
new file mode 100644
index 000000000000..ea3d4effc0b2
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardLayout.scss
@@ -0,0 +1,17 @@
+.wizard-layout {
+ display: flex;
+ gap: 48px;
+ width: 100%;
+
+ &__sidebar {
+ flex-shrink: 0;
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ flex: 1;
+ min-width: 0;
+ }
+}
diff --git a/frontend/web/components/experiments-v2/wizard/WizardLayout.tsx b/frontend/web/components/experiments-v2/wizard/WizardLayout.tsx
new file mode 100644
index 000000000000..7c2bad1f11f1
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardLayout.tsx
@@ -0,0 +1,19 @@
+import React, { FC, ReactNode } from 'react'
+import './WizardLayout.scss'
+
+type WizardLayoutProps = {
+ sidebar: ReactNode
+ children: ReactNode
+}
+
+const WizardLayout: FC = ({ children, sidebar }) => {
+ return (
+
+ )
+}
+
+WizardLayout.displayName = 'WizardLayout'
+export default WizardLayout
diff --git a/frontend/web/components/experiments-v2/wizard/WizardNavButtons.scss b/frontend/web/components/experiments-v2/wizard/WizardNavButtons.scss
new file mode 100644
index 000000000000..320f37991436
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardNavButtons.scss
@@ -0,0 +1,13 @@
+.wizard-nav-buttons {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 12px;
+ padding-top: 16px;
+ border-top: 1px solid var(--color-border-default);
+
+ .btn svg {
+ margin-left: 4px;
+ margin-right: 4px;
+ }
+}
diff --git a/frontend/web/components/experiments-v2/wizard/WizardNavButtons.tsx b/frontend/web/components/experiments-v2/wizard/WizardNavButtons.tsx
new file mode 100644
index 000000000000..1a69a34f5ad3
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardNavButtons.tsx
@@ -0,0 +1,54 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import { IconName } from 'components/icons/Icon'
+import './WizardNavButtons.scss'
+
+type WizardNavButtonsProps = {
+ onBack?: () => void
+ onContinue: () => void
+ isFirstStep?: boolean
+ isLastStep?: boolean
+ continueDisabled?: boolean
+ continueLabel?: string
+ continueIcon?: IconName
+}
+
+const WizardNavButtons: FC = ({
+ continueDisabled = false,
+ continueIcon,
+ continueLabel,
+ isFirstStep = false,
+ isLastStep = false,
+ onBack,
+ onContinue,
+}) => {
+ const label = continueLabel || (isLastStep ? 'Launch Experiment' : 'Continue')
+ const icon = continueIcon || (isLastStep ? 'rocket' : undefined)
+
+ return (
+
+ {!isFirstStep && onBack && (
+
+ Back
+
+ )}
+
+ {label}
+
+
+ )
+}
+
+WizardNavButtons.displayName = 'WizardNavButtons'
+export default WizardNavButtons
diff --git a/frontend/web/components/experiments-v2/wizard/WizardSidebar.scss b/frontend/web/components/experiments-v2/wizard/WizardSidebar.scss
new file mode 100644
index 000000000000..7b4b5083922e
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardSidebar.scss
@@ -0,0 +1,6 @@
+.wizard-sidebar {
+ display: flex;
+ flex-direction: column;
+ width: 220px;
+ flex-shrink: 0;
+}
diff --git a/frontend/web/components/experiments-v2/wizard/WizardSidebar.tsx b/frontend/web/components/experiments-v2/wizard/WizardSidebar.tsx
new file mode 100644
index 000000000000..d1a707aa7f07
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardSidebar.tsx
@@ -0,0 +1,45 @@
+import React, { FC } from 'react'
+import WizardStepIndicator from './WizardStepIndicator'
+import {
+ WizardStepDef,
+ WizardStepStatus,
+} from 'components/experiments-v2/types'
+import './WizardSidebar.scss'
+
+type WizardSidebarProps = {
+ steps: WizardStepDef[]
+ currentStep: number
+ onStepClick?: (step: number) => void
+}
+
+function getStepStatus(index: number, currentStep: number): WizardStepStatus {
+ if (index < currentStep) return 'done'
+ if (index === currentStep) return 'active'
+ return 'upcoming'
+}
+
+const WizardSidebar: FC = ({
+ currentStep,
+ onStepClick,
+ steps,
+}) => {
+ return (
+
+ {steps.map((step, index) => (
+ onStepClick?.(index) : undefined}
+ />
+ ))}
+
+ )
+}
+
+WizardSidebar.displayName = 'WizardSidebar'
+export default WizardSidebar
diff --git a/frontend/web/components/experiments-v2/wizard/WizardStepIndicator.scss b/frontend/web/components/experiments-v2/wizard/WizardStepIndicator.scss
new file mode 100644
index 000000000000..16aee2ad1576
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardStepIndicator.scss
@@ -0,0 +1,121 @@
+.wizard-step-indicator {
+ display: flex;
+ gap: 20px;
+
+ &--done {
+ cursor: pointer;
+
+ .wizard-step-indicator__circle {
+ background: var(--color-surface-success);
+ border: 2px solid var(--green-500);
+ color: var(--color-text-success);
+ }
+
+ .wizard-step-indicator__title {
+ color: var(--color-text-default);
+ }
+ }
+
+ &--active {
+ .wizard-step-indicator__circle {
+ border: 2px solid var(--color-border-action);
+ color: var(--color-text-action);
+ }
+
+ .wizard-step-indicator__title {
+ color: var(--color-text-default);
+ }
+
+ .wizard-step-indicator__connector {
+ border-color: var(--color-border-default);
+ }
+ }
+
+ &--upcoming {
+ .wizard-step-indicator__circle {
+ border: 2px solid var(--color-border-default);
+ color: var(--color-text-disabled);
+ }
+
+ .wizard-step-indicator__title {
+ color: var(--color-text-disabled);
+ }
+ }
+
+ &__left {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 32px;
+ flex-shrink: 0;
+ }
+
+ &__circle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--radius-full);
+ flex-shrink: 0;
+ }
+
+ &__number {
+ font-family: var(--font-family);
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-semibold);
+ }
+
+ &__connector {
+ width: 2px;
+ flex: 1;
+ min-height: 24px;
+ background: var(--color-border-default);
+ }
+
+ &__right {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 4px 0 24px;
+ flex: 1;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ &__title {
+ font-family: var(--font-family);
+ font-size: var(--font-h6-size);
+ font-weight: var(--font-weight-semibold);
+ }
+
+ &__badge {
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-success);
+ background: var(--color-surface-success);
+ padding: 3px 10px;
+ border-radius: var(--radius-full);
+ }
+
+ &__summary {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__description {
+ font-size: var(--font-body-size);
+ color: var(--color-text-secondary);
+ line-height: 1.5;
+ }
+
+ &__subtitle {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-disabled);
+ }
+}
diff --git a/frontend/web/components/experiments-v2/wizard/WizardStepIndicator.tsx b/frontend/web/components/experiments-v2/wizard/WizardStepIndicator.tsx
new file mode 100644
index 000000000000..5ea3e3c82923
--- /dev/null
+++ b/frontend/web/components/experiments-v2/wizard/WizardStepIndicator.tsx
@@ -0,0 +1,76 @@
+import React, { FC } from 'react'
+import Icon from 'components/icons/Icon'
+import { WizardStepStatus } from 'components/experiments-v2/types'
+import './WizardStepIndicator.scss'
+
+type WizardStepIndicatorProps = {
+ stepNumber: number
+ title: string
+ subtitle?: string
+ status: WizardStepStatus
+ completeSummary?: string
+ showConnector?: boolean
+ onClick?: () => void
+}
+
+const WizardStepIndicator: FC = ({
+ completeSummary,
+ onClick,
+ showConnector = true,
+ status,
+ stepNumber,
+ subtitle,
+ title,
+}) => {
+ const isClickable = status === 'done' && !!onClick
+
+ return (
+ {
+ if (e.key === 'Enter' || e.key === ' ') onClick?.()
+ }
+ : undefined
+ }
+ >
+
+
+ {status === 'done' ? (
+
+ ) : (
+ {stepNumber}
+ )}
+
+ {showConnector &&
}
+
+
+
+
+ {title}
+ {status === 'done' && (
+ Complete
+ )}
+
+ {status === 'done' && completeSummary && (
+
+ {completeSummary}
+
+ )}
+ {status === 'active' && subtitle && (
+
{subtitle}
+ )}
+ {status === 'upcoming' && subtitle && (
+
{subtitle}
+ )}
+
+
+ )
+}
+
+WizardStepIndicator.displayName = 'WizardStepIndicator'
+export default WizardStepIndicator
diff --git a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx
index 3dccf9503c7d..3181d0cc0f67 100644
--- a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx
+++ b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx
@@ -79,13 +79,22 @@ const EnvironmentNavbar: FC = ({
Features
{Utils.getFlagsmithHasFeature('experimental_flags') && (
-
- Experiments
-
+ <>
+
+ Experiments
+
+
+ Metrics
+
+ >
)}
{
- const routeContext = useRouteContext()
- const projectId = routeContext.projectId!
-
- const { data: tags, isLoading } = useGetTagsQuery({ projectId })
-
- if (isLoading) {
- return (
-
-
-
- )
- }
-
- const experimentTag = tags?.find(
- (t) => t.label.toLowerCase() === 'experiment',
- )
-
- if (!experimentTag) {
- return (
-
-
Experiments
-
You don't have any experiment running at the moment
-
- )
- }
-
return (
-
+
+
+
)
}
diff --git a/frontend/web/components/warehouse/WarehousePage.scss b/frontend/web/components/warehouse/WarehousePage.scss
new file mode 100644
index 000000000000..6368e29e0a41
--- /dev/null
+++ b/frontend/web/components/warehouse/WarehousePage.scss
@@ -0,0 +1,105 @@
+.warehouse-page {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ padding: 32px 40px;
+ min-height: 100vh;
+
+ &__breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ &__breadcrumb-item {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__breadcrumb-link {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-action);
+ text-decoration: none;
+ transition: color var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ text-decoration: underline;
+ color: var(--color-text-action);
+ }
+ }
+
+ &__breadcrumb-sep {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-disabled);
+ }
+
+ &__breadcrumb-current {
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__subtitle {
+ font-size: var(--font-body-size);
+ color: var(--color-text-secondary);
+ margin: 0;
+ max-width: 600px;
+ }
+
+ &__demo-switcher {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+ padding: 8px 12px;
+ background: var(--color-surface-warning-subtle, #fff7e6);
+ border: 1px dashed var(--color-border-warning, #f2c078);
+ border-radius: var(--radius-md);
+ }
+
+ &__demo-switcher-label {
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-warning, #b36b00);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-right: 4px;
+ }
+
+ &__demo-switcher-pill {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-medium);
+ padding: 4px 10px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-full);
+ background: var(--color-surface-default);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition:
+ background var(--duration-fast) var(--easing-standard),
+ color var(--duration-fast) var(--easing-standard),
+ border-color var(--duration-fast) var(--easing-standard);
+
+ &:hover {
+ border-color: var(--color-border-action);
+ color: var(--color-text-default);
+ }
+
+ &--active {
+ background: var(--color-surface-action);
+ color: var(--color-surface-default);
+ border-color: var(--color-surface-action);
+
+ &:hover {
+ background: var(--color-surface-action);
+ color: var(--color-surface-default);
+ }
+ }
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ }
+}
diff --git a/frontend/web/components/warehouse/WarehousePage.tsx b/frontend/web/components/warehouse/WarehousePage.tsx
new file mode 100644
index 000000000000..28345b293a8e
--- /dev/null
+++ b/frontend/web/components/warehouse/WarehousePage.tsx
@@ -0,0 +1,219 @@
+import React, { FC, useCallback, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import Utils from 'common/utils/utils'
+import EmptyState from './components/EmptyState'
+import ConfigForm from './components/ConfigForm'
+import PendingSetupState from './components/PendingSetupState'
+import TestingState from './components/TestingState'
+import ConnectedState from './components/ConnectedState'
+import ErrorState from './components/ErrorState'
+import {
+ ConnectionState,
+ MOCK_CONFIG,
+ MOCK_CONNECTION_DETAILS,
+ MOCK_ERROR,
+ MOCK_PUBLIC_KEY,
+ MOCK_SETUP_SCRIPT,
+ MOCK_STATS,
+ WarehouseConfig,
+} from './types'
+import './WarehousePage.scss'
+
+const VALID_STATES: ConnectionState[] = [
+ 'empty',
+ 'configuring',
+ 'pending_customer_setup',
+ 'testing',
+ 'connected',
+ 'error',
+]
+
+const STATE_LABELS: Record = {
+ configuring: 'Configuring',
+ connected: 'Connected',
+ empty: 'Empty',
+ error: 'Error',
+ pending_customer_setup: 'Pending setup',
+ testing: 'Testing',
+}
+
+type WarehousePageProps = {
+ initialState?: ConnectionState
+}
+
+const WarehousePage: FC = ({ initialState }) => {
+ const { organisationId } = useParams<{ organisationId?: string }>()
+ const integrationsUrl = organisationId
+ ? `/organisation/${organisationId}/integrations`
+ : undefined
+ const params = Utils.fromParam() as Record
+ const stateFromUrl = VALID_STATES.includes(params.state as ConnectionState)
+ ? (params.state as ConnectionState)
+ : undefined
+ const showDemoSwitcher = params.demo === '1'
+ const [connectionState, setConnectionState] = useState(
+ initialState ?? stateFromUrl ?? 'empty',
+ )
+ /**
+ * Distinguishes "creating a new connection" from "editing the existing one"
+ * while both share the `configuring` state. Used by ConfigForm to lock
+ * immutable fields (type + accountIdentifier) on edit per issue #7276.
+ */
+ const [isEditingExisting, setIsEditingExisting] = useState(false)
+ /**
+ * Where to return when the user cancels out of the edit form — the state
+ * they were in before clicking Edit. `empty` when they're creating fresh.
+ */
+ const [cancelReturnState, setCancelReturnState] =
+ useState('empty')
+
+ const handleConnect = useCallback(() => {
+ setIsEditingExisting(false)
+ setCancelReturnState('empty')
+ setConnectionState('configuring')
+ }, [])
+
+ const handleConnectSubmit = useCallback((_config: WarehouseConfig) => {
+ // Real API: POST returns the generated public_key + setup_script and sets
+ // status to pending_customer_setup until the customer runs the SQL and
+ // clicks "Test Connection".
+ setConnectionState('pending_customer_setup')
+ }, [])
+
+ const handleTestConnection = useCallback(() => {
+ setConnectionState('testing')
+ setTimeout(() => {
+ setConnectionState(Math.random() > 0.3 ? 'connected' : 'error')
+ }, 3000)
+ }, [])
+
+ const handleEdit = useCallback(() => {
+ setIsEditingExisting(true)
+ setCancelReturnState(connectionState === 'error' ? 'error' : 'connected')
+ setConnectionState('configuring')
+ }, [connectionState])
+
+ const handleRetry = useCallback(() => {
+ setConnectionState('testing')
+ setTimeout(() => {
+ setConnectionState(Math.random() > 0.3 ? 'connected' : 'error')
+ }, 3000)
+ }, [])
+
+ const handleDisconnect = useCallback(() => {
+ setConnectionState('empty')
+ }, [])
+
+ const handleShowSetupScript = useCallback(() => {
+ setConnectionState('pending_customer_setup')
+ }, [])
+
+ const handleCancel = useCallback(() => {
+ setConnectionState(cancelReturnState)
+ }, [cancelReturnState])
+
+ const renderState = () => {
+ switch (connectionState) {
+ case 'empty':
+ return
+ case 'configuring':
+ return (
+
+ )
+ case 'pending_customer_setup':
+ return (
+
+ )
+ case 'testing':
+ return
+ case 'connected':
+ return (
+
+ )
+ case 'error':
+ return (
+
+ )
+ default:
+ return null
+ }
+ }
+
+ return (
+
+
+ {integrationsUrl ? (
+
+ Organisation Integrations
+
+ ) : (
+
+ Organisation Integrations
+
+ )}
+ /
+
+ Data Warehouse
+
+
+
+ {showDemoSwitcher && (
+
+
+ Demo state
+
+ {VALID_STATES.map((s) => (
+ setConnectionState(s)}
+ >
+ {STATE_LABELS[s]}
+
+ ))}
+
+ )}
+
+ {(connectionState === 'connected' || connectionState === 'error') && (
+
+ Stream flag evaluation and custom event data to your warehouse for
+ experimentation and analysis.
+
+ )}
+
+
{renderState()}
+
+ )
+}
+
+WarehousePage.displayName = 'WarehousePage'
+export default WarehousePage
diff --git a/frontend/web/components/warehouse/components/ConfigForm.scss b/frontend/web/components/warehouse/components/ConfigForm.scss
new file mode 100644
index 000000000000..5c4b64de7ba5
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ConfigForm.scss
@@ -0,0 +1,103 @@
+@use '../../../styles/animations';
+
+.wh-config-form {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ &__section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__section-label {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ letter-spacing: 0.5px;
+ }
+
+ &__type-row {
+ display: flex;
+ gap: 12px;
+ }
+
+ &__type-card {
+ position: relative;
+ width: 180px;
+
+ &--disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+ }
+
+ &__coming-soon {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ font-size: var(--font-caption-xs-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-warning);
+ background: var(--color-surface-warning);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ }
+
+ &__card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 24px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ }
+
+ &__field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1;
+ }
+
+ &__label {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ }
+
+ &__row {
+ display: flex;
+ gap: 16px;
+ }
+
+ &__hint {
+ font-size: var(--font-caption-xs-size);
+ color: var(--color-text-tertiary);
+
+ code {
+ font-size: inherit;
+ padding: 1px 4px;
+ background: var(--color-surface-muted);
+ border-radius: var(--radius-sm);
+ color: var(--color-text-secondary);
+ }
+ }
+
+ &__note {
+ padding: 12px 14px;
+ background: var(--color-surface-info-subtle, var(--color-surface-muted));
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ }
+}
diff --git a/frontend/web/components/warehouse/components/ConfigForm.tsx b/frontend/web/components/warehouse/components/ConfigForm.tsx
new file mode 100644
index 000000000000..7aa5354f8210
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ConfigForm.tsx
@@ -0,0 +1,184 @@
+import React, { FC, useState } from 'react'
+import Button from 'components/base/forms/Button'
+import Input from 'components/base/forms/Input'
+import SelectableCard from 'components/experiments-v2/shared/SelectableCard'
+import {
+ MOCK_CONFIG,
+ WAREHOUSE_TYPES,
+ WarehouseConfig,
+ WarehouseType,
+} from 'components/warehouse/types'
+import './ConfigForm.scss'
+
+type ConfigFormProps = {
+ /** Commits the config and transitions to the pending-setup state. */
+ onConnect: (config: WarehouseConfig) => void
+ onCancel: () => void
+ /**
+ * Editing an existing connection. `type` and `accountIdentifier` become
+ * read-only — per the API they're immutable (PATCH rejects changes).
+ * Changing either means disconnecting and starting over.
+ */
+ isEdit?: boolean
+ /** Prefilled config when editing. Ignored when `isEdit` is false. */
+ initialConfig?: WarehouseConfig
+}
+
+const ConfigForm: FC = ({
+ initialConfig,
+ isEdit = false,
+ onCancel,
+ onConnect,
+}) => {
+ const [config, setConfig] = useState(
+ initialConfig ?? MOCK_CONFIG,
+ )
+ const [selectedType, setSelectedType] = useState(config.type)
+
+ const updateField = (field: keyof WarehouseConfig, value: string) => {
+ setConfig((prev) => ({ ...prev, [field]: value }))
+ }
+
+ return (
+
+
+
Warehouse Type
+
+ {WAREHOUSE_TYPES.map((wh) => {
+ const locked = isEdit && wh.type !== selectedType
+ const cardDisabled = !wh.available || isEdit
+ return (
+
+ {
+ if (wh.available && !isEdit) setSelectedType(wh.type)
+ }}
+ />
+ {!wh.available && !locked && (
+
+ Coming Soon
+
+ )}
+
+ )
+ })}
+
+ {isEdit && (
+
+ Warehouse type can't be changed. To move to a different
+ provider, disconnect and create a new connection.
+
+ )}
+
+
+
+
+ Account Identifier
+ ) =>
+ updateField('accountIdentifier', e.target.value)
+ }
+ placeholder='xy12345.us-east-1'
+ disabled={isEdit}
+ />
+
+ {isEdit
+ ? "Identifier can't be changed. To move to a different Snowflake account, disconnect and re-connect."
+ : 'The Snowflake account identifier — the part of your Snowflake URL before .snowflakecomputing.com.'}
+
+
+
+
+
+
+
+
+ User
+ ) =>
+ updateField('user', e.target.value)
+ }
+ placeholder='FLAGSMITH_SERVICE'
+ />
+
+
+ {!isEdit && (
+
+ Flagsmith generates an RSA key pair on save — no passwords or
+ private keys to type. You'll get a setup script to run in
+ Snowflake after saving.
+
+ )}
+
+
+
+ Cancel
+
+ onConnect(config)}
+ >
+ {isEdit ? 'Save changes' : 'Save and continue'}
+
+
+
+
+ )
+}
+
+ConfigForm.displayName = 'WarehouseConfigForm'
+export default ConfigForm
diff --git a/frontend/web/components/warehouse/components/ConnectedState.scss b/frontend/web/components/warehouse/components/ConnectedState.scss
new file mode 100644
index 000000000000..ff661975de9d
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ConnectedState.scss
@@ -0,0 +1,107 @@
+.wh-connected {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &__status-card {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 24px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-success);
+ border-radius: var(--radius-lg);
+ }
+
+ &__status-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__info {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ &__icon-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ background: var(--color-surface-muted);
+ border-radius: var(--radius-lg);
+
+ svg {
+ color: var(--color-icon-action);
+ }
+ }
+
+ &__name-col {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__name {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__account {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__divider {
+ height: 1px;
+ background: var(--color-border-default);
+ }
+
+ &__stats {
+ display: flex;
+ gap: 16px;
+ }
+
+ &__stat {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 16px 20px;
+ background: var(--color-surface-muted);
+ border-radius: var(--radius-md);
+ flex: 1;
+ }
+
+ &__stat-label {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__stat-value {
+ font-size: var(--font-h5-size);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+ }
+
+ &__stat-sub {
+ font-size: var(--font-caption-xs-size);
+ color: var(--color-text-tertiary);
+
+ &--success {
+ color: var(--color-text-success);
+ }
+ }
+
+ &__actions {
+ display: flex;
+ gap: 10px;
+
+ .btn svg {
+ margin-right: 4px;
+ }
+ }
+}
diff --git a/frontend/web/components/warehouse/components/ConnectedState.tsx b/frontend/web/components/warehouse/components/ConnectedState.tsx
new file mode 100644
index 000000000000..6b841519ae5e
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ConnectedState.tsx
@@ -0,0 +1,107 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import Icon from 'components/icons/Icon'
+import StatusBadge from 'components/experiments-v2/shared/StatusBadge'
+import ConnectionDetailsGrid from './ConnectionDetailsGrid'
+import {
+ ConnectionDetail,
+ ConnectionStats,
+ MOCK_CONFIG,
+} from 'components/warehouse/types'
+import './ConnectedState.scss'
+
+type ConnectedStateProps = {
+ stats: ConnectionStats
+ details: ConnectionDetail[]
+ onEdit: () => void
+ onDisconnect: () => void
+}
+
+const ConnectedState: FC = ({
+ details,
+ onDisconnect,
+ onEdit,
+ stats,
+}) => {
+ return (
+
+ {/* Status card */}
+
+
+
+
+
+
+
+ Snowflake
+
+ {MOCK_CONFIG.accountIdentifier}
+
+
+
+
+
+
+
+
+
+
+
+ Last Successful Delivery
+
+
+ {stats.lastDelivery}
+
+
+ {stats.lastDeliveryDate}
+
+
+
+
+ Flag Evaluations (24h)
+
+
+ {stats.flagEvaluations24h.toLocaleString()}
+
+
+ {stats.flagEvaluationsTrend}
+
+
+
+
+ Custom Events (24h)
+
+
+ {stats.customEvents24h.toLocaleString()}
+
+
+ {stats.customEventsTrend}
+
+
+
+
+
+ {/* Connection details */}
+
+
+ {/* Actions */}
+
+
+ Edit Connection
+
+
+ Disconnect
+
+
+
+ )
+}
+
+ConnectedState.displayName = 'WarehouseConnectedState'
+export default ConnectedState
diff --git a/frontend/web/components/warehouse/components/ConnectionDetailsGrid.scss b/frontend/web/components/warehouse/components/ConnectionDetailsGrid.scss
new file mode 100644
index 000000000000..4a8f3457f412
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ConnectionDetailsGrid.scss
@@ -0,0 +1,46 @@
+.wh-details-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 24px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+
+ &__title {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__rows {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0;
+
+ &--bordered {
+ border-bottom: 1px solid var(--color-border-default);
+ }
+ }
+
+ &__label {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__value {
+ font-family: var(--font-family);
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-default);
+
+ &--masked {
+ color: var(--color-text-tertiary);
+ }
+ }
+}
diff --git a/frontend/web/components/warehouse/components/ConnectionDetailsGrid.tsx b/frontend/web/components/warehouse/components/ConnectionDetailsGrid.tsx
new file mode 100644
index 000000000000..e0896238dcfd
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ConnectionDetailsGrid.tsx
@@ -0,0 +1,37 @@
+import React, { FC } from 'react'
+import { ConnectionDetail } from 'components/warehouse/types'
+import './ConnectionDetailsGrid.scss'
+
+type ConnectionDetailsGridProps = {
+ details: ConnectionDetail[]
+}
+
+const ConnectionDetailsGrid: FC = ({ details }) => {
+ return (
+
+
Connection Details
+
+ {details.map((detail, index) => (
+
+ {detail.label}
+
+ {detail.value}
+
+
+ ))}
+
+
+ )
+}
+
+ConnectionDetailsGrid.displayName = 'ConnectionDetailsGrid'
+export default ConnectionDetailsGrid
diff --git a/frontend/web/components/warehouse/components/EmptyState.scss b/frontend/web/components/warehouse/components/EmptyState.scss
new file mode 100644
index 000000000000..0ae8683d05d6
--- /dev/null
+++ b/frontend/web/components/warehouse/components/EmptyState.scss
@@ -0,0 +1,30 @@
+.wh-empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 20px;
+ padding: 80px 40px;
+ text-align: center;
+ flex: 1;
+
+ > svg,
+ > .icon {
+ color: var(--color-icon-secondary);
+ }
+
+ &__heading {
+ font-size: var(--font-h5-size);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-default);
+ margin: 0;
+ }
+
+ &__text {
+ font-size: var(--font-body-size);
+ color: var(--color-text-secondary);
+ max-width: 420px;
+ line-height: 1.5;
+ margin: 0;
+ }
+}
diff --git a/frontend/web/components/warehouse/components/EmptyState.tsx b/frontend/web/components/warehouse/components/EmptyState.tsx
new file mode 100644
index 000000000000..35b8436d1942
--- /dev/null
+++ b/frontend/web/components/warehouse/components/EmptyState.tsx
@@ -0,0 +1,27 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import Icon from 'components/icons/Icon'
+import './EmptyState.scss'
+
+type EmptyStateProps = {
+ onConnect: () => void
+}
+
+const EmptyState: FC = ({ onConnect }) => {
+ return (
+
+
+
No warehouse connected
+
+ Stream flag evaluation and custom event data to your data warehouse for
+ experimentation and analysis.
+
+
+ Connect Data Warehouse
+
+
+ )
+}
+
+EmptyState.displayName = 'WarehouseEmptyState'
+export default EmptyState
diff --git a/frontend/web/components/warehouse/components/ErrorState.scss b/frontend/web/components/warehouse/components/ErrorState.scss
new file mode 100644
index 000000000000..6c5cfa81fc68
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ErrorState.scss
@@ -0,0 +1,147 @@
+.wh-error {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &__card {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 24px;
+ background: var(--color-surface-subtle);
+ border: 1px solid var(--color-border-danger);
+ border-radius: var(--radius-lg);
+ }
+
+ &__top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__info {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+
+ &__icon-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ background: var(--color-surface-danger);
+ border-radius: var(--radius-lg);
+
+ svg {
+ color: var(--color-icon-danger);
+ }
+ }
+
+ &__name-col {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__name {
+ font-size: var(--font-body-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__account {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: var(--radius-full);
+ background: var(--color-surface-danger);
+ color: var(--color-text-danger);
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-semibold);
+
+ svg {
+ color: var(--color-icon-danger);
+ }
+ }
+
+ &__divider {
+ height: 1px;
+ background: var(--color-border-default);
+ }
+
+ &__message-box {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px 20px;
+ background: var(--color-surface-danger);
+ border-radius: var(--radius-md);
+ }
+
+ &__message-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__message-label {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-danger);
+ }
+
+ &__message-date {
+ font-size: var(--font-caption-xs-size);
+ color: var(--color-text-tertiary);
+ }
+
+ &__message-text {
+ font-family: var(--font-family);
+ font-size: var(--font-caption-size);
+ color: var(--color-text-danger);
+ line-height: 1.5;
+ margin: 0;
+ }
+
+ &__last-ok {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ &__last-ok-label {
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ }
+
+ &__last-ok-value {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--font-body-sm-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ }
+
+ &__last-ok-date {
+ font-size: var(--font-caption-size);
+ color: var(--color-text-tertiary);
+ }
+
+ &__actions {
+ display: flex;
+ gap: 10px;
+
+ .btn svg {
+ margin-right: 4px;
+ }
+ }
+}
diff --git a/frontend/web/components/warehouse/components/ErrorState.tsx b/frontend/web/components/warehouse/components/ErrorState.tsx
new file mode 100644
index 000000000000..a6c9b604295d
--- /dev/null
+++ b/frontend/web/components/warehouse/components/ErrorState.tsx
@@ -0,0 +1,103 @@
+import React, { FC } from 'react'
+import Button from 'components/base/forms/Button'
+import Icon from 'components/icons/Icon'
+import ConnectionDetailsGrid from './ConnectionDetailsGrid'
+import {
+ ConnectionDetail,
+ ConnectionError,
+ MOCK_CONFIG,
+} from 'components/warehouse/types'
+import './ErrorState.scss'
+
+type ErrorStateProps = {
+ error: ConnectionError
+ details: ConnectionDetail[]
+ /** Re-run the test without changing anything. */
+ onRetry: () => void
+ /** Go back to the setup-script view — useful if the customer needs to
+ * re-copy the SQL or confirm they ran it correctly. */
+ onShowSetupScript: () => void
+ onEdit: () => void
+}
+
+const ErrorState: FC = ({
+ details,
+ error,
+ onEdit,
+ onRetry,
+ onShowSetupScript,
+}) => {
+ return (
+
+ {/* Error card */}
+
+
+
+
+
+
+
+ Snowflake
+
+ {MOCK_CONFIG.accountIdentifier}
+
+
+
+
+
+ Connection Failed
+
+
+
+
+
+
+
+ Last Error
+ {error.timestamp}
+
+
{error.message}
+
+
+
+
+
+
+ Last successful delivery
+
+
+ {error.lastSuccessful}
+
+ {error.lastSuccessfulDate}
+
+
+
+
+
+ {/* Connection details */}
+
+
+ {/* Actions */}
+
+
+ Retry test
+
+
+ Show setup script
+
+
+ Edit Connection
+
+
+
+ )
+}
+
+ErrorState.displayName = 'WarehouseErrorState'
+export default ErrorState
diff --git a/frontend/web/components/warehouse/components/PendingSetupState.scss b/frontend/web/components/warehouse/components/PendingSetupState.scss
new file mode 100644
index 000000000000..e5b23d98c5b6
--- /dev/null
+++ b/frontend/web/components/warehouse/components/PendingSetupState.scss
@@ -0,0 +1,144 @@
+.wh-pending-setup {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ &__header {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+ }
+
+ &__icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ background: var(--color-surface-success, var(--color-surface-muted));
+ border-radius: var(--radius-full);
+ color: var(--color-icon-success);
+ }
+
+ &__title {
+ margin: 0 0 4px 0;
+ font-size: var(--font-body-lg-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__subtitle {
+ margin: 0;
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+ max-width: 720px;
+
+ code {
+ padding: 1px 5px;
+ background: var(--color-surface-muted);
+ border-radius: var(--radius-sm);
+ font-size: inherit;
+ color: var(--color-text-default);
+ }
+ }
+
+ &__script {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-subtle);
+ overflow: hidden;
+ }
+
+ &__script-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ background: var(--color-surface-muted);
+ border-bottom: 1px solid var(--color-border-default);
+ }
+
+ &__script-title {
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ }
+
+ &__script-body {
+ margin: 0;
+ padding: 16px;
+ font-family: var(--font-family-mono, ui-monospace, SFMono-Regular, monospace);
+ font-size: var(--font-caption-size);
+ line-height: 1.5;
+ color: var(--color-text-default);
+ white-space: pre;
+ overflow-x: auto;
+ max-height: 360px;
+ }
+
+ &__public-key {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-md);
+ padding: 10px 14px;
+ background: var(--color-surface-default);
+ }
+
+ &__public-key-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ font-size: var(--font-caption-size);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-default);
+ cursor: pointer;
+
+ svg {
+ color: var(--color-icon-secondary);
+ }
+ }
+
+ &__public-key-hint {
+ font-weight: var(--font-weight-regular);
+ color: var(--color-text-tertiary);
+ font-size: var(--font-caption-xs-size);
+ }
+
+ &__public-key-body {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-top: 4px;
+ }
+
+ &__public-key-value {
+ margin: 0;
+ padding: 10px 12px;
+ background: var(--color-surface-muted);
+ border: 1px solid var(--color-border-default);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-family-mono, ui-monospace, SFMono-Regular, monospace);
+ font-size: var(--font-caption-xs-size);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+ white-space: pre-wrap;
+ word-break: break-all;
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ }
+}
diff --git a/frontend/web/components/warehouse/components/PendingSetupState.tsx b/frontend/web/components/warehouse/components/PendingSetupState.tsx
new file mode 100644
index 000000000000..32a2ff877553
--- /dev/null
+++ b/frontend/web/components/warehouse/components/PendingSetupState.tsx
@@ -0,0 +1,103 @@
+import React, { FC, useState } from 'react'
+import Button from 'components/base/forms/Button'
+import Icon from 'components/icons/Icon'
+import Utils from 'common/utils/utils'
+import './PendingSetupState.scss'
+
+type PendingSetupStateProps = {
+ /** The server-generated setup SQL the customer needs to run in Snowflake. */
+ setupScript: string
+ /** The server-generated RSA public key, registered by the setup script. */
+ publicKey: string
+ /** Customer has run the script in Snowflake and wants Flagsmith to verify. */
+ onTestConnection: () => void
+ /** Jump back to the config form to change fields. */
+ onEdit: () => void
+}
+
+const PendingSetupState: FC = ({
+ onEdit,
+ onTestConnection,
+ publicKey,
+ setupScript,
+}) => {
+ const [showPublicKey, setShowPublicKey] = useState(false)
+
+ return (
+
+
+
+
+
+
+
+ Connection saved — now set up Snowflake
+
+
+ Flagsmith generated a service account and an RSA key pair. Run the
+ setup script below in your Snowflake console with the{' '}
+ SYSADMIN role to register the public key and grant
+ Flagsmith the access it needs.
+
+
+
+
+
+
+ Setup script
+ Utils.copyToClipboard(setupScript)}
+ >
+ Copy script
+
+
+ {setupScript}
+
+
+
+ setShowPublicKey((v) => !v)}
+ >
+
+ RSA public key
+
+ already embedded in the script above
+
+
+ {showPublicKey && (
+
+
+ {publicKey}
+
+
Utils.copyToClipboard(publicKey)}
+ >
+ Copy public key
+
+
+ )}
+
+
+
+
+ Edit configuration
+
+
+ I've run the script — Test Connection
+
+
+
+ )
+}
+
+PendingSetupState.displayName = 'WarehousePendingSetupState'
+export default PendingSetupState
diff --git a/frontend/web/components/warehouse/components/TestingState.scss b/frontend/web/components/warehouse/components/TestingState.scss
new file mode 100644
index 000000000000..428ca9ea5b19
--- /dev/null
+++ b/frontend/web/components/warehouse/components/TestingState.scss
@@ -0,0 +1,59 @@
+@use '../../../styles/animations';
+
+.wh-testing {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 24px;
+ padding: 80px 40px;
+ flex: 1;
+
+ &__spinner {
+ @include animations.spinner;
+ color: var(--color-icon-action);
+ }
+
+ &__title {
+ font-size: var(--font-h6-size);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-default);
+ }
+
+ &__steps {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 280px;
+ }
+
+ &__step {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: var(--font-body-sm-size);
+ color: var(--color-text-secondary);
+
+ svg {
+ color: var(--color-icon-secondary);
+ flex-shrink: 0;
+ }
+
+ &--done {
+ color: var(--color-text-success);
+
+ svg {
+ color: var(--color-icon-success);
+ }
+ }
+ }
+
+ &__step-dot {
+ width: 16px;
+ height: 16px;
+ border-radius: var(--radius-full);
+ border: 2px solid var(--color-border-default);
+ flex-shrink: 0;
+ @include animations.animate-pulse;
+ }
+}
diff --git a/frontend/web/components/warehouse/components/TestingState.tsx b/frontend/web/components/warehouse/components/TestingState.tsx
new file mode 100644
index 000000000000..0b95745a2894
--- /dev/null
+++ b/frontend/web/components/warehouse/components/TestingState.tsx
@@ -0,0 +1,50 @@
+import React, { FC } from 'react'
+import { motion } from 'motion/react'
+import Icon from 'components/icons/Icon'
+import { staggerContainer, staggerItem } from 'common/utils/motion'
+import { TESTING_STEPS } from 'components/warehouse/types'
+import './TestingState.scss'
+
+type TestingStateProps = {
+ currentStep?: number
+}
+
+const TestingState: FC = ({
+ currentStep = TESTING_STEPS.length,
+}) => {
+ return (
+
+
+
+
+
Connecting…
+
+
+ {TESTING_STEPS.map((step, index) => (
+
+ {index < currentStep ? (
+
+ ) : (
+
+ )}
+ {step}
+
+ ))}
+
+
+ )
+}
+
+TestingState.displayName = 'WarehouseTestingState'
+export default TestingState
diff --git a/frontend/web/components/warehouse/types.ts b/frontend/web/components/warehouse/types.ts
new file mode 100644
index 000000000000..50aae7b1996e
--- /dev/null
+++ b/frontend/web/components/warehouse/types.ts
@@ -0,0 +1,169 @@
+// =============================================================================
+// Warehouse Connection — Types & Mock Data
+// =============================================================================
+
+export type WarehouseType = 'snowflake' | 'bigquery' | 'databricks'
+
+export type ConnectionState =
+ | 'empty'
+ | 'configuring'
+ | 'pending_customer_setup'
+ | 'testing'
+ | 'connected'
+ | 'error'
+
+/**
+ * Config fields the customer fills in. Mirrors issue #7276 — no credential
+ * fields; the server generates the RSA keypair and returns a public key + a
+ * setup script the customer runs in Snowflake themselves.
+ */
+export type WarehouseConfig = {
+ type: WarehouseType
+ accountIdentifier: string
+ warehouse: string
+ database: string
+ schema: string
+ role: string
+ user: string
+}
+
+export type ConnectionStats = {
+ lastDelivery: string
+ lastDeliveryDate: string
+ flagEvaluations24h: number
+ flagEvaluationsTrend: string
+ customEvents24h: number
+ customEventsTrend: string
+}
+
+export type ConnectionError = {
+ message: string
+ timestamp: string
+ lastSuccessful: string
+ lastSuccessfulDate: string
+}
+
+export type ConnectionDetail = {
+ label: string
+ value: string
+ masked?: boolean
+}
+
+export type WarehouseTypeOption = {
+ type: WarehouseType
+ icon: string
+ label: string
+ description: string
+ available: boolean
+}
+
+// =============================================================================
+// Mock Data
+// =============================================================================
+
+export const WAREHOUSE_TYPES: WarehouseTypeOption[] = [
+ {
+ available: true,
+ description: 'Cloud data warehouse',
+ icon: 'snowflake',
+ label: 'Snowflake',
+ type: 'snowflake',
+ },
+ {
+ available: false,
+ description: 'Google Cloud data warehouse',
+ icon: 'database',
+ label: 'BigQuery',
+ type: 'bigquery',
+ },
+ {
+ available: false,
+ description: 'Unified analytics platform',
+ icon: 'database',
+ label: 'Databricks',
+ type: 'databricks',
+ },
+]
+
+export const MOCK_CONFIG: WarehouseConfig = {
+ accountIdentifier: 'xy12345.us-east-1',
+ database: 'FLAGSMITH',
+ role: 'FLAGSMITH_LOADER',
+ schema: 'ANALYTICS',
+ type: 'snowflake',
+ user: 'FLAGSMITH_SERVICE',
+ warehouse: 'COMPUTE_WH',
+}
+
+export const MOCK_PUBLIC_KEY =
+ 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwK3k7mB9G8o7fQ6X4lTz' +
+ 'VpS2gI2vL5TXyBqcJ9xN3fWZpLeEgDvKwYc6M7HRqL8nT2Jf4sYh1DaPBqXFbWzC' +
+ 'oLvP8rKmRpgFq7GPdVwH5cX2bYyNhYeZ7CvIoR9N3mvXLmMBGkKQQkGVKLmN6pQL' +
+ 'fWxG2kPqBhHvWLVuF4mBzNjMqLBn2Xb5Y7KJj8DwLcLK6sGnNWk6xKwqNtnDr6vP' +
+ 'RPAJm6xKZLMdV4X9rYbQGBnNvJw4mXtLmPqF8QXNQ1VpYZmPEqC8uBxNKfLpVcLw' +
+ 'N4kZQIDAQAB'
+
+export const MOCK_SETUP_SCRIPT = `-- Flagsmith Analytics — Snowflake setup
+-- Run as a user with SYSADMIN role.
+
+USE ROLE SYSADMIN;
+
+-- Dedicated role for Flagsmith ingest
+CREATE ROLE IF NOT EXISTS FLAGSMITH_LOADER;
+
+-- Service user, no password (key-pair auth only)
+CREATE USER IF NOT EXISTS FLAGSMITH_SERVICE
+ DEFAULT_ROLE = FLAGSMITH_LOADER
+ DEFAULT_WAREHOUSE = COMPUTE_WH
+ DEFAULT_NAMESPACE = FLAGSMITH.ANALYTICS;
+
+-- Register Flagsmith's public key for authentication
+ALTER USER FLAGSMITH_SERVICE SET RSA_PUBLIC_KEY = '${MOCK_PUBLIC_KEY}';
+
+-- Database + schema for analytics ingest
+CREATE DATABASE IF NOT EXISTS FLAGSMITH;
+CREATE SCHEMA IF NOT EXISTS FLAGSMITH.ANALYTICS;
+
+-- Grants
+GRANT USAGE ON WAREHOUSE COMPUTE_WH TO ROLE FLAGSMITH_LOADER;
+GRANT USAGE ON DATABASE FLAGSMITH TO ROLE FLAGSMITH_LOADER;
+GRANT USAGE ON SCHEMA FLAGSMITH.ANALYTICS TO ROLE FLAGSMITH_LOADER;
+GRANT CREATE TABLE ON SCHEMA FLAGSMITH.ANALYTICS TO ROLE FLAGSMITH_LOADER;
+GRANT SELECT, INSERT, UPDATE, DELETE
+ ON FUTURE TABLES IN SCHEMA FLAGSMITH.ANALYTICS
+ TO ROLE FLAGSMITH_LOADER;
+
+GRANT ROLE FLAGSMITH_LOADER TO USER FLAGSMITH_SERVICE;`
+
+export const MOCK_STATS: ConnectionStats = {
+ customEvents24h: 84210,
+ customEventsTrend: '+5% vs yesterday',
+ flagEvaluations24h: 1284039,
+ flagEvaluationsTrend: '+12% vs yesterday',
+ lastDelivery: '2 minutes ago',
+ lastDeliveryDate: 'Apr 8, 2026 — 14:37 UTC',
+}
+
+export const MOCK_ERROR: ConnectionError = {
+ lastSuccessful: '3 hours ago',
+ lastSuccessfulDate: 'Apr 8, 2026 — 11:15 UTC',
+ message:
+ "JWT token authentication failed. Snowflake couldn't verify the public key registered on the FLAGSMITH_SERVICE user. Re-run the setup script to re-register the key, or check that the user exists.",
+ timestamp: 'Apr 8, 2026 — 14:22 UTC',
+}
+
+export const MOCK_CONNECTION_DETAILS: ConnectionDetail[] = [
+ { label: 'Account Identifier', value: 'xy12345.us-east-1' },
+ { label: 'Database', value: 'FLAGSMITH' },
+ { label: 'Schema', value: 'ANALYTICS' },
+ { label: 'Warehouse', value: 'COMPUTE_WH' },
+ { label: 'Role', value: 'FLAGSMITH_LOADER' },
+ { label: 'User', value: 'FLAGSMITH_SERVICE' },
+]
+
+export const TESTING_STEPS = [
+ 'Resolving hostname...',
+ 'Establishing TLS connection...',
+ 'Authenticating with key pair...',
+ 'Verifying schema access...',
+]
diff --git a/frontend/web/routes.js b/frontend/web/routes.js
index 3c526e7e9342..def99fdfa042 100644
--- a/frontend/web/routes.js
+++ b/frontend/web/routes.js
@@ -47,12 +47,16 @@ import CreateReleasePipelinePage from './components/pages/CreateReleasePipelineP
import ReleasePipelineDetailPage from './components/pages/ReleasePipelineDetailPage'
import SegmentPage from './components/pages/SegmentPage'
import ExperimentsPage from './components/pages/ExperimentsPage'
+import CreateExperimentPage from './components/experiments-v2/CreateExperimentPage'
+import ExperimentResultsPage from './components/experiments-v2/ExperimentResultsPage'
+import MetricsLibraryPage from './components/experiments-v2/MetricsLibraryPage'
import ReleaseManagerPage from './components/pages/ReleaseManagerPage'
import FlagEnvironmentsPage from './components/pages/FlagEnvironmentsPage'
import ExecutiveViewPage from './components/pages/ExecutiveViewPage'
import DevViewPage from './components/pages/DevViewPage'
import AdminDashboardPage from './components/pages/admin-dashboard/AdminDashboardPage'
import CleanupPage from './components/pages/feature-lifecycle'
+import WarehousePage from './components/warehouse/WarehousePage'
import OAuthAuthorizePage from './components/pages/OAuthAuthorizePage'
import { Provider } from 'react-redux'
import { getStore } from 'common/store'
@@ -77,7 +81,13 @@ export const routes = {
'environment-settings':
'/project/:projectId/environment/:environmentId/settings',
'executive-view': '/organisation/:organisationId/executive-view',
+ 'experiment-results':
+ '/project/:projectId/environment/:environmentId/experiments/:experimentId',
'experiments': '/project/:projectId/environment/:environmentId/experiments',
+ 'experiments-create':
+ '/project/:projectId/environment/:environmentId/experiments/create',
+ 'experiments-metrics':
+ '/project/:projectId/environment/:environmentId/experiments/metrics',
'feature-history': '/project/:projectId/environment/:environmentId/history',
'feature-history-detail':
'/project/:projectId/environment/:environmentId/history/:id/',
@@ -133,6 +143,7 @@ export const routes = {
'segment': '/project/:projectId/segments/:id',
'segments': '/project/:projectId/segments',
'signup': '/signup',
+ 'warehouse': '/organisation/:organisationId/warehouse',
}
export default (
@@ -164,6 +175,21 @@ export default (
exact
component={FlagsPage}
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/web/styles/_animations.scss b/frontend/web/styles/_animations.scss
new file mode 100644
index 000000000000..8d9ddcbf0096
--- /dev/null
+++ b/frontend/web/styles/_animations.scss
@@ -0,0 +1,107 @@
+// =============================================================================
+// Shared Keyframes & Mixins
+// Uses motion tokens from _tokens.scss. For CSS-only animations.
+// For orchestrated JS animations, use common/utils/motion.ts with the motion library.
+// =============================================================================
+
+// -----------------------------------------------------------------------------
+// Keyframes
+// -----------------------------------------------------------------------------
+
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 20% {
+ transform: translateX(-6px);
+ }
+ 40% {
+ transform: translateX(6px);
+ }
+ 60% {
+ transform: translateX(-3px);
+ }
+ 80% {
+ transform: translateX(3px);
+ }
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slide-in-up {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Mixins
+// -----------------------------------------------------------------------------
+
+/// Focus ring for interactive elements (inputs, cards, buttons)
+@mixin focus-ring {
+ border: 2px solid var(--color-border-action);
+ box-shadow: 0 0 0 3px oklch(from var(--purple-600) l c h / 0.13);
+ outline: none;
+}
+
+/// Danger focus ring (validation errors)
+@mixin focus-ring-danger {
+ border: 2px solid var(--color-border-danger);
+ box-shadow: 0 0 0 3px oklch(from var(--red-500) l c h / 0.13);
+ outline: none;
+}
+
+/// Apply shake animation (form validation errors)
+@mixin input-error-shake {
+ animation: shake 350ms var(--easing-standard);
+}
+
+/// Spinner animation
+@mixin spinner {
+ animation: spin 800ms linear infinite;
+}
+
+/// Fade in with configurable duration
+@mixin animate-fade-in($duration: var(--duration-normal)) {
+ animation: fade-in $duration var(--easing-entrance);
+}
+
+/// Slide in from below with fade
+@mixin animate-slide-in-up($duration: var(--duration-normal)) {
+ animation: slide-in-up $duration var(--easing-entrance);
+}
+
+/// Pulse (loading skeletons, pending states)
+@mixin animate-pulse {
+ animation: pulse 1.5s var(--easing-standard) infinite;
+}
diff --git a/frontend/web/styles/styles.scss b/frontend/web/styles/styles.scss
index 5537f71e9f23..0844910815c7 100644
--- a/frontend/web/styles/styles.scss
+++ b/frontend/web/styles/styles.scss
@@ -1,6 +1,7 @@
@import "variables";
@import "tokens";
@import "token-utilities";
+@import "animations";
@import "3rdParty/index";
@import "components/index";
@import "flexbox/index";