feat: Ship editor chooser testB and use live_2026 UTM campaign

End the A/B test by always showing the testB modal, remove unused variant
components and assignment logic, and route CTA links with utm_campaign=live_2026.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sidharth Vinod
2026-05-29 15:45:04 +05:30
co-authored by Cursor
parent 1fabea49cf
commit adf3ddf015
9 changed files with 11 additions and 387 deletions
@@ -1,118 +0,0 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import CodeIcon from '~icons/custom/code';
import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded';
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
import HistoryIcon from '~icons/material-symbols/history';
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
import LanguageIcon from '~icons/material-symbols/language';
import WidthIcon from '~icons/material-symbols/width-rounded';
import type { EditorChooserVariantProps } from './editorChooserActions';
let { actions }: EditorChooserVariantProps = $props();
const plusFeatures = [
{ icon: EditIcon, label: 'Visual editor' },
{ icon: SparklesIcon, label: '300 AI credits' },
{ icon: HomeIcon, label: 'Unlimited diagram storage' },
{ icon: WidthIcon, label: 'Limitless diagram size' },
{ icon: ChatIcon, label: 'View & comment collaboration' }
];
const openSourceFeatures = [
{ icon: LanguageIcon, label: 'Diagram stored in URL' },
{ icon: CodeIcon, label: 'Code editor' },
{ icon: OpenSourceIcon, label: 'Open source' },
{ icon: HistoryIcon, label: 'Version history' }
];
</script>
<Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background">
<Dialog.Header>
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Description class="text-center text-sm font-light">
You'll never see this again
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Mermaid Plus Card -->
<div
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
Recommended
</div>
<div class="flex flex-col gap-4 p-6">
<div>
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-center">
<span
class="rounded-full bg-pink-100 px-3 py-0.5 text-xs font-semibold text-pink-700 dark:bg-pink-950 dark:text-pink-300">
10% off with code JS26
</span>
</div>
<McWrapper labelPrefix="Opens ">
<Button variant="accent" class="w-full" onclick={() => actions.startTrial()}>
Start free trial
</Button>
</McWrapper>
</div>
<ul class="space-y-3 text-sm">
{#each plusFeatures as feature (feature.label)}
<li class="flex items-center gap-2">
<feature.icon class="size-4 shrink-0 text-muted-foreground" />
{feature.label}
</li>
{/each}
</ul>
</div>
</div>
<!-- Open Source Card -->
<div class="flex flex-col gap-4 rounded-xl border bg-white p-6 shadow dark:bg-card">
<div class="flex flex-col justify-end pt-10">
<h3 class="text-xl font-bold">Open Source</h3>
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
</div>
<div class="flex flex-col gap-2">
<p class="mt-2 text-sm text-muted-foreground">Mermaid has a new home</p>
<McWrapper labelPrefix="Opens ">
<Button
variant="outline"
class="w-full border-accent"
onclick={() => actions.openMermaidAiLive('continueToNewHome')}>
Continue to mermaid.ai/live
<ArrowIcon class="ml-1 size-4" />
</Button>
</McWrapper>
<Button variant="outline" class="w-full" onclick={() => actions.dismiss('stayOnLive')}>
Stay on mermaid.live
</Button>
</div>
<ul class="space-y-3 text-sm">
{#each openSourceFeatures as feature (feature.label)}
<li class="flex items-center gap-2">
<feature.icon class="size-4 shrink-0 text-muted-foreground" />
{feature.label}
</li>
{/each}
</ul>
</div>
</div>
<PrivacyPolicyLink />
</Dialog.Content>
@@ -1,10 +1,7 @@
<script lang="ts">
import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser, getEditorChooserVariant } from '$/util/migration/domainMigration';
import EditorChooserControl from './EditorChooserControl.svelte';
import EditorChooserTestA from './EditorChooserTestA.svelte';
import { dismissEditorChooser } from '$/util/migration/domainMigration';
import EditorChooserTestB from './EditorChooserTestB.svelte';
import EditorChooserTestC from './EditorChooserTestC.svelte';
import { createEditorChooserActions } from './editorChooserActions';
interface Props {
@@ -12,11 +9,10 @@
}
let { open = $bindable() }: Props = $props();
const variant = getEditorChooserVariant();
// Tracks whether the current close was triggered by an explicit action
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside).
let handled = false;
const actions = createEditorChooserActions(variant, () => {
const actions = createEditorChooserActions(() => {
handled = true;
open = false;
});
@@ -30,13 +26,5 @@
handled = false;
dismissEditorChooser();
}}>
{#if variant === 'testA'}
<EditorChooserTestA {actions} />
{:else if variant === 'testB'}
<EditorChooserTestB {actions} />
{:else if variant === 'testC'}
<EditorChooserTestC {actions} />
{:else}
<EditorChooserControl {actions} />
{/if}
<EditorChooserTestB {actions} />
</Dialog.Root>
@@ -1,94 +0,0 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { asset } from '$app/paths';
import type { EditorChooserVariantProps } from './editorChooserActions';
let { actions }: EditorChooserVariantProps = $props();
const openSourceFeatures = [
'Diagram stored in URL',
'Code editor',
'Open source',
'Version history'
];
const plusFeatures = [
'AI diagramming',
'Visual editor',
'Encrypted diagram storage',
'Premium plugins',
'View & comment collaboration'
];
</script>
<Dialog.Content class="flex max-w-3xl flex-col gap-6 bg-background p-8">
<Dialog.Header>
<Dialog.Title class="text-center text-2xl font-bold">Choose your editor</Dialog.Title>
<Dialog.Description class="sr-only">
Choose between the open source editor or Mermaid Plus
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Open Source Card -->
<div class="flex flex-col gap-4 rounded-xl border bg-card p-6">
<div class="flex items-center">
<img class="size-10" src={asset('/favicon.svg')} alt="Mermaid Live Editor" />
</div>
<div>
<h3 class="text-xl font-bold">Open source</h3>
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
</div>
<ul class="flex flex-col gap-2 text-sm">
{#each openSourceFeatures as feature (feature)}
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
<span class="size-2 shrink-0 rounded-full bg-blue-500"></span>
{feature}
</li>
{/each}
</ul>
<Button
variant="outline"
class="mt-auto self-start"
onclick={() => actions.dismiss('continueToLive')}>
Continue to mermaid.live
</Button>
</div>
<!-- Mermaid Plus Card -->
<div class="flex flex-col gap-4 rounded-xl border bg-card p-6">
<div class="flex items-center gap-3">
<img class="size-10" src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
<span
class="rounded-md bg-secondary px-2 py-0.5 text-xs font-semibold text-secondary-foreground">
Recommended
</span>
</div>
<div>
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
</div>
<ul class="flex flex-col gap-2 text-sm">
{#each plusFeatures as feature (feature)}
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
<span class="size-2 shrink-0 rounded-full bg-blue-500"></span>
{feature}
</li>
{/each}
</ul>
<McWrapper labelPrefix="Opens ">
<Button variant="accent" class="mt-auto self-start" onclick={() => actions.startTrial()}>
Start free trial
</Button>
</McWrapper>
</div>
</div>
<PrivacyPolicyLink />
</Dialog.Content>
@@ -9,9 +9,9 @@
import AmazonIcon from '~icons/logos/aws';
import GoogleIcon from '~icons/logos/google';
import MicrosoftIcon from '~icons/logos/microsoft';
import type { EditorChooserVariantProps } from './editorChooserActions';
import type { EditorChooserProps } from './editorChooserActions';
let { actions }: EditorChooserVariantProps = $props();
let { actions }: EditorChooserProps = $props();
const features = [
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
@@ -1,99 +0,0 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import * as Dialog from '$/components/ui/dialog';
import { asset } from '$app/paths';
import type { Component } from 'svelte';
import SparklesIcon from '~icons/hugeicons/sparkles';
import ChevronIcon from '~icons/material-symbols/chevron-right-rounded';
import CodeIcon from '~icons/material-symbols/code-rounded';
import EditIcon from '~icons/material-symbols/rebase-edit-outline-rounded';
import type { EditorChooserVariantProps } from './editorChooserActions';
let { actions }: EditorChooserVariantProps = $props();
interface Option {
buttonClick: string;
badge: string;
description: string;
icon: Component;
onclick: () => void;
title: string;
external: boolean;
}
const options: Option[] = [
{
badge: 'Start free trial',
buttonClick: 'createWithAi',
description: 'Describe your diagram in plain text and AI generates it for you',
external: true,
icon: SparklesIcon,
onclick: () => actions.startTrial('createWithAi'),
title: 'Create with AI'
},
{
badge: 'Start free trial',
buttonClick: 'useVisualEditor',
description: 'Drag, drop, and connect shapes on an interactive canvas',
external: true,
icon: EditIcon,
onclick: () => actions.startTrial('useVisualEditor'),
title: 'Use visual editor'
},
{
badge: 'Mermaid live',
buttonClick: 'writeCode',
description: 'Use Mermaid syntax directly in the code editor',
external: false,
icon: CodeIcon,
onclick: () => actions.dismiss('writeCode'),
title: 'Write code'
}
];
</script>
<Dialog.Content class="flex max-w-xl flex-col gap-3 bg-background p-8">
<Dialog.Header class="items-center gap-2 space-y-0">
<img class="size-10" src={asset('/favicon.svg')} alt="Mermaid" />
<Dialog.Title class="pt-2 text-2xl font-bold">How do you want to create?</Dialog.Title>
<Dialog.Description class="text-xs text-muted-foreground">
Choose how to start your diagram
</Dialog.Description>
</Dialog.Header>
<div class="mt-3 flex flex-col gap-3">
{#snippet optionButton(option: Option)}
<button
type="button"
class="group flex w-full items-center gap-3 rounded-lg border border-border px-3 py-3.5 text-left transition-colors hover:border-accent"
onclick={option.onclick}>
<option.icon class="size-5 shrink-0 text-muted-foreground group-hover:text-accent" />
<div class="flex flex-1 flex-col gap-0.5">
<span class="text-sm font-medium">{option.title}</span>
<span class="text-xs font-light text-muted-foreground">{option.description}</span>
</div>
<span
class="shrink-0 rounded-full bg-secondary px-3 py-1 text-xs font-normal text-secondary-foreground">
{option.badge}
</span>
<ChevronIcon class="size-5 shrink-0 text-muted-foreground group-hover:text-accent" />
</button>
{/snippet}
{#each options as option (option.buttonClick)}
{#if option.external}
<McWrapper labelPrefix="Opens ">
{@render optionButton(option)}
</McWrapper>
{:else}
{@render optionButton(option)}
{/if}
{/each}
</div>
<p class="mt-2 text-center text-xs text-muted-foreground">
AI and visual editor are part of mermaid.ai. Start free today.
</p>
<PrivacyPolicyLink />
</Dialog.Content>
@@ -1,23 +1,8 @@
import type { EditorChooserVariant } from '$/util/experiments';
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl, getMermaidAiLiveUrl } from '$/util/util';
const utmMedium = 'editorSelection';
// See MC-4486: UTM campaign per variant for CTAs routing to mermaid.ai.
// The campaign defaults to the variant name; TestC's two trial CTAs are disambiguated
// by buttonClick so we can tell AI vs visual-editor clicks apart in analytics.
const testCUtmCampaignByButtonClick: Record<string, string> = {
createWithAi: 'testCAi',
useVisualEditor: 'testCVisual'
};
const resolveUtmCampaign = (variant: EditorChooserVariant, buttonClick: string): string => {
if (variant === 'testC') {
return testCUtmCampaignByButtonClick[buttonClick] ?? variant;
}
return variant;
};
const utmCampaign = 'live_2026';
export interface EditorChooserActions {
log: (buttonClick: string) => void;
@@ -26,30 +11,20 @@ export interface EditorChooserActions {
openMermaidAiLive: (buttonClick: string) => void;
}
export interface EditorChooserVariantProps {
export interface EditorChooserProps {
actions: EditorChooserActions;
}
export const createEditorChooserActions = (
variant: EditorChooserVariant,
close: () => void
): EditorChooserActions => {
export const createEditorChooserActions = (close: () => void): EditorChooserActions => {
const log = (buttonClick: string) => {
logEvent('chooseEditor', { buttonClick, variant });
logEvent('chooseEditor', { buttonClick });
};
const startTrial = (buttonClick = 'startTrial') => {
log(buttonClick);
logMermaidChartClick('editorPicker');
close();
window.open(
getCheckoutUrl({
utmCampaign: resolveUtmCampaign(variant, buttonClick),
utmMedium
}),
'_blank',
'noopener'
);
window.open(getCheckoutUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
const dismiss = (buttonClick: string) => {
@@ -60,14 +35,7 @@ export const createEditorChooserActions = (
const openMermaidAiLive = (buttonClick: string) => {
log(buttonClick);
close();
window.open(
getMermaidAiLiveUrl({
utmCampaign: resolveUtmCampaign(variant, buttonClick),
utmMedium
}),
'_blank',
'noopener'
);
window.open(getMermaidAiLiveUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
return { log, startTrial, dismiss, openMermaidAiLive };
-1
View File
@@ -12,6 +12,5 @@ export const TID = {
export const C = {
aiLiveEditor: 'ai_live_editor',
editorChooserDismissedKey: 'mermaid-editor-chooser-dismissed',
editorChooserVariantKey: 'mermaid-editor-chooser-variant',
utmSource: 'mermaid_live_editor'
} as const;
-2
View File
@@ -1,2 +0,0 @@
export const editorChooserVariants = ['control', 'testA', 'testB', 'testC'] as const;
export type EditorChooserVariant = (typeof editorChooserVariants)[number];
-18
View File
@@ -1,6 +1,5 @@
import { C } from '$/constants';
import { env } from '$/util/env';
import { editorChooserVariants, type EditorChooserVariant } from '$/util/experiments';
const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live';
@@ -85,20 +84,3 @@ export const shouldShowEditorChooser = (): boolean => {
export const dismissEditorChooser = (): void => {
window.localStorage.setItem(C.editorChooserDismissedKey, 'true');
};
const isValidVariant = (value: string | null): value is EditorChooserVariant => {
return value !== null && (editorChooserVariants as readonly string[]).includes(value);
};
/**
* Get the A/B/C test variant assigned to this user for the editor chooser modal.
* Assigns a variant on first call (uniform random across variants), persists it
* in localStorage, and returns the same value on subsequent calls.
*/
export const getEditorChooserVariant = (): EditorChooserVariant => {
const stored = window.localStorage.getItem(C.editorChooserVariantKey);
if (isValidVariant(stored)) return stored;
const variant = editorChooserVariants[Math.floor(Math.random() * editorChooserVariants.length)];
window.localStorage.setItem(C.editorChooserVariantKey, variant);
return variant;
};