---
title: Banner experiments
description: Run A/B tests on consent banner presentation with any feature-flag
  provider or built-in weighted assignment, and attribute every impression and
  choice to its arm.
group: guides
lastModified: "2026-10-10T16:01:45+01:00"
---
## Vary presentation, not policy

An experiment changes only how the prompt and the preference center look:
variant, position, layout, primary actions, blocking. The policy rule, its
categories and its copy stay the same for every arm, so every arm records a
choice under the same policy fingerprint.

Your normal `presentation` is the `control` arm; without one, `control` is
the stock banner. Every other arm lists only what it changes. Add the
experiment to the provider you already have and pass the arm your feature flag
resolved. In the React example, the `Consent` wrapper takes the arm as a prop:

```tsx title="src/consent.tsx"
import { defineExperiment } from 'c15t';
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentProvider,
	hosted,
} from 'c15t/react';
import type { ConsentProviderCallbacks } from 'c15t/react';
import type { ReactNode } from 'react';

import { scripts } from './scripts';

const mode = hosted({ backendURL: 'https://your-project.inth.app' });

// `control` is the stock banner. `wall` blocks the page until the visitor
// chooses.
const bannerExperiment = defineExperiment({
	arms: { wall: { prompt: { variant: 'wall' } } },
	id: 'banner-shape',
});

const pushToDataLayer = (event: Record<string, unknown>) => {
	const page = window as Window & { dataLayer?: unknown[] };
	page.dataLayer ??= [];
	page.dataLayer.push(event);
};

// Forward each impression and choice made under an arm to your analytics.
const callbacks = {
	onChoiceRecorded: ({ consentAction, experiment }) => {
		if (experiment) {
			pushToDataLayer({
				arm: experiment.arm,
				consent_action: consentAction,
				event: 'c15t_choice_recorded',
				experiment_id: experiment.id,
			});
		}
	},
	onSurfaceShown: ({ experiment, surface }) => {
		if (experiment) {
			pushToDataLayer({
				arm: experiment.arm,
				event: 'c15t_surface_shown',
				experiment_id: experiment.id,
				surface,
			});
		}
	},
} satisfies ConsentProviderCallbacks;

export const Consent = ({
	arm,
	children,
}: {
	/**
	 * The arm your flag provider resolved. Omit it to let c15t pick, or pass
	 * `off` to leave this visitor out of the experiment.
	 */
	arm?: 'control' | 'wall' | 'off';
	children: ReactNode;
}) => (
	<ConsentProvider
		options={{
			callbacks,
			experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
			mode,
			scripts,
		}}
	>
		{children}
		<ConsentBanner />
		<ConsentDialog />
		<footer>
			<ConsentDialogLink>Privacy settings</ConsentDialogLink>
		</footer>
	</ConsentProvider>
);
```

`defineExperiment()` from `c15t` returns its argument and has TypeScript check
the arm names in `arm` and `split`. The callbacks forward each impression and
choice to `window.dataLayer`; see
[send the events to your own analytics](#send-the-events-to-your-own-analytics-too).

Your flag provider returns a plain string. To check it before passing it as
`arm`, type it with `ExperimentArmName` from `c15t`, which is `control` plus
the experiment's arm names:

```ts
import type { ExperimentArmName } from 'c15t';

// 'control' | 'wall'
type BannerArm = ExperimentArmName<typeof bannerExperiment>;

const armFromFlag = (value: string | undefined): BannerArm | undefined =>
	value === 'control' || value === 'wall' ? value : undefined;
```

Omit `arm` to let c15t pick, and set `split` to weight the arms:

```ts
experiment: { ...bannerExperiment, split: { control: 60, wall: 40 } }
```

|Field|Purpose|
|--|--|
|`id`|Stable experiment name, recorded with every choice|
|`arms`|What each arm changes, merged over `presentation`. `control` is your `presentation` and is not listed|
|`arm`|The arm your flag resolved: `control` or a key of `arms`|
|`split`|Relative weights when c15t picks the arm; default equal. Keys are `control` and the arms|
|`acknowledgeDiagnostics`|Run an arm that trips a presentation diagnostic and record that you reviewed it|

c15t merges the arm over `presentation`, exposes it as `snapshot.experiment`,
sends it with `/init`, and records it on the choices of visitors the banner
showed it to, so you can compare opt-in rate and time to decision per arm.

The same option exists in `c15t/next` (`ConsentRoot` `options`), `c15t/vue`,
`@c15t/svelte`, the `c15t()` Astro integration from `c15t/astro`, and
`@c15t/browser`. On a server-rendered page in Next.js, TanStack Start or
SvelteKit, pass it to `resolveConsent` instead, which counts the arm and hands
it to the client; see [Vercel Flags SDK](#vercel-flags-sdk).

`experiment` is read once, when the provider mounts. Changing it later has
no effect; remount the provider (a `key` in React) to switch experiments.

Assignment and arm validation load as a separate chunk, only on pages that
set `experiment`. A site without an experiment does not download them.

An experiment compares choices, so the banner has to ask about at least one
category. Under a rule with `scopeMode: 'permissive'`, the banner asks only
about the categories your site declares through `consentCategories`,
`scripts` or `vendors`. With none declared, it asks about none: accepting or rejecting records a notice
acknowledgement instead of a choice, `onChoiceRecorded` never fires, and the
experiment counts impressions without choices. Outside production, c15t logs
a warning the first time the banner shows an arm in that state.

### Astro

The Astro banner is server-rendered HTML that the browser only shows or
hides, so the arm is resolved on the server. To pick it per request, set
`middleware: false` in `c15t()` and compose the consent middleware yourself
with `experimentArm`, where `bannerExperimentFlag` stands for your flag lookup:

```ts title="src/middleware.ts"
import { consentMiddleware } from 'c15t/astro/middleware';

export const onRequest = consentMiddleware({
	experimentArm: async (context) => await bannerExperimentFlag(context),
});
```

Return `undefined` to run no experiment for that request. A fixed
`experiment.arm` in `c15t()` puts every visitor in one arm, which only
suits a staged rollout. Prerendered routes render once for every visitor, so
they get no per-request arm. `c15t()` throws at config time when
`experiment` has neither.

### Vary the theme

An arm can carry `theme` overrides next to its presentation fragment. They
merge over the host `theme` one token group deep, so an arm can change one
colour or radius and keep the rest of your palette. Arrays and scalars are
replaced.

```tsx
experiment: {
	id: 'button-style',
	arms: {
		bold: {
			theme: {
				colors: { primary: '#0a0a0a' },
				radius: { lg: '4px' },
			},
		},
	},
}
```

Read the merged theme with `useResolvedTheme()` in React,
`useResolvedTheme(theme)` in Vue, `getConsentManager().theme` in Svelte and
`client.theme` in `@c15t/browser`. Astro renders the arm's tokens with the
banner. In React, `consentActions` and slot overrides apply through the
provider, but colour, radius and other tokens reach the page through
`ConsentTheme`. Render it from the resolved theme in a client component inside
the provider, with both imported from `c15t/react`:

```tsx
<ConsentTheme theme={useResolvedTheme()} />;
```

Rendered from a client component, `ConsentTheme` ships the theme generator.
With an arm from your flag, you can instead render
`<ConsentTheme theme={resolveExperimentTheme(theme, experiment, { arm })} />`
from a Server Component. Per-action styling through
`theme.consentActions` runs through the same prominence check as
presentation: an arm that fills accept and outlines reject trips
`equivalent-prominence-overridden` and needs `acknowledgeDiagnostics: true`.

## Resolve the arm with a flag provider

Resolve the arm wherever your flags live and pass its name as `arm`. c15t
records `assignedBy: 'host'` and never re-assigns a visitor you assigned.

### Vercel Flags SDK

Give the flag three values. `off` keeps a visitor out of the test; `control`
and `wall` split the rest. Set the weights in the Vercel dashboard, so
you can roll out and widen the test without a deploy: start at 90% `off`,
check that both arms arrive, then move to 0% `off` for the real run.

```ts title="src/flags.ts"
import { vercelAdapter } from '@flags-sdk/vercel';
import { flag } from 'flags/next';

export const bannerExperimentFlag = flag<'off' | 'control' | 'wall'>({
	key: 'banner-shape',
	adapter: vercelAdapter(),
	identify, // your existing identify
	defaultValue: 'off',
});
```

Keep `identify` returning a stable id. Vercel splits on it, so a visitor
keeps their arm when you change the weights.

Resolve the flag where consent resolves and pass the experiment to
`resolveConsent`. The server reports the arm with `/init`, and the returned
state carries the experiment to `ConsentRoot`, so the client needs no
`experiment` option of its own. In Next.js, do this in the root layout:

```tsx title="app/layout.tsx"
import { resolveConsent } from 'c15t/next/server';
import { Suspense } from 'react';
import type { ReactNode } from 'react';

import { ExperimentConsent } from '@/components/experiment-consent';
import { bannerExperiment } from '@/lib/experiment';
import { bannerExperimentFlag } from '@/lib/flags';

import '@/styles/globals.css';

const ResolvedConsent = async ({ children }: { children: ReactNode }) => {
	const arm = await bannerExperimentFlag();
	const state = await resolveConsent({
		// `off` keeps this visitor out of the experiment.
		experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
	});

	return <ExperimentConsent state={state}>{children}</ExperimentConsent>;
};

const RootLayout = ({ children }: { children: ReactNode }) => (
	<html lang="en">
		<body>
			<Suspense fallback={null}>
				<ResolvedConsent>{children}</ResolvedConsent>
			</Suspense>
		</body>
	</html>
);

export default RootLayout;
```

`@/lib/flags` exports the flag above. `ExperimentConsent` is the `Consent`
wrapper from the
[App Router guide](/docs/frameworks/next/app-router) with the event callbacks
from [send the events to your own analytics](#send-the-events-to-your-own-analytics-too)
in `options`. The layout awaits consent inside `<Suspense>`, as in
[render the banner in the server HTML](/docs/frameworks/next/app-router#render-the-banner-in-the-server-html),
so the banner is in the server HTML already showing the visitor's arm.
`resolveConsent` in `c15t/tanstack-start/server` and `@c15t/svelte/server`
takes the same `experiment`.

If you pass `resolveConsent()` to `ConsentRoot` without awaiting it (the
streaming layout), the client mounts before the state arrives. Pass the
same experiment to the client options as well; c15t warns in development
when the streamed state carries an experiment the client did not get.

### PostHog

PostHog resolves flags asynchronously in the browser. Because `experiment`
is read once at mount, wait for the flags, then mount the provider with the
resolved arm. Do not wait forever: a blocked or slow flags request would
leave the visitor with no banner and you with no consent. After a second,
mount without the experiment. Those visitors see `presentation` and are not
counted in either arm. This component wraps the `Consent` component from
[the first example](#vary-presentation-not-policy):

```tsx title="src/flagged-consent.tsx"
import posthog from 'posthog-js';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';

import { Consent } from './consent';

export const FlaggedConsent = ({ children }: { children: ReactNode }) => {
	const [arm, setArm] = useState<'control' | 'wall' | 'off' | null>(null);
	useEffect(() => {
		// The first answer wins: the provider reads the arm once.
		const fallback = setTimeout(
			() => setArm((current) => current ?? 'off'),
			1000
		);
		const unsubscribe = posthog.onFeatureFlags(() => {
			const flag = posthog.getFeatureFlag('banner-shape');
			setArm((current) => current ?? (flag === 'wall' ? 'wall' : 'control'));
		});
		return () => {
			clearTimeout(fallback);
			unsubscribe();
		};
	}, []);
	if (arm === null) {
		// Flags not loaded yet: no provider, no banner.
		return children;
	}
	return <Consent arm={arm}>{children}</Consent>;
};
```

### LaunchDarkly, GrowthBook, Statsig

```ts
const arm = ldClient.stringVariation('banner-shape', 'control');
const arm = growthbook.getFeatureValue('banner-shape', 'control');
const arm = StatsigClient.instance()
	.getExperiment('banner-shape')
	.get('arm', 'control');
```

An `arm` that is not `control` or a key of `arms` logs an error and runs no
experiment for that visitor; the page still renders. Treat that as a flag
misconfiguration.

## Let c15t assign the arm

Omit `arm` and c15t picks one by `split` (equal by default) when the page
starts, before `/init`, and records `assignedBy: 'c15t'`. A visitor who
already saw an arm keeps it.

The banner waits until the arm is picked, so the visitor never sees the base
banner swap for their arm. On a server-rendered page that means the banner is
not in the server HTML; it appears once the browser has loaded the
assignment chunk. If the chunk fails to load, the base banner shows and no
experiment runs. To keep the banner in the server HTML, resolve the arm on
the server and pass it as `arm`.

Once the banner has shown the arm, c15t stores `{ id, arm }` under
`c15t-experiment-v1` in localStorage (a cookie when localStorage is
unavailable), so the visitor keeps seeing the banner they saw. Nothing is
stored for a visitor who is never prompted, and nothing is stored for a
host-resolved arm: your flag provider decides that one on every visit. The
record holds no identifier. It exists only to keep the consent banner
consistent, so treat it like the consent record itself when you describe
your storage.

A `split` that gives no arm a positive weight, or that names an arm that does
not exist, logs an error and runs no experiment. An arm missing from the
split gets no visitors.

```ts
experiment: {
	id: 'banner-shape',
	arms: { wall: { prompt: { variant: 'wall' } } },
	split: { control: 60, wall: 40 },
}
```

Changing `id` starts a new experiment and re-assigns everyone. Removing an
arm re-assigns only the visitors who were in it. For a clean analysis, change
`id` rather than editing the arms of a running experiment.

Built-in assignment is not available in Astro; see
[Astro](#astro).

## Acknowledge presentation diagnostics

Each arm is resolved under the visitor's policy the same way `presentation`
is. An arm that trips a diagnostic, for example
`equivalent-prominence-overridden` because it makes accept primary while
reject stays neutral, is not shown under that policy: those visitors see the
base presentation, are not counted in the experiment, and c15t logs the
diagnostics. Set `acknowledgeDiagnostics: true` to run the arm anyway. c15t
then logs the diagnostics as a warning once per policy and records
`acknowledgedDiagnostics: true` with the arm on every choice. You own the
legal review of that arm; c15t records that you made it.

The check runs in the browser once the policy is known, so catch a rejected
arm before you deploy with `validateExperiment` in a test. `euPolicy` and
`presentation` stand for the policy and presentation your site uses:

```ts
import { validateExperiment } from 'c15t/experiment';

test('banner-shape arms pass under the EU policy', () => {
	validateExperiment(bannerExperiment, euPolicy, { presentation });
});
```

It throws for an invalid definition and for arms with unacknowledged
diagnostics.

## Read the assignment

```tsx
import {
	useExperiment,
	useResolvedPresentation,
	useResolvedTheme,
} from 'c15t/react';

const { id, arm, assignedBy } = useExperiment() ?? {};
const presentation = useResolvedPresentation();
const theme = useResolvedTheme();
```

React exposes three hooks:

* `useExperiment()` returns the assigned arm (`id`, `arm`, `assignedBy`,
  `acknowledgedDiagnostics`), or `null` while no experiment is configured
  or no arm is assigned yet.
* `useResolvedPresentation()` returns `presentation` with the assigned
  arm merged over it per surface. While no arm is assigned it returns
  `presentation` itself. The stock banner, dialog and widget render from it,
  as do `usePromptPresentation()` and `usePreferencesPresentation()`.
* `useResolvedTheme()` returns `theme` with the arm's `theme` merged one
  token group deep over it. While no arm is assigned, or the arm has no
  `theme`, it returns `theme` itself. The provider injects this merged theme.

Built-in assignment lands after mount, so `useExperiment()` is `null` on the
server render and during hydration and the resolved hooks return the base
values. The banner is held until then, and a clean preferences draft reseeds
from the arm's `preferences.defaults` when it lands. An
arm from your flag is known from the first render, on the server too.

Vue exposes `useExperiment()`, `useResolvedPresentation()` and
`useResolvedTheme(theme)`, Svelte `getConsentManager().experiment`,
`.presentation` and `.theme`, and `@c15t/browser` `client.presentation` and
`client.theme`. Every adapter also reports the arm on `snapshot.experiment`.

## What is recorded

An impression or a choice carries the arm only once the banner has shown it
in the current page. A returning visitor who reopens the preference center
from a footer link, or saves from an inline widget, never saw the arm's
banner, so their choice is recorded without it and does not count toward any
arm.

Each choice saved through `POST /subjects` carries in `metadata`:

|Key|Value|
|--|--|
|`experiment`|`{ id, arm, assignedBy, acknowledgedDiagnostics }`|
|`timeToDecisionMs`|Milliseconds from the surface's first impression to the action|

`uiSource` on the same record names the surface (`banner`, `dialog`,
`widget`). The `surface:shown` and `choice:recorded` kernel events and the
`onSurfaceShown` and `onChoiceRecorded` callbacks carry the same
`experiment` object, so impressions and decisions can be joined per arm in
your analytics without a backend query.

## Measure the results

An opt-in rate needs two counts per arm: how many visitors the banner was
owed to, and how many of them accepted. c15t sends both to the backend on
its own; you do not wire anything up.

* **Visitors owed the banner.** While a visitor has no stored choice, every
  `/init` carries their arm as `<id>=<arm>` in the `experiment` query
  parameter, and the backend puts it on that request's session report as
  `experiment: { id, arm }`. In manifest mode, the server render or the
  init route puts it on the report it sends to `POST /sessions`. A visitor
  who already chose is not shown the banner, so is not counted. A
  server-to-server caller can send the `x-c15t-experiment` header instead.
* **Choices.** Every choice saved through `POST /subjects` carries the arm
  in `metadata.experiment`, as above.

On a server-rendered page the server calls `/init`, not the browser, so the
server has to know the arm. Pass the experiment to `resolveConsent`, as in
[Vercel Flags SDK](#vercel-flags-sdk): it sends only `{ id, arm }` to the
backend and hands the full experiment to the client in its state.
`resolveConsent` takes `experiment` in `c15t/next/server`,
`c15t/tanstack-start/server` and `@c15t/svelte/server`; Astro and Nuxt pass
the arm they rendered on their own. When c15t picks the arm in the browser,
the browser's own `/init` carries it, so a server-rendered page that skips
the client `/init` has no count for built-in assignment. Resolve the arm with
a flag on those pages.

### Where the counts go

On Inth, the dashboard reads both counts for you. A self-hosted backend
hands each session report to `sessions.onReport`, which is where you log or
count it; `experiment` is on the report. Choices are in the `consent` table,
and [`GET /experiments/:id/summary`](#read-the-summary-from-your-backend)
groups them per arm.

The opt-in rate of an arm is visitors with an `accept_all` choice under that
arm, divided by visitors whose session reports carry the arm. Count
visitors, not requests: an undecided visitor sends a report on every page
until they choose. Deduplicate the reports per visitor the way you count
sessions.

### Send the events to your own analytics too

The `onSurfaceShown` and `onChoiceRecorded` callbacks carry the same
`experiment` object, so a few lines forward them to any tool. The
[React example](#vary-presentation-not-policy) pushes both to
`window.dataLayer` for Google Tag Manager, as `c15t_surface_shown` with the
surface and `c15t_choice_recorded` with the consent action. To send them to
PostHog instead, call `posthog.capture()` with the same fields in the
callbacks. The callbacks run for every impression and choice; check
`experiment` first, because it is `undefined` for visitors outside the test.

An impression fires before any consent exists. If your analytics tool loads
only after consent, it never sees a decliner's impression, and its opt-in
rate trends towards 100%. The backend counts above do not have that gap.

### Read the results

Consent rates move by a few points, not by half. At a 30% base rate you need
roughly 2,500 visitors per arm to detect a 5-point change with 80% power,
and about 10,000 per arm to detect 2 points. Run a sample-size calculator
against your own base rate before you start, decide the stop date up front,
and do not stop early on a good-looking day.

Under an `opt-out` policy with `prompt: 'notice'`, dismissing the notice
records no choice, so compare arms on opt-outs instead: `opt_out` choices
per visitor owed the banner.

Time to decision is the median `timeToDecisionMs` per arm over the choices.
Use the median, not the mean: a visitor who leaves the tab open skews the
mean without saying anything about the arm.

## Read the summary from your backend

The self-hosted backend copies `experiment.id`, `experiment.arm` and
`timeToDecisionMs` out of `metadata` onto their own columns (migration
`6-experiment-attribution`), so `GET /experiments/:id/summary` can group
choices per arm without a JSON query. It needs an API key.

```bash
curl 'https://app.example.com/api/c15t/experiments/banner-shape/summary?from=2026-09-01&to=2026-09-30' \
  -H "Authorization: Bearer $C15T_API_KEY"
```

```json
{
	"experimentId": "banner-shape",
	"from": "2026-09-01T00:00:00.000Z",
	"to": "2026-09-30T23:59:59.999Z",
	"arms": [
		{
			"arm": "wall",
			"choices": 120,
			"byAction": {
				"accept_all": 80,
				"custom": 10,
				"opt_out": 0,
				"reject_all": 30,
				"unknown": 0
			},
			"bySurface": { "banner": 100, "dialog": 20 },
			"medianTimeToDecisionMs": 4200
		},
		{
			"arm": "control",
			"choices": 95,
			"byAction": {
				"accept_all": 50,
				"custom": 5,
				"opt_out": 0,
				"reject_all": 40,
				"unknown": 0
			},
			"bySurface": { "banner": 90, "dialog": 5 },
			"medianTimeToDecisionMs": 5100
		}
	]
}
```

`byAction` always has all five keys. They are the action the backend
stored, not the client's `consent_action`: `accept_all` for accept,
`reject_all` for reject, `opt_out` for a reject under an opt-out policy,
`custom` for a saved selection, and `unknown` for a record without one.
`choices` counts consent records, so a visitor who changes their mind under
the same arm counts twice.

`from` and `to` accept an ISO 8601 date or timestamp and filter on the
consent's `givenAt`, both ends inclusive. A date without a time is the whole
of that day in UTC: `from=2026-09-01` starts at midnight and `to=2026-09-30`
runs to the end of the 30th, so the example above covers all of September. A
`from` later than `to` is a 400. `domain` narrows to one domain name. An
experiment id no consent carries returns `arms: []`.

From server code, the [Node.js SDK](/docs/self-host/api/node-sdk#read-an-experiment-summary)
makes the same call. Create the client with an API key; `from` and `to` also
take a `Date`:

```ts
const c15t = createC15tClient({
	baseUrl: 'https://app.example.com/api/c15t',
	apiKey,
});

const result = await c15t.experiments.summary('banner-shape', {
	from: '2026-09-01',
	to: '2026-09-30',
});
```

The summary counts choices. The other half of an opt-in rate, the visitors
each arm's banner was owed to, is on the session reports `/init` produces
(see [Measure the results](#measure-the-results)). Divide `accept_all` per
arm by the visitors whose reports carry that arm.

## Try it

Every example app under `internals/fixtures/` runs this experiment outside the pages
the docs publish. Each page shows the assigned arm and the events the
callbacks sent. Adding `arm=wall` sets the arm the way a flag would, so the
page shows `assignedBy: host`. Run each command in the example's directory.

|Example|Start|Open|
|--|--|--|
|Next.js|`bun run dev`|`/experiment`, with the arm from a stand-in flag: `?arm=wall`, `?arm=off` to leave the test, `control` otherwise|
|TanStack Start|`C15T_EXPERIMENT=1 bun run dev`|`/consent-example?experiment=1`, then add `&arm=wall`|
|React|`bun run dev`|`/experiment.html`, then add `?arm=wall`|
|Nuxt|`C15T_NUXT_EXPERIMENT=1 bun run dev`; add `C15T_NUXT_EXPERIMENT_ARM=wall` for the wall arm|`/consent-example`|
|Vue|`bun run dev`|`/?experiment=1`, then add `&arm=wall`|
|Astro|`C15T_EXPERIMENT=1 bun run dev`|`/consent-example?experiment=1` runs `control`, as Astro has no built-in assignment; add `&arm=wall`|
|Svelte|`bun run dev`|`/?experiment=1`, then add `&arm=wall`|
|SvelteKit|`bun run dev`|`/experiment-example?experiment=1`, then add `&arm=wall`|
|HTML script tag|`bun run dev`|`/?experiment=1`, then add `&arm=wall`|
|JavaScript|`bun run dev`|`/experiment/`, then add `?arm=wall`|

## End an experiment

Move the winning arm's fragment into `presentation` (and its `theme` into
`theme`), then remove `experiment`. Visitors keep their consent; the stored
arm is ignored once no experiment reads it.

## Copy is out of scope

Arms change presentation and theme tokens only. Copy is not a variant dimension because
`copyRevision` is hashed into the prompt fingerprint: a copy change re-prompts
every returning visitor, so a copy experiment would re-prompt them on each
arm switch. Vary layout, shape, position and action prominence instead.
