> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cfosilvia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Landing experiment

# Landing-page A/B/C analytics event dictionary

This is the source of truth for the PostHog event surface across the three
landing-page arms of the `landing-page-conversion-v02` experiment. If you
ship code that touches `components/landing-page-v{3,4,5}.tsx` or anything
under `components/landing-v{4,5}/`, keep this document in sync.

> **Flag key note (2026-09-21):** the experiment was bumped from
> `landing-page-conversion-v01` (2-arm test) to
> `landing-page-conversion-v02` (3-arm test) alongside the tracking
> overhaul that added `landing_arm` as a PostHog super-property. Data
> from V01 is not directly comparable to V02 because pre-overhaul
> events did not carry arm attribution on `signed_up` /
> `signup_page_viewed` / any downstream event. See the docblock on
> `LANDING_CONVERSION_EXPERIMENT_KEY` in
> `hooks/use-landing-conversion-experiment.ts` for the ops checklist.

## The three arms

| Arm | `arm` value | Entry point | Analytics module |
| - | - | - | - |
| Control | `control` | `components/landing-page-v3.tsx` | inline in `landing-page-v3.tsx` |
| Test | `test` | `components/landing-page-v4.tsx` | `components/landing-v4/analytics.ts` |
| Test-B | `test-b` | `components/landing-page-v5.tsx` | `components/landing-v5/analytics.ts` |

The router that picks the arm is `components/home-landing-router.tsx`
(reads the PostHog multivariate flag `landing-page-conversion-v02` via
`hooks/use-landing-conversion-experiment.ts`).

## The one-property rule for cross-arm comparison

**Every event on every arm stamps `arm: 'control' | 'test' | 'test-b'`.**
That's the single canonical dimension PostHog insights / funnels /
cohorts use to compare arms on **landing-page** events. Do NOT
introduce a new arm-tagging property — extend `arm` if we add a fourth
arm.

Additionally:

