Overture is the marketing site's engagement overlay system. A configurable card appears
at any of nine placements on matters.ai pages — corners, edge centres, mid-screen, or a
full-width top/bottom bar — once its trigger fires (time on page, scroll
depth, exit intent or resource intent; 7 seconds by default), shows one of six premade
archetypes, and captures visitor intent — newsletter subscriptions,
webinar registrations, lead details, whitepaper downloads — or simply presents a message.
Everything about it (targeting, copy, timing, frequency, scheduling, appearance) is
configured only in the workspace at /overture.
Design spec: docs/superpowers/specs/2026-07-18-overture-popup-engine-design.md.
| Kind | Form fields | Submits to | Server effect |
|---|---|---|---|
subscribe | email (+ optional name) | POST /api/overture/convert | upsertConfirmedSubscription into universal_subscriptions (source overture), optional MailerLite group + Salesforce push |
lead | name, email, company (+ optional phone) | POST /api/overture/convert | Insert into mattersai-leads / overture_leads (visible in Audience → Leads), optional Salesforce/MailerLite |
webinar | name, email, company | existing POST /api/webinars/register | The full webinar pipeline is reused untouched: dedupe, confirmation email, fully-booked fallback, Salesforce + MailerLite |
whitepaper | name, email, country (+ company) | existing POST /api/whitepapers/submit | Existing pipeline: lead sheet + Mongo + Slack, then the card blob-downloads the returned file |
testimonial | none | — | Social-proof quote (picked from lib/testimonials.ts or custom) + optional CTA link |
announcement | none | — | Headline + body + CTA link (webinar promo, case study, event) |
Copy is fully editable per overture: title, body, ctaLabel, dismissLabel,
successTitle, successBody, plus optional imageUrl (square thumbnail beside the
headline, https or site-relative only), countdownTo (live ticking countdown to an
instant), and secondaryLabel / secondaryHref (a quiet secondary link).
The subscribe and lead archetypes can also collect a role —
collectJobTitle renders a CXO / CISO / Engineer / Other dropdown (WEB-2), stored on the
lead and appended to the Salesforce description.
One MongoDB collection: db mattersai, collection overtures. The canonical shape is
Overture in lib/schemas/overture.ts (Zod is the single source of truth):
name, slug (unique, kebab-case), status (draft | active | paused | archived), priority (0–1000)
content — discriminated union on kind (the archetype table above)
targeting.include / targeting.exclude — path patterns (grammar below)
behavior — delaySeconds (default 7), frequency, cooldownDays (default 7), startAt/endAt schedule window, daysOfWeek + hourStart/hourEnd recurring window, maxImpressions cap
audience — visitor/campaign/referrer/query/country rules (see below)
variant — optional A/B side with its own copy and/or looks
appearance.placement — nine positions: the four corners (bottom-right default,
bottom-left, top-right, top-left), the two edge centres (bottom-center,
top-center), center (mid-screen, optional dim scrim via appearance.backdrop),
and two full-width bars (bottom-bar, top-bar) that re-flow the content into a
single row with the form fields laid out horizontally. Top-anchored placements clear
the site navbar by reading its --nav-height-scrolled custom property.
appearance.tone — seven brand surfaces (paper, ink, blue, mist, cream,
sky, plum) plus custom, which reads four hex values from appearance.custom
(surface / ink / action / action text) and applies them as CSS custom properties
appearance.shape — soft (20px), edge (4px), sharp (0px), round (32px card,
pill controls)
appearance.size — six fluid tiers: mini (272), compact (320), standard (380),
wide (440), large (560), panel (720). Every tier renders at
min(tier, 100vw − 2rem), so a 720px panel sits comfortably on a 320px phone (288px)
with no media query and no horizontal overflow. Bars span the viewport regardless.
Gotcha worth knowing: calc() requires whitespace around the minus, so these are
written w-[min(720px,calc(100vw_-_2rem))] — Tailwind converts the underscores. Writing
calc(100vw-2rem) is invalid CSS and silently drops the whole declaration
appearance.density — cosy / comfortable / roomy padding, independent of width
appearance.layout — stack (copy then action), row (copy and action side by side,
with the form fields flowing horizontally) or split (a real media panel beside the
content, positioned by appearance.mediaPosition: left / right / top). Bars are
always a row; split falls back to stack when no image is set
appearance.elevation — flat / soft / strong shadow weight
appearance.animation — slide (from the placement's own edge), fade, pop or
none; reduced-motion visitors always get a plain fade
appearance.rail — optional 3px accent on the card's leading edge: none, blue,
sky, plum, cream
appearance.showClose / appearance.showDismissLink — hide the X and/or the
"No thanks" link
behavior.trigger — delay (time on page), scroll (fires at
behavior.scrollPercent depth), exit_intent (pointer leaves through the top; touch
devices fall back to the delay) or resource_intent (a click on a link into a resource
section, or on any download link — sections configurable via
behavior.resourcePatterns, defaulting to blogs, whitepapers, datasheets, webinars,
videos, case studies and downloads)
behavior.devices — all, desktop or mobile
behavior.successAutoCloseSeconds — seconds the thank-you stays up (0 keeps it open)
behavior.reopenAfterDismiss — leaves a small re-open pill in the same corner after a
dismissal, so the offer stays recoverable instead of disappearing for the whole cooldown.
Re-opening does not count as a second impression
stats — impressions, dismissals, conversions (server-side $inc counters), plus stats.variants.a / .b when an A/B test has run
audit: created_at/by, updated_at/by (Clerk user ids)
Placement geometry lives in components/overture/placement.ts (under components/
because Tailwind does not scan lib/), and is unit-tested in
tests/overture/placement.test.ts.
Path targeting says which pages; audience says which people. Pure matcher in
lib/overture/audience.ts (unit-tested), evaluated client-side. Every empty field means
"no restriction", so a blank audience behaves exactly as before it existed.
| Field | Effect |
|---|---|
visitorType | any, new (first session) or returning |
utmSource / utmMedium / utmCampaign | Match the landing URL's campaign parameters (case-insensitive, any-of). Persisted for the session, so they survive in-site navigation |
referrerContains | Substring of the referring URL — linkedin catches every LinkedIn host |
queryKey / queryValue | Require a query parameter; empty value means "present with any value" |
countries / excludeCountries | ISO alpha-2 allow/deny. Deny wins; an allow-list cannot be satisfied when geo is unknown. Geo is fetched from /api/geo only when some active overture targets countries, and cached per session |
On top of the absolute startAt/endAt window: behavior.daysOfWeek (empty = every day)
and an inclusive hourStart–hourEnd window, both evaluated in the visitor's local
time (that is what "business hours only" means to a marketer). Setting hourEnd lower
than hourStart wraps past midnight. behavior.maxImpressions caps lifetime views per
visitor (0 = unlimited). Logic in lib/overture/schedule.ts.
variant.enabled splits traffic; variant.splitPercent is the share going to B. Variant B
carries its own content and/or appearance — a null block means "same as A", so a test
can vary copy only, looks only, or both.
Assignment is sticky by construction: a pure FNV-1a hash of slug + visitorId
(lib/overture/variant.ts), so a returning visitor always sees the same side with nothing
stored, and two overtures split independently rather than in lockstep. The visitor id is a
random first-party value in localStorage (matters_overture_visitor) alongside the
existing frequency state — no PII, no third-party storage.
Counters are kept per side (stats.variants.a / .b) and the workspace list's
Performance drawer shows each side's conversion rate with the leader highlighted.
Every event increments three places: the lifetime total, the per-variant total, and a
per-day bucket (overture_daily, one document per slug + UTC day). The workspace reads
/api/workspace/overture/[id]/stats?days=14 and renders a themed 14-day trend via the
shared WsTimeSeriesChart. GA4/GTM events carry overture_variant alongside slug and kind.
The card is role="dialog" with aria-live="polite", never steals focus for anchored
placements, and never blocks scrolling. A centred overture with a backdrop behaves like
a real dialog: focus moves to its first control on open and returns to the previously
focused element on close. Escape closes any open overture. Every animation collapses to
a plain fade when the visitor prefers reduced motion.
Append ?overture=<slug> to any marketing URL to force that overture to render
immediately, bypassing targeting, frequency, the global gate and the trigger. Add
&overture_variant=b to inspect the B side. Handy for reviewing an overture in real page
context before activating it.
One singleton document (mattersai → overture_settings), edited from the
Module settings panel at the top of the workspace list page and shipped to the site
inside the same cached /api/overture/active payload:
| Setting | Effect |
|---|---|
enabled | Master switch — off means nothing renders anywhere |
maxPerSession | Distinct overtures one visitor may see per session (default 2) |
minGapSeconds | Quiet period after one closes before another may appear (default 90) |
respectConsentBanner | Defer to the cookie banner before showing anything |
globalExclude | Paths no overture may ever appear on, whatever its own targeting says |
defaultDelaySeconds | Pre-filled delay on newly created overtures |
The gate is pure and unit-tested in lib/overture/gating.ts /
tests/overture/gating.test.ts; the host applies it before arming any trigger.
Implemented in lib/overture/match.ts (pure, jest-tested in tests/overture/match.test.ts):
| Pattern | Matches |
|---|---|
/ | homepage only |
/pricing | that exact path (case-insensitive, trailing slash/query/hash normalized) |
/platform/* | exactly one extra segment (/platform/agents, not /platform/agents/x) |
/webinars/** | any depth including /webinars itself |
** | every page |
Rules: at least one include must match AND no exclude may match — exclusions always
win. When several active overtures match a page, the highest priority wins; ties go to
the most recently updated. At most one popup ever shows per page view.
Hard-excluded route prefixes (never show, regardless of config): /admin, /wp-admin,
/tools, /auth, /workspace, /overture, /confirm, /c/.
Implemented in lib/overture/frequency.ts (jest-tested), state stored client-side in
localStorage (matters_overture_state, versioned) + sessionStorage:
| Frequency | Behavior |
|---|---|
always | every eligible page view |
once_per_session | once per browser session |
once_per_visitor | once ever per device |
until_dismissed | shows until dismissed; re-eligible cooldownDays after dismissal |
until_converted (default) | dismissals obey the cooldown; a conversion silences it permanently |
Corrupt/blocked storage always degrades to "show". The popup also defers while the
cookie-consent banner is visible (they never stack) and respects prefers-reduced-motion.
Marketing site (matters.ai, root app)
├── components/overture/OvertureHost.tsx — mounted in app/layout.tsx (inside ConsentProvider)
│ fetches /api/overture/active (TanStack Query, staleTime 10 min)
│ re-matches on every client navigation, runs the delay timer,
│ frequency-caps, emits GA4/GTM + server counters
├── components/overture/OvertureCard.tsx — presentational card, 3 brand tones, framer-motion
├── components/overture/forms/* — per-kind mini-forms (react-hook-form + zod + reCAPTCHA)
└── app/overture/preview — noindex postMessage preview surface for the editor
Public APIs (root app)
├── GET /api/overture/active — active set, cachedFetch tag "overtures", revalidate 300 s
├── POST /api/overture/convert — subscribe/lead conversions (origin check → BotID → reCAPTCHA → zod)
└── POST /api/overture/track — impression/dismiss/convert counters (sendBeacon-friendly)
Workspace (workspace.matters.ai)
├── /overture — list: status/kind, targeting summary, stats, CVR, pause/activate
├── /overture/[id] ("new" = create) — editor + live preview iframe
└── /api/workspace/overture[/*] — CRUD + duplicate + picker options, org:marketing:* gated
The service layer (lib/services/overture.service.ts) is shared by both apps through the
@/lib alias. Fan-out toggles (MailerLite/Salesforce) are resolved server-side from the
stored overture on every conversion — the client cannot force pushes the admin didn't
enable.
GET /api/overture/active is cached under the overtures tag with a 300 s revalidate
(plus CDN s-maxage=300). Admin writes call revalidateTag("overtures", "max") locally
and POST a { tags: ["overtures"] } body to the marketing deployment's
/api/revalidate webhook (same WORDPRESS_WEBHOOK_SECRET, strict tag allowlist) — because
the workspace and marketing site are separate deployments, the local revalidate alone
cannot bust the marketing cache. Worst case propagation without the webhook: 5 minutes.
A dedicated marketing scope (added to lib/auth/rbac-shared.ts):
| Permission | Grants |
|---|---|
org:marketing:view | see the module, list, options (also: content_editor, publisher) |
org:marketing:edit | create/update/duplicate, and the editor page |
org:marketing:delete | delete (edit also suffices — mirrors other modules) |
Full grants: super_admin, admin, marketing_manager. content_manager gets view+edit.
Enforcement is two-layered: requirePermission in the layout/pages and per-verb
checkRole in every API handler. The nav entry (badge Marketing) lives in
lib/workspace-nav.ts.
The editor iframes the marketing site's /overture/preview page and postMessages the
draft (debounced 300 ms, message type overture:preview). The preview page allowlists
message origins (same-origin, NEXT_PUBLIC_WORKSPACE_URL, localhost) and re-validates the
draft's content with Zod before rendering the real OvertureCard — so what you see is the
production rendering, real fonts and all, not a workspace-theme approximation.
Three server counters per overture (impressions, dismissals, conversions — CVR shown
in the list) plus GTM dataLayer events for GA4/LinkedIn/Meta tags:
overture_impression, overture_dismiss, overture_conversion, each with
overture_slug, overture_kind, page_path. Webinar/whitepaper conversions (which submit
to their own endpoints) are counted client-side via the track endpoint; subscribe/lead
conversions are counted server-side in /api/overture/convert.
After a conversion the card shows its success copy, then self-closes after 6 seconds; a post-conversion close (auto or the X) is never counted as a dismissal. The editor preview keeps the success state pinned for inspection, and re-posts of an unchanged draft never reset the preview card.
The editor's live preview iframes the marketing site — in local dev the
marketing app must be running on port 4000, and both dev servers must be
restarted after next.config.ts changes (headers are loaded at boot).
The module ships dark: nothing renders on the site until an overture is created and
set active in the workspace.
/api/overture/active never 500s the shell — any failure returns an empty list and the
host renders nothing.
Deleting an overture removes it from the site within the propagation window; its stats go with it (duplicate first if you want to keep history).
overture_leads is registered in lib/services/sales/lead-collections.ts, so lead-kind
submissions appear in the Audience leads viewer and the sales export (RBAC-gated).
org:marketing:* scope extends.