# marko-web-p1-events — Claude Code Context

Parameter1 user-event ("p1events") tracking for Mindful Web / Marko websites. Marko
components emit events into the p1events pipeline (`events.parameter1.com`) via a global
`window.p1events(...)` command queue installed by `init.marko`. This package owns the
**client** side only; ingest/classification lives in the `events` repo and reporting in
`mindful-reporting`.

## Config model — `sites/<domain>/config/site.js` `p1events`

Every component reads config with `site.getAsObject("p1events")`. Shape:

```js
p1events: {
  enabled: true,            // master gate — nothing tracks unless true
  tenant: 'abmedia',        // required alongside `enabled` for init to run
  cookieDomain: '…',        // prod cookie domain for the p1events lib
  engagement: {             // optional; engagement is default-ON (see below)
    enabled: false,         // set false to OPT OUT of engagement on this site
    floorPx: 500,           // override engagement scroll thresholds (rare)
    viewportFraction: 0.75,
    enableTextSelect: false,
  },
}
```

There is **no shared/base p1events config** — each site declares its own inline `p1events`
literal. (This is why engagement was made default-ON rather than adding it per-site — see
mindful-web #275.)

## Initialization

- **`init.marko`** (`<marko-web-p1-events-init>`) gates on `enabled && tenant`, loads the
  p1events lib (`cdn.parameter1.com/p1-events/...`), and calls `p1events('init', { tenant,
  realm, env, uri, cookieDomain, … })`. Optional GAM / Mindful ad-server plugins
  (`enableGamPlugin` / `enableMindfulPlugin`) and deferred loading via `on`.
- **`middleware.js`** — `p1EventsVisitorCookie()` sets the `__p1e_vis` visitor cookie
  server-side (2-year, non-httpOnly). Important for the Mindful ad server, which reads it
  when requesting ads.

## Event model

All tracking goes through `window.p1events('track', { category, action, entity, label?,
ctx?, props? })`. `entity = { id, ns, name?, props?, refs? }`. Namespaces come from
`utils/create-namespace.js` → `base.{TENANT_KEY}.{type}` (native/mindful trackers use
`mindful.{namespace}.*` instead). `p1events('setIdentity', entity)` associates the session
with an app-user entity (used by conversion events so the session de-anonymizes at submit).

## View trackers (one `View` event per page-type)

Each gates on `config.enabled`, emits `action: 'View'`, and — except native-story — mounts
`<marko-web-p1-events-track-engagement>` passing its own `category` + `entity` as props.

| Component | category | entity ns (`base.{tenant}.`…) | notes |
|---|---|---|---|
| `track-content` | `Content` | `content-{type}` | refs: primarySection, company, authors, createdBy; `label: 'Sponsored'` when sponsored |
| `track-website-section` | `Website Section` | `website-section` | |
| `track-static-route-view` | `Static Route` | `static-route` | |
| `track-user-route-view` | `User Route` | `user-route` | |
| `track-search` | `Search` | `search` | `label` = search term, lowercased |
| `track-native-story` | `Advertising Post` / `Content` | `mindful.{ns}.advertising-post` / nativeX ns | mindful-ads vs nativeX branch; **does not** mount engagement |

## Engagement tracking — `Engaged View`

**Why:** a positive "humanity" signal to counter bot-inflated page views. Cloudflare `bmsc`
already gates p1events, but the 30–99 score band passes through as human, so AI crawlers /
headless leak in. Engagement is orthogonal (positive humanity vs. negative bot score) and
lets reporting surface **"Engaged Page Views."**

**Wrapper — `track-engagement.marko`** (`<marko-web-p1-events-track-engagement>`):
- Gates on `config.enabled && engagement.enabled !== false` → **default-ON (opt-out) since
  v1.76.4** (mindful-web #275). A site opts out with `p1events.engagement.enabled: false`.
- Reads `floorPx` / `viewportFraction` / `enableTextSelect` thresholds from config and
  passes `category` + `entity` (from the mounting View tracker) as props.

**Browser component — `browser/track-engagement.vue`:** fires exactly **one** `Engaged View`
on the first genuine human interaction, then unbinds all listeners. One View tracker per
page → exactly one engagement event, attributed to that page's entity.

- **Triggers (OR):** scroll past `max(floorPx=500, viewportFraction=0.75 × innerHeight)` —
  armed **only after a real gesture** (wheel/touch/keydown, to ignore layout-shift/anchor
  jumps); link/button click; menu-open (classified via `MENU_SELECTOR`); form-field focus
  (`focusin`); media `play`; text-selection (opt-in, off by default).
- **Event shape:** `action: 'Engaged View'`; `category` + `entity` mirror the View;
  `label: 'Sponsored'` on sponsored content (so reporting splits editorial vs. sponsored
  engagement the same way it splits Views); `props.eventName` = the trigger type
  (`scroll | link-click | menu-open | form-input | media-play | text-select`).
- **Ad clicks are NOT a trigger.** GAM and Mindful creatives render in cross-origin
  (safe)frames, so their clicks never bubble to the page — and they are already tracked and
  bot-filtered by the ad servers. Same-origin ad containers (`[data-unit-form]`,
  `[id^="mfads-req-"]`) are explicitly ignored.
- **Deliberately separate from scroll-depth.** Engagement is single-shot humanity; the
  scroll-depth trackers are gated-content-aware and multi-threshold. Do not merge them.
- **GTM/GA4 mirror.** `fire()` also pushes a `verified-view` event to `window.dataLayer`
  (in addition to the p1event) so GA4 can report the same signal. Shape follows the fleet
  convention (`identity-x`): a top-level `event: 'verified-view'` for the GTM Custom Event
  trigger, plus a nested `'p1-events'` namespace object (`category`, `action: 'Engaged
  View'`, `label?`, `eventName`, `entityId`/`entityNs`/`entityName`) that GTM Data Layer
  Variables read. Pushed independently of `window.p1events`, so GA4 still gets it if the
  p1events lib is blocked. Gated only on `dataLayer` presence — no extra config. GTM side:
  Custom Event trigger on `verified-view` → GA4 event tag mapping the `p1-events.*` DLVs.

**Accepted limitations** (deliberately biased toward *under*-counting — the safe direction
for an advertiser-facing "engaged" number):
- **Ad-only clickers uncredited per-post.** A human whose only interaction is an
  above-the-fold ad click isn't counted engaged — the tracker can't see cross-origin ad
  clicks, and the bot-filtered ad-click p1event carries the ad-creative entity, not the
  content entity. Tiny population and the most bot-adversarial signal; measure the gap from
  ad-click events rather than blending it in.
- **Forward-only.** `engagedViews` exists from client-ship forward; historical days show 0.
  Communicate this to analytics consumers.
- **Score-filter residual.** Catches the cheap-bot majority (`bmsc` 30–99 leaking into
  `events-by-hour`), not synthetic-interaction adversaries. A sub-second time-to-interaction
  floor was deferred (instrument first) — add only if a near-zero synthetic cluster appears.

## Scroll-depth trackers — `track-scroll-depth/`

Separate family (`track-content-scroll-depth`, `-website-section-`, `-native-story-`) that
emit `Scroll` events at `targetScrollDepths` (default `[0.25, 0.5, 0.75, 0.9]`) via the
`P1EventsTrackScrollDepth` browser component. Content/section entities mirror the View
trackers'. Each needs `content` (builds the entity) and a CSS `selector` for the scroll
container. **Gated-content-aware and multi-threshold — deliberately NOT merged with
engagement** (see above).

**Gating principle (critical).** Scroll depth must fire ONLY when the full body is actually
on the page — never on an anonymous/gated preview (a "Scroll 90%" on a 2-paragraph preview
is a false read). This is enforced by **placement/selector, not a runtime auth check**: the
full body renders through `theme-body-with-injection` (or a site's local
`content-body-with-injection`) into `#content-body-${content.id}`, while gated previews
render a truncated `marko-web-content-body` with **no** id — so the tracker only ever
attaches to the full render. Access (`canAccess`) is resolved server-side
(`marko-web-identity-x/components/access.marko`), so the SSR HTML already differs.

**The seam.** `theme-body-with-injection` takes an opt-in `track-scroll-depth` attribute
(+ optional `target-scroll-depths`); when set it renders
`<marko-web-p1-events-track-content-scroll-depth content=content selector=\`#${selector}\`>`
after the body. Site content layouts flip `track-scroll-depth=true` on the **primary-body**
(`content=content`) calls. Bare-`marko-web-content-body` layouts (product/company/whitepaper/
webinar/document/media-gallery) instead get the tracker added at the call site, targeting the
body's `#content-body-${id}` id (or its `.${blockName}__content-body` class, e.g. company =
`.leaders-more-info__content-body`).

**Rules when extending (new content type / new site):**
- Enable ONLY on the full, id'd `obj=content` render. NEVER on `obj={ body }` /
  `getContentPreview(...)` previews, nor hidden `modifiers=["ld-json"]` / `display:none` SEO
  copies.
- NEVER on the transcript render (`content={ body: content.transcript }`) —
  `theme-body-with-injection` is reused for transcripts, so a second tracker double-counts.
- Do NOT add tracking to the base `marko-web-content-body` component — it's a generic field
  renderer (feeds/teasers/inline/print), not a valid seam.
- **Pitfall:** older sites had an *ungated* `track-content-scroll-depth` in content
  `wrapper.marko` `<@page>` (selector `.…page-contents__content-body`) that fired on previews
  and double-counted the gated tracker. Removed fleet-wide — do not reintroduce a
  wrapper-level scroll tracker.

## Conversion & integration events — `browser/index.js`

Bridges identity-x / form EventBus events to `p1events('track', …)`:
- Newsletter form action → `category/action/label` from the payload.
- Identity conversions: `Submit` (Content Access, Content Download, Profile), `Sent`
  (Login Link), `Click` (Login Link); Subscription `Subscribe`. Events in the
  `identifyOnSubmit` set call `setIdentity` **before** tracking so the session
  de-anonymizes at submit time (identity is otherwise baked at page render).
- Submission trackers: `track-inquiry-submission` / `track-download-submission`
  (`eventName` prop), `track-content-body-links` (`category` / `action` props).

## Downstream (pointers — not the source of truth here)

- **Reporting:** `Engaged View` events roll up to `metrics.engagedViews` and are exposed as
  `engagedViews` / `engagedPageViews` — see `mindful-reporting` (graphql/reporting
  `resolvers/analytics/content-performance.ts` + the content-post sync).
- **CMS:** surfaced in the Content Performance analytics view in `mindful-cms`.