* V4 events also stamp `variant: 'v4'` and `landing_revision: 'hero-cta-v2'`
  (legacy — kept so pre-existing PostHog insights don't break).
* V5 events also stamp `variant: 'v5'` and `landing_revision: 'framer-unframer-v1'`
  (same pattern; bump `landing_revision` for within-arm event-level revisions
  that shouldn't re-randomize traffic).

## The `landing_arm` super-property

**Every event fired anywhere in the app AFTER a visitor's variant
resolves — and within the same PostHog session — also carries a
`landing_arm: 'control' | 'test' | 'test-b'` property.** This
includes `signup_page_viewed`, `signup_plan_intent_captured`,
`signed_up`, `signed_in`, every `$pageview` on `/sign-up`,
`$autocapture`, and any custom event fired downstream. The
registration happens in `useLandingConversionExperiment`
immediately after the variant is resolved — see the exposure
`useEffect` for the implementation.

Why both `arm` (event-level) AND `landing_arm` (super-property)?

* `arm` is the primary dimension for filtering landing-page events —
  it's on the event body, cheap to read, and can't drift with PostHog
  persistence quirks.
* `landing_arm` is the same value promoted to a super-property so it
  automatically attaches to events fired OUTSIDE the landing page —
  where we have no arm-aware `trackEvent` closure to add it (the
  Clerk-owned sign-up widget, /auth/callback captures, onboarding,
  and every `$pageview` in the funnel). Without this the primary
  conversion funnel — `landing_page_viewed → signed_up` grouped by
  arm — is unanswerable.

**Session scoping (Bugbot + CodeRabbit PR #4156):** the super-property
is registered via plain `posthog.register()` (cross-tab, localStorage-
backed) alongside `landing_arm_session_id` which stores
`posthog.get_session_id()` at register time. The paired
`ensureFreshLandingArm(posthog)` helper (exported from the landing
conversion hook, called from the hook itself AND from the sign-up
page's `signup_page_viewed` effect) compares the stored session ID
to the current one and unregisters both properties on mismatch.
Scenario matrix:

| Scenario | `landing_arm` attaches? | Why |
| - | :-: | - |
| Same-session same-tab: `/` → click "Get Pro" → `/sign-up` | ✅ (correct) | Stored session id == current |
| Same-session cmd-click: `/` in tab A → cmd-click "Get Pro" → `/sign-up` in tab B | ✅ (correct) | posthog session id is cross-tab; localStorage super-prop is cross-tab; stored session id == current |
| Cross-session direct visit: user comes back a week later straight to `/sign-up` | ❌ (correct — no landing exposure this session) | Stored session id != current → `ensureFreshLandingArm()` on sign-up page unregisters before `signup_page_viewed` fires |
| User re-visits `/` in a new session and gets re-bucketed to a different arm | ✅ (fresh) | Guard unregisters old on mount; exposure effect re-registers with new variant + new session id |
| Same session after `posthog.reset()` on logout | ❌ (correct — expected reset semantics) | `reset()` clears all super-props |

**Why NOT `posthog.register_for_session()`?** Its name implies session
scoping but its implementation uses per-tab `sessionStorage`
(`sessionPersistence?.register(properties)`). Cmd-click new tabs get
empty `sessionStorage` and therefore no arm — the exact regression
Bugbot caught on the initial fix attempt (commit 5fcb3b7). Using
posthog's session-id explicitly for scoping gives us the cross-tab
persistence we need for cmd-click while still expiring on session
rotation. See `LANDING_ARM_SUPER_PROPERTY` docblock in
`hooks/use-landing-conversion-experiment.ts` for the full rationale.

Person-level attribution (durable): the same exposure effect also
calls `posthog.setPersonProperties({ last_landing_arm,
last_landing_arm_at })`. Person properties persist FOREVER on the
Person and are NOT scoped to session — so cohort analysis across
long time windows uses `last_landing_arm` (durable) while
funnel-per-session analysis uses `landing_arm` (session-scoped).

Session-storage mirror (belt-and-suspenders): also written to
`LANDING_ARM_SESSION_STORAGE_KEY` (`'landing:arm'`) so `/auth/callback`
can defensively double-stamp `landing_arm` onto the client-side
`signed_up` capture even if PostHog's persistence has been cleared.
The callback uses conditional spread — it only stamps `landing_arm`
when sessionStorage has a value; passing an explicit `undefined`
would overwrite the session super-property and strip attribution
from cmd-click new-tab signups (Bugbot PR #4156).

## Common properties on every event

Every event fired through a landing page's `trackEvent` closure carries:

| Property | Source | Purpose |
| - | - | - |
| `arm` | inline in trackEvent | Cross-arm filter (landing events only) |
| `device_bucket` | `getLandingDeviceContext()` | `mobile` / `tablet` / `desktop` |
| `viewport_width` | `getLandingDeviceContext()` | Numeric pixel width at emit time |
| `viewport_height` | `getLandingDeviceContext()` | Numeric pixel height at emit time |
| (V4/V5 only) | | |
| `variant` | `LANDING_V{4,5}_ANALYTICS_PROPS` | Legacy arm tag |
| `landing_revision` | `LANDING_V{4,5}_ANALYTICS_PROPS` | Within-arm iteration tag |

These are evaluated at emit time (not at page load) so mid-session
device rotations, dev-tools device-toolbar toggles, and viewport
resizes are captured with the shape the visitor actually saw.

## Server-side assignment log: the fair denominator

### `landing_arm_assigned`

Fired by middleware (`lib/landing/server-landing-assignment.ts`) for every
visitor it assigns on `/`, **in all three arms**, at the same point in the
request, with the same properties and the same dedupe: once per visitor per
30-minute idle window. `$lib` is `silvia-middleware`.

The `landing_assignment_logged` cookie holds `<arm>:<distinct_id>` of the
**last** assignment logged. A visitor whose arm changes inside the window is
logged again, and so is one who changes back, because only the most recent
arm is remembered. For a given visitor the arm is fixed by a hash of their id,
so it only moves when the flag's variants or weights are edited. Treat any
such edit as the start of a new analysis window rather than attributing
across it.

| Property | Notes |
| - | - |
| `arm` | `control` \| `test` \| `test-b` |
| `landing_arm`, `$feature/landing-page-conversion-v02` | Same value as `arm` |
| `$referrer`, `$current_url`, `$raw_user_agent`, `$ip` | From the request; `$direct` when no referrer |
| `utm_*`, click ids | Copied from the query string, explicit allowlist |

**Use this, not `landing_page_viewed`, as the per-arm visitor count.**
`landing_page_viewed` is recorded at different moments per arm: V5's copy
is captured server-side at request time, V3/V4's in the browser once the app
has booted and Clerk has loaded. So V5 alone counts visitors who leave
before any script runs.

> **Known bad window: 2026-09-24 20:24 UTC until the fix in PR "fix(landing):
> forward bootstrap through Clerk's rewrite" reaches production. Do not
> compare arms across it.** New visitors were genuinely mis-routed, not just
> miscounted. Middleware's bootstrap step checked for `x-middleware-next`,
> which Clerk's `decorateRequest` removes from every `next()` (turning it into
> a same-URL rewrite), so the step never ran. A new visitor's minted id was
> never persisted or forwarded; the layout minted a second id and re-bucketed
> it, and a `test-b` on either draw ended on V5. Measured 2026-09-25/27: new
> visitors 17 / 18 / 65 (control / test / test-b), returning visitors, whose id
> was already in the cookie, 31 / 29 / 39. The same bug meant this event was
> only ever logged for V5 until the fix. An earlier version of this page
> blamed the counting asymmetry alone; that was wrong. The asymmetry is real
> but accounts for the smaller part.

Not logged, and arm-neutral by construction because each is decided before
any arm exists: requests that are not a real browser page load, bots,
signed-in visitors, `?ref=` and QA-override links, and requests where the
server's flags call timed out. Those still resolve their arm client-side as
before, so this event covers the server-assigned majority, not every visitor.

The client handoff to Framer (`framer-proxy-v1`: the one-shot
`landing_v5_framer_handoff` cookie that `middleware.ts` serves before
anything else) is deliberately not logged either. It only happens for
visitors whose arm was resolved in the browser because the server did not
assign them. Their V3/V4 counterparts are not logged, since they never make a
second request to `/`. Logging the handoff would count that group for V5
alone and bring back the one-arm bias this event exists to remove. (During
the bad window above, most handoffs were instead the mis-routed new
visitors; once the bootstrap reaches the layout, only genuinely unassigned
visitors take this path.)

**Best-effort delivery.** Like every middleware capture it is sent after the
response, so the dedupe cookie is already set by the time delivery is tried,
and a batch that still fails after the capture helper's retries is not
retried on a later load. During a PostHog outage the total can undercount.
The loss is arm-neutral, since all three arms share the same capture path, so
use this event for the per-arm split, not as an exact visitor census.

## Shared events (fire from every arm)

These events fire on all three arms with the same name and the same
payload shape. Compare arms on these directly.

### `landing_page_viewed`

Fires once per session (sessionStorage dedupe) on first mount.

| Property | Type | Notes |
| - | - | - |
| `path` | string | `window.location.pathname` at emit time |
| `referrer` | string \| null | Origin+pathname of `document.referrer`, or null |
| `arm`, common props | — | See above |

### `signup_button_clicked`

Fires when a visitor clicks any primary "Get started" CTA — hero, intro,
final CTA, and the nav "Sign up" button. Routes to `/sign-up`.

| Property | Type | Values |
| - | - | - |
| `source` | string | `landing_page_v3` \| `landing_page_v4` \| `landing_page_v5` |
| `section` | string | `nav` \| `hero` \| `intro` \| `final_cta` \| arm-specific (`how_it_works` on V4, `why_silvia` on V4) |

> Pricing-tier CTAs (`Get Free` / `Get Pro` / `Get Max`) do NOT fire
> this event — they fire `pricing_cta_clicked` (below). The primary
> conversion funnel should treat both as sign-up intent:
> `signup_button_clicked OR pricing_cta_clicked → signed_up`.

### `pricing_cta_clicked`

Fires when a visitor clicks any tier CTA on the pricing card (Free /
Pro / Max — both signed-out "Get X" and signed-in "Manage plan" /
"Go to Dashboard"). Payload shape standardized across V3, V4, V5
(2026-09-21) so a single filter groups all three arms.

| Property | Type | Values |
| - | - | - |
| `plan` | string | `free` \| `pro` \| `premium` — matches `PricingPlanId` |
| `interval` | string | `month` \| `year` (V5 always emits `month` — see V5 gotcha below) |
| `cta_type` | string | `primary` \| `outline` — matches `plan.ctaType` |
| `section` | string | `pricing` |
| `signed_in` | boolean | Clerk auth state at click time |
| `source` | string | `landing_page_v3` \| `landing_page_v4` \| `landing_page_v5` |

> **V5 gotcha:** V5's `pricing_cta_clicked` always sends
> `interval: 'month'` because Framer's `AnimatedPriceSwitch` toggle
> state isn't cleanly exposed via a stable DOM signal. Documented in
> `components/landing-v5/pricing.tsx`. Cross-arm annual-vs-monthly
> analysis needs to filter to V3/V4 arms only.

### `landing_scroll_depth`

Fires at 25 / 50 / 75 / 100% document scroll depth. Each bucket fires at
most once per session per arm. Emitted by `useLandingScrollDepth`.

| Property | Type | Values |
| - | - | - |
| `depth` | number | `25` \| `50` \| `75` \| `100` |
| `depth_pct` | number | Actual scroll % at emit |

### `landing_page_engaged`

Dwell-time pings from `useLandingEngagement`. Fires at 5 / 15 / 30 second
thresholds if the tab stays visible.

| Property | Type | Values |
| - | - | - |
| `seconds` | number | `5` \| `15` \| `30` |

### `<section>_section_viewed`

Fires the first time each section reaches 25% viewport intersection.
Emitted by `useSectionViewed`. Section slug is the event name minus the
`_section_viewed` suffix, also stamped as `section` in the payload for
easier funnel authoring.

**V5 (test-b) sections that emit:**
`hero_section_viewed`, `intro_section_viewed`, `features_section_viewed`,
`integrations_section_viewed`, `why_section_viewed`, `about_section_viewed`,
`pricing_section_viewed`, `numbers_proof_section_viewed`,
`faq_section_viewed`, `final_cta_section_viewed`.

V3 and V4 emit an equivalent set for their own section names — grep
`useSectionViewed` in `components/landing-page-v{3,4}.tsx` for the full
list per arm.

## Sign-up funnel events (downstream, but arm-attributed)

These fire OUTSIDE the landing page but carry `landing_arm` (via the
super-property) so they roll up cleanly by arm.

### `signup_page_viewed`

Fires when a visitor lands on `/sign-up`. Emitted by
`app/(public)/sign-up/[[...sign-up]]/page.tsx`.

| Property | Notes |
| - | - |
| `timestamp` | ISO timestamp |
| `in_app_browser` | Detected in-app browser (X/IG/etc.) or `null` |
| `quiz_id`, `quiz_session`, `quiz_outcome`, `quiz_score` | Growth-funnel context if the visitor came from a quiz CTA |
| `landing_arm` | Auto-stamped by super-property |
| `$set_once: { signup_start_time }` | First-time-only person property |

### `signup_plan_intent_captured`

Fires when the visitor arrives at `/sign-up?plan=<free|pro|premium>` (a
landing-pricing-CTA handoff). Stashes plan intent into sessionStorage
for the post-onboarding Stripe checkout redirect.

| Property | Values |
| - | - |
| `plan` | `free` \| `pro` \| `premium` |
| `interval` | `month` \| `year` |
| `source` | `landing_pricing_cta` |
| `landing_arm` | Auto-stamped by super-property |

### `auth_social_button_clicked`

Fires when the visitor clicks Clerk's Google or Coinbase OAuth button
on `/sign-up` or `/sign-in`. Emitted by
`components/auth/social-auth-click-tracker.tsx`.

| Property | Values |
| - | - |
| `provider` | `google` \| `coinbase` \| `unknown` |
| `page` | `sign_up` \| `sign_in` |
| `in_app_browser`, `social_auth_broken`, `platform` | Environment context |
| `landing_arm` | Auto-stamped by super-property |

### `signed_up` (client-side)

Fires when a new user reaches `/auth/callback` after Clerk finishes
signup. Deduped by `posthog_signed_up_tracked` sessionStorage latch.

| Property | Notes |
| - | - |
| `userId` | Clerk `user.id` |
| `source` | `clerk` |
| `authMethod` | `oauth` \| `email` |
| `timestamp` | ISO timestamp |
| `detection` | `auth_callback` (vs `hydration` fallback) |
| `landing_arm` | Explicitly stamped from `LANDING_ARM_SESSION_STORAGE_KEY` — the single most important conversion event, so we double-source it |
| `$set_once: { signup_complete_time }` | First-time-only person property |

### `user_signed_up` (server-side, from Clerk webhook)

Fires from `app/api/webhooks/clerk/route.ts` on `user.created`.
Bulletproof — fires even if the browser tab closes mid-redirect. Does
NOT carry `landing_arm` today (webhook has no session context). For
cohort analysis, filter Persons by `last_landing_arm` (set by the
landing hook's `setPersonProperties` call) and count
`user_signed_up` events per person.

| Property | Notes |
| - | - |
| `signup_source` | Clerk auth provider |
| `onboarding_auto_completed` | Bool |

## V5-specific interaction events

V5 has richer interactive components than V3/V4 (Framer's tabs, carousel,
integrations wall). These events are V5-only — they will show
`arm: test-b` exclusively.

| Event | Emitted by | Properties |
| - | - | - |
| `press_logo_click` | `intro.tsx` PressTile | `section: 'intro'`, `publication: string` |
| `features_tab_click` | `features.tsx` | `section: 'features'`, `label: string` |
| `features_card_click` | `features.tsx` | `section: 'features'`, `card: string` |
| `integration_chip_click` | `integrations.tsx` | `section: 'integrations'`, `label: string`, `href?: string` |
| `integrations_media_card_click` | `integrations.tsx` | `section: 'integrations'`, `badge: string` |
| `why_carousel_navigate` | `why.tsx` | `section: 'why'`, `label: string`, `href?: string` |
| `about_step_viewed` | `about.tsx` | `section: 'about'`, `step: 'step-1' \| 'step-2' \| 'step-3'` |
| `numbers_proof_chip_click` | `numbers-proof.tsx` | `section: 'numbers_proof'`, `label: string`, `href?: string` |
| `faq_item_opened` | `faq.tsx` | `section: 'faq'`, `question: string`, `position: number` |
| `final_cta_click` | `final-cta.tsx` | `section: 'final_cta'`, `label: string`, `href?: string` |
| `landing_v5_section_error` | `framer-error-boundary.tsx` | `arm: 'test-b'`, `section: string`, `error_message`, `component_stack` |

Delegated click tracking (Features tabs, Why carousel, Integrations
chips, NumbersProof chips) uses an `onClickCapture` handler that reads
`event.target.closest('a, button')` — so a click on any interactive
descendant of a Framer `.Responsive` component is captured without
forking the unframer output.

## Adding a new event

1. If it fires from ONE arm only: use that arm's `trackEvent` closure
   directly. It's already stamped with `arm` + device context + any
   arm-specific variant props.
2. If it fires from EVERY arm: also add the event to this doc's "Shared
   events" section, use the same name in all three arm files, and match
   the property shape exactly so PostHog funnels compare cleanly.
3. If it's a section impression: prefer `useSectionViewed('<name>_section_viewed', track, extraProps)`
   over hand-rolled IntersectionObserver code — it already handles
   sessionStorage dedupe with mid-session variant-flip refresh.
4. If it's a scroll milestone: use `useLandingScrollDepth(track, extraProps)`.
   `extraProps` should be the arm's `LANDING_V{4,5}_ANALYTICS_PROPS` (or
   an equivalent tagged object for V3) so a mid-session arm flip refires
   the buckets under the new arm.
5. For any interactive tile / button, tag the DOM node with
   `data-analytics-cta='<section>_<element>'` for dev-tools inspection
   and for future delegated-listener additions.

## Building the primary conversion funnel

Build the canonical primary funnel **once per arm**, not as a single funnel
broken down by arm. For arm `X`:

1. `landing_arm_assigned` filtered to `arm = X`. This is the like-for-like
   denominator (see above). `landing_page_viewed` still works within an
   arm, but overstates V5 against V3/V4.
2. `signup_button_clicked OR pricing_cta_clicked` — clickthrough intent
3. `signup_page_viewed` filtered to `landing_arm = X` (super-prop)
4. `signed_up` filtered to `landing_arm = X` (the client capture explicitly
   stamps `landing_arm` on this event)

**Why per arm.** A single funnel broken down by the first step's `arm` uses
PostHog's default first-touchpoint attribution, so a visitor logged in
`control` and later in `test` would have a signup made under `test` credited
to `control`. Filtering the assignment step by `arm` and every later step by
`landing_arm` credits each conversion to the arm the visitor was in when they
converted. A reassigned visitor enters step 1 of both arms but converts in at
most one. Reassignment only follows a flag edit, which should start a new
analysis window anyway (see the dedupe note above).

Put the three per-arm funnels side by side to compare control vs test vs
test-b end-to-end. Add `landing_scroll_depth = 100` or
`landing_page_engaged { seconds: 30 }` for a "scrolled deep / stayed long"
pre-conversion cohort.

For the "actual conversion rate" bottom-line KPI, use the person-level
cohort filter `Person WHERE last_landing_arm = 'test-b'` and count
`user_signed_up` events against that cohort — the webhook is the most
reliable signal because it can't be lost to a browser closing
mid-redirect.

## Files owning the analytics contract

* `hooks/use-landing-conversion-experiment.ts` — flag key, arm resolution, `landing_arm` super-property + person-property registration
* `components/landing-page-v3.tsx` — `trackEvent` + `useLandingEngagement` + `useLandingScrollDepth`
* `components/landing-page-v4.tsx` — same + `LANDING_V4_ANALYTICS_PROPS` stamps
* `components/landing-page-v5.tsx` — same + `LANDING_V5_ANALYTICS_PROPS` stamps
* `components/landing-v4/analytics.ts` — V4 arm/variant constants
* `components/landing-v5/analytics.ts` — V5 arm/variant constants + shared `TrackFn` / `noopTrack`
* `hooks/use-section-viewed.ts` — shared IO-based impression hook
* `hooks/use-landing-scroll-depth.ts` — 25/50/75/100 scroll milestones
* `hooks/use-landing-engagement.ts` — 5s/15s/30s dwell pings
* `lib/landing/device-context.ts` — the `device_bucket` / `viewport_*` stamps
* `lib/landing/plan-intent.ts` — plan-CTA → sign-up → Stripe checkout handoff (`signup_plan_intent_captured`, `pricing_intent_carried_through`)
* `lib/activity-tracker.ts` — the global `isAnalyticsEnabled()` gate (DNT / consent / dev opt-out)
* `components/site-top-nav.tsx` — accepts `track` prop, fires nav-click events
* `components/site-footer.tsx` — accepts `track` prop, fires footer-click / social events
* `app/(public)/auth/callback/page.tsx` — client-side `signed_up` capture (double-stamps `landing_arm`)
* `app/api/webhooks/clerk/route.ts` — server-side `user_signed_up` capture (no `landing_arm`; use Person cohort filter)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.