diff --git a/.vscode/settings.json b/.vscode/settings.json
index e97c47e5..c5fa7f01 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,11 +12,14 @@
"fsegurai",
"gantt",
"gitgraph",
+ "hugeicons",
"KROKI",
"localstorage",
"mermaidchart",
"mindmap",
"NEWYEAR",
+ "noopener",
+ "noreferrer",
"Pageview",
"pako",
"panmove",
diff --git a/package.json b/package.json
index 326abe38..0c43de79 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,8 @@
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@fortawesome/fontawesome-free": "^6.7.2",
+ "@iconify-json/hugeicons": "^1.2.26",
+ "@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.20",
"@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.52.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7ec5430e..a8672051 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -102,6 +102,12 @@ importers:
'@fortawesome/fontawesome-free':
specifier: ^6.7.2
version: 6.7.2
+ '@iconify-json/hugeicons':
+ specifier: ^1.2.26
+ version: 1.2.26
+ '@iconify-json/logos':
+ specifier: ^1.2.11
+ version: 1.2.11
'@iconify-json/material-symbols':
specifier: ^1.2.20
version: 1.2.20
@@ -702,6 +708,12 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@iconify-json/hugeicons@1.2.26':
+ resolution: {integrity: sha512-ZJB0EjFrYJy+EBUnx2/mGD4MCGScQr4dauV455lC9CuCt50guk6TosnOi9t2r4EIyhHmytO5IOeVFG4C8V9/hw==}
+
+ '@iconify-json/logos@1.2.11':
+ resolution: {integrity: sha512-fOo4pGEatuyuCFNL+cwquYMa2Im0oJHRHV7lt/Qqs5Ode/lPImHCQcfTtPzZj7qYMPb/h8YHN3TG54uEowrjNQ==}
+
'@iconify-json/material-symbols@1.2.20':
resolution: {integrity: sha512-+KqOT+3fD+LC2FbWiV8gd4+JLMiVUtmqrjzpKN1ji7rfMQTwvYJ94RT0WQlmL+vfDNJ5MTRe3rBzzJyvIH/aSg==}
@@ -4549,6 +4561,14 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@iconify-json/hugeicons@1.2.26':
+ dependencies:
+ '@iconify/types': 2.0.0
+
+ '@iconify-json/logos@1.2.11':
+ dependencies:
+ '@iconify/types': 2.0.0
+
'@iconify-json/material-symbols@1.2.20':
dependencies:
'@iconify/types': 2.0.0
diff --git a/src/lib/components/DesktopEditor.svelte b/src/lib/components/DesktopEditor.svelte
index 4979ab9c..2a920a1c 100644
--- a/src/lib/components/DesktopEditor.svelte
+++ b/src/lib/components/DesktopEditor.svelte
@@ -224,7 +224,11 @@
onClose={closePopup}
onTryFree={() => {
logMermaidChartClick('vibeDiagramming');
- window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
+ window.open(
+ $urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save,
+ '_blank',
+ 'noopener'
+ );
closePopup();
}} />
diff --git a/src/lib/components/migration/EditorChooserControl.svelte b/src/lib/components/migration/EditorChooserControl.svelte
new file mode 100644
index 00000000..13a48524
--- /dev/null
+++ b/src/lib/components/migration/EditorChooserControl.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+ Choose your editor
+
+ You'll never see this again
+
+
+
+
+
+
+
+ Recommended
+
+
+
+
+
Mermaid Plus
+
Unlock AI, storage and collaboration
+
+
+
+
+
+ 10% off with code JS26
+
+
+
+
+ actions.startTrial()}>
+ Start free trial
+
+
+
+
+
+ {#each plusFeatures as feature (feature.label)}
+
+
+ {feature.label}
+
+ {/each}
+
+
+
+
+
+
+
+
Open Source
+
Code only, no login, always free
+
+
+
+
Mermaid has a new home
+
+ actions.openMermaidAiLive('continueToNewHome')}>
+ Continue to mermaid.ai/live
+
+
+
+
actions.dismiss('stayOnLive')}>
+ Stay on mermaid.live
+
+
+
+
+ {#each openSourceFeatures as feature (feature.label)}
+
+
+ {feature.label}
+
+ {/each}
+
+
+
+
+
+
diff --git a/src/lib/components/migration/EditorChooserModal.svelte b/src/lib/components/migration/EditorChooserModal.svelte
index 6b49af95..84edc78f 100644
--- a/src/lib/components/migration/EditorChooserModal.svelte
+++ b/src/lib/components/migration/EditorChooserModal.svelte
@@ -1,164 +1,42 @@
{
- if (!v) handleStartFree();
+ if (v) return;
+ if (!handled) actions.log('dismissed');
+ handled = false;
+ dismissEditorChooser();
}}>
-
-
- Choose your editor
-
- You'll never see this again
-
-
-
-
-
-
-
- Recommended
-
-
-
-
-
Mermaid Plus
-
Unlock AI, storage and collaboration
-
-
-
-
-
- 10% off with code JS26
-
-
-
-
- Start free trial
-
-
-
-
-
-
- Visual editor
-
-
-
- 300 AI credits
-
-
-
- Unlimited diagram storage
-
-
-
- Limitless diagram size
-
-
-
- View & comment collaboration
-
-
-
-
-
-
-
-
-
Open Source
-
Code only, no login, always free
-
-
-
-
Mermaid has a new home
-
- Continue to mermaid.ai/live
-
-
-
- Stay on mermaid.live
-
-
-
-
-
-
- Diagram stored in URL
-
-
-
- Code editor
-
-
-
- Open source
-
-
-
- Version history
-
-
-
-
-
-
-
+ {#if variant === 'testA'}
+
+ {:else if variant === 'testB'}
+
+ {:else if variant === 'testC'}
+
+ {:else}
+
+ {/if}
diff --git a/src/lib/components/migration/EditorChooserTestA.svelte b/src/lib/components/migration/EditorChooserTestA.svelte
new file mode 100644
index 00000000..25bfab1c
--- /dev/null
+++ b/src/lib/components/migration/EditorChooserTestA.svelte
@@ -0,0 +1,94 @@
+
+
+
+
+ Choose your editor
+
+ Choose between the open source editor or Mermaid Plus
+
+
+
+
+
+
+
+
+
+
+
Open source
+
Code only, no login, always free
+
+
+
+ {#each openSourceFeatures as feature (feature)}
+
+
+ {feature}
+
+ {/each}
+
+
+
actions.dismiss('continueToLive')}>
+ Continue to mermaid.live
+
+
+
+
+
+
+
+
+ Recommended
+
+
+
+
Mermaid Plus
+
Unlock AI, storage and collaboration
+
+
+
+ {#each plusFeatures as feature (feature)}
+
+
+ {feature}
+
+ {/each}
+
+
+
+ actions.startTrial()}>
+ Start free trial
+
+
+
+
+
+
+
diff --git a/src/lib/components/migration/EditorChooserTestB.svelte b/src/lib/components/migration/EditorChooserTestB.svelte
new file mode 100644
index 00000000..6df834db
--- /dev/null
+++ b/src/lib/components/migration/EditorChooserTestB.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+
+ Try the full Mermaid experience
+
+ Free forever, with Plus features free for 7 days.
+
+
+
+
+ {#each features as feature (feature.title)}
+
+
+
+
{feature.title}
+
{feature.description}
+
+
+ {/each}
+
+
+
+
+ actions.startTrial()}>Start free trial
+
+ actions.dismiss('stayOnLive')}>
+ Stay on mermaid.live
+
+
+
+
+
Trusted by 5M people and over 200k companies
+
+ {#each trustedLogos as logo (logo.name)}
+
+ {/each}
+
+
+
+
+
diff --git a/src/lib/components/migration/EditorChooserTestC.svelte b/src/lib/components/migration/EditorChooserTestC.svelte
new file mode 100644
index 00000000..bfc2bd02
--- /dev/null
+++ b/src/lib/components/migration/EditorChooserTestC.svelte
@@ -0,0 +1,99 @@
+
+
+
+
+
+ How do you want to create?
+
+ Choose how to start your diagram
+
+
+
+
+ {#snippet optionButton(option: Option)}
+
+
+
+ {option.title}
+ {option.description}
+
+
+ {option.badge}
+
+
+
+ {/snippet}
+ {#each options as option (option.buttonClick)}
+ {#if option.external}
+
+ {@render optionButton(option)}
+
+ {:else}
+ {@render optionButton(option)}
+ {/if}
+ {/each}
+
+
+
+ AI and visual editor are part of mermaid.ai. Start free today.
+
+
+
+
diff --git a/src/lib/components/migration/PrivacyPolicyLink.svelte b/src/lib/components/migration/PrivacyPolicyLink.svelte
new file mode 100644
index 00000000..656b1afe
--- /dev/null
+++ b/src/lib/components/migration/PrivacyPolicyLink.svelte
@@ -0,0 +1,8 @@
+
diff --git a/src/lib/components/migration/editorChooserActions.ts b/src/lib/components/migration/editorChooserActions.ts
new file mode 100644
index 00000000..c4c78411
--- /dev/null
+++ b/src/lib/components/migration/editorChooserActions.ts
@@ -0,0 +1,74 @@
+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 = {
+ createWithAi: 'testCAi',
+ useVisualEditor: 'testCVisual'
+};
+
+const resolveUtmCampaign = (variant: EditorChooserVariant, buttonClick: string): string => {
+ if (variant === 'testC') {
+ return testCUtmCampaignByButtonClick[buttonClick] ?? variant;
+ }
+ return variant;
+};
+
+export interface EditorChooserActions {
+ log: (buttonClick: string) => void;
+ startTrial: (buttonClick?: string) => void;
+ dismiss: (buttonClick: string) => void;
+ openMermaidAiLive: (buttonClick: string) => void;
+}
+
+export interface EditorChooserVariantProps {
+ actions: EditorChooserActions;
+}
+
+export const createEditorChooserActions = (
+ variant: EditorChooserVariant,
+ close: () => void
+): EditorChooserActions => {
+ const log = (buttonClick: string) => {
+ logEvent('chooseEditor', { buttonClick, variant });
+ };
+
+ const startTrial = (buttonClick = 'startTrial') => {
+ log(buttonClick);
+ logMermaidChartClick('editorPicker');
+ close();
+ window.open(
+ getCheckoutUrl({
+ utmCampaign: resolveUtmCampaign(variant, buttonClick),
+ utmMedium
+ }),
+ '_blank',
+ 'noopener'
+ );
+ };
+
+ const dismiss = (buttonClick: string) => {
+ log(buttonClick);
+ close();
+ };
+
+ const openMermaidAiLive = (buttonClick: string) => {
+ log(buttonClick);
+ close();
+ window.open(
+ getMermaidAiLiveUrl({
+ utmCampaign: resolveUtmCampaign(variant, buttonClick),
+ utmMedium
+ }),
+ '_blank',
+ 'noopener'
+ );
+ };
+
+ return { log, startTrial, dismiss, openMermaidAiLive };
+};
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index f9b48755..2d346622 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -12,5 +12,6 @@ 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;
diff --git a/src/lib/util/experiments.ts b/src/lib/util/experiments.ts
new file mode 100644
index 00000000..2f37b4b5
--- /dev/null
+++ b/src/lib/util/experiments.ts
@@ -0,0 +1,2 @@
+export const editorChooserVariants = ['control', 'testA', 'testB', 'testC'] as const;
+export type EditorChooserVariant = (typeof editorChooserVariants)[number];
diff --git a/src/lib/util/migration/domainMigration.ts b/src/lib/util/migration/domainMigration.ts
index e8ce68ff..c4b5159e 100644
--- a/src/lib/util/migration/domainMigration.ts
+++ b/src/lib/util/migration/domainMigration.ts
@@ -1,8 +1,10 @@
import { C } from '$/constants';
import { env } from '$/util/env';
+import { editorChooserVariants, type EditorChooserVariant } from '$/util/experiments';
const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live';
+const netlifyPreviewDomain = 'netlify.app';
/**
* Check if we're on mermaid.ai
@@ -20,6 +22,13 @@ export const isOnMermaidLive = (): boolean => {
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
};
+/**
+ * Check if we're on a Netlify preview/staging deploy (*.netlify.app).
+ */
+const isOnNetlifyPreview = (): boolean => {
+ return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
+};
+
/**
* Check if the current URL has pako data (diagram content from a shared link).
*/
@@ -55,11 +64,15 @@ const isReferredFromMermaid = (): boolean => {
* Check if the editor chooser modal should be shown.
* Shows for new users who haven't dismissed it and aren't viewing a shared link.
* Not shown on mobile (viewport width < 640px).
+ * Can be forced open for QA via the `?editorChooser=1` query flag, which bypasses
+ * the hostname and dismissed checks.
*/
export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false;
- if (!isOnMermaidAI() && !isOnMermaidLive()) return false;
if (window.innerWidth < 640) return false;
+ const forced = new URLSearchParams(window.location.search).get('editorChooser') === '1';
+ if (forced) return true;
+ if (!isOnMermaidAI() && !isOnMermaidLive() && !isOnNetlifyPreview()) return false;
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
if (hasPakoData()) return false;
if (isReferredFromMermaid()) return false;
@@ -72,3 +85,20 @@ 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;
+};
diff --git a/src/lib/util/stats.ts b/src/lib/util/stats.ts
index 1aab01e6..9f658c79 100644
--- a/src/lib/util/stats.ts
+++ b/src/lib/util/stats.ts
@@ -113,6 +113,9 @@ export const logEvent = (
name: AnalyticsEvent,
data?: Record
): void => {
+ if (browser && window.location.hostname === 'localhost') {
+ console.log('[plausible]', name, data);
+ }
if (!plausible) {
return;
}
diff --git a/src/lib/util/util.ts b/src/lib/util/util.ts
index 2b272be1..72757903 100644
--- a/src/lib/util/util.ts
+++ b/src/lib/util/util.ts
@@ -42,23 +42,30 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174'
: 'https://example.com';
-export const getCheckoutUrl = ({
+const buildUtmParams = ({
utmCampaign,
utmMedium
}: {
utmCampaign: string;
utmMedium: string;
-}): string => {
- const params = new URLSearchParams({
- coupon: 'arDfyFT8',
- tier: 'plus',
+}): URLSearchParams =>
+ new URLSearchParams({
utm_campaign: utmCampaign,
utm_medium: utmMedium,
utm_source: getUTMSource()
});
+
+export const getCheckoutUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
+ const params = buildUtmParams(utm);
+ params.set('coupon', 'arDfyFT8');
+ params.set('tier', 'plus');
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
};
+export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
+ return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
+};
+
let count = 0;
export const errorDebug = (limit = 1000) => {
count += 1;