diff --git a/qawolf-attribution-handler.js b/qawolf-attribution-handler.js
new file mode 100644
index 0000000..175ebfa
--- /dev/null
+++ b/qawolf-attribution-handler.js
@@ -0,0 +1,146 @@
+/*
+ Must be present (in some form) before qawolf-signup-handler.js runs its
+ submit handler, since that script calls window.qawAttribution.getAttribution()
+ with no null-check. In practice this isn't a real ordering risk: this file
+ sets window.qawAttribution synchronously within milliseconds of page load,
+ long before a user could type an email and click submit.
+
+ No head/early-load requirement despite earlier assumption: document.referrer
+ is fixed by the browser at navigation time regardless of when JS reads it,
+ and setIfAbsent() means first-touch data only gets captured once per
+ session anyway. Mintlify's standard "runs after the page becomes
+ interactive" timing is sufficient.
+
+ Nothing sensitive in here — no keys, no internal endpoints — confirmed
+ safe to commit as plain source in the public docs repo.
+*/
+
+(function () {
+ const attributionUrlParams = ['fbclid', 'gclid', 'utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term'];
+ const attributionCookies = ['li_fat_id'];
+
+ const LANDING_PAGE_KEY = 'qaw_attr_landing_page';
+ const REFERRER_KEY = 'qaw_attr_referrer';
+ function sessionKey(name) {
+ return 'qaw_attr_' + name;
+ }
+
+ function setIfAbsent(key, value) {
+ if (!value) return;
+ try {
+ if (sessionStorage.getItem(key)) return;
+ sessionStorage.setItem(key, value);
+ } catch {
+ // sessionStorage unavailable
+ }
+ }
+
+ function readSession(key) {
+ try {
+ return sessionStorage.getItem(key) || undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ function getQueryParam(name) {
+ try {
+ const url = new URL(window.location.href);
+ return url.searchParams.get(name) || undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ function getCookie(name) {
+ if (typeof document === 'undefined') return undefined;
+ try {
+ const rows = document.cookie.split('; ');
+ for (let i = 0; i < rows.length; i++) {
+ if (rows[i].indexOf(name + '=') === 0) {
+ const value = rows[i].slice(name.length + 1);
+ return value ? decodeURIComponent(value) : undefined;
+ }
+ }
+ return undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ function initializeAttribution() {
+ if (typeof window === 'undefined') return;
+ setIfAbsent(LANDING_PAGE_KEY, window.location.href);
+ setIfAbsent(REFERRER_KEY, document.referrer);
+ attributionUrlParams.forEach(function (name) {
+ setIfAbsent(sessionKey(name), getQueryParam(name));
+ });
+ attributionCookies.forEach(function (name) {
+ setIfAbsent(sessionKey(name), getCookie(name));
+ });
+ }
+
+ function getStoredAttribution() {
+ if (typeof window === 'undefined') return {};
+ const params = {
+ landing_page: readSession(LANDING_PAGE_KEY),
+ referrer: readSession(REFERRER_KEY),
+ };
+ attributionUrlParams.forEach(function (name) {
+ params[name] = readSession(sessionKey(name));
+ });
+ attributionCookies.forEach(function (name) {
+ const live = getCookie(name);
+ params[name] = live != null ? live : readSession(sessionKey(name));
+ });
+ const output = {};
+ Object.keys(params).forEach(function (key) {
+ if (params[key]) output[key] = params[key];
+ });
+ return output;
+ }
+
+ function getPosthogAttribution() {
+ if (typeof window === 'undefined') return {};
+ var posthog = window.posthog;
+ if (!posthog || typeof posthog.get_property !== 'function' || typeof posthog.getSessionProperty !== 'function') return {};
+
+ try {
+ const clientSessionProps = posthog.get_property('$client_session_props');
+ const cspProps = (clientSessionProps ? clientSessionProps.props : {}) || {};
+ const merged = {};
+ attributionUrlParams.concat(attributionCookies).forEach(function (key) {
+ merged[key] = posthog.getSessionProperty(key);
+ });
+ merged.landing_page = cspProps.u;
+ merged.referrer = cspProps.r;
+ const output = {};
+ Object.keys(merged).forEach(function (key) {
+ if (merged[key]) output[key] = merged[key];
+ });
+ return output;
+ } catch {
+ return {};
+ }
+ }
+
+ function getAttribution() {
+ const stored = getStoredAttribution();
+ const posthog = getPosthogAttribution();
+
+ const output = {};
+ Object.keys(stored).forEach(function (k) {
+ output[k] = stored[k];
+ });
+ Object.keys(posthog).forEach(function (k) {
+ output[k] = posthog[k];
+ });
+ if (typeof window !== 'undefined' && window.location && window.location.href) {
+ output.submitted_from_url = window.location.href;
+ }
+ return output;
+ }
+
+ initializeAttribution();
+ window.qawAttribution = { getAttribution };
+})();
diff --git a/qawolf-signup-handler.js b/qawolf-signup-handler.js
new file mode 100644
index 0000000..3a7a3c0
--- /dev/null
+++ b/qawolf-signup-handler.js
@@ -0,0 +1,243 @@
+/*
+ Verbatim handler from the design team, unmodified.
+
+ Uses the default platformUrl ('https://app.qawolf.com') since
+ document.currentScript.dataset won't be set when Mintlify includes this
+ as an external file rather than an inline tag with data-platform-url — the
+ fallback in the code already covers this, no changes needed here.
+
+ Requires:
+ 1. CORS on app.qawolf.com's /api/trpc/* allows the docs origin.
+ 2. window.qawAttribution is loaded (or safely stubbed) on the docs domain.
+ Until then every submission fails with the same generic "Failed to
+ connect to the server" message, which looks identical regardless of which
+ of the two is actually missing.
+*/
+
+function getPosthogIds() {
+ const posthog = window?.posthog;
+ if (!posthog) return {};
+ const posthogDistinctId = posthog.get_distinct_id();
+ const posthogSessionId = posthog.get_session_id();
+ return {
+ posthogDistinctId,
+ posthogSessionId,
+ };
+}
+
+const platformUrl = document.currentScript?.dataset.platformUrl || 'https://app.qawolf.com';
+const requestTimeoutMs = 5000;
+const genericError = 'Something went wrong. Please try again.';
+const invalidEmail = 'Please enter a valid email address.';
+
+const boundForms = new WeakSet();
+
+function renderFatalBanner(message) {
+ const banner = document.createElement('div');
+ banner.textContent = `Signup handler broken: ${message}`;
+ banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;' + 'background:#b00020;color:#fff;padding:12px 16px;' + 'font:14px/1.4 system-ui,sans-serif;text-align:center;';
+ document.body.appendChild(banner);
+}
+
+async function postTrpc(procedure, payload, signal) {
+ const response = await fetch(`${platformUrl}/api/trpc/${procedure}`, {
+ body: JSON.stringify({ json: payload }),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ signal,
+ });
+ if (!response.ok) {
+ const errorBody = await response.json().catch(() => undefined);
+ return {
+ type: 'http-error',
+ status: response.status,
+ body: errorBody,
+ message: errorBody?.error?.json?.message,
+ };
+ }
+ const body = await response.json();
+ const result = body?.result?.data?.json;
+ if (!result || !result.outcome) {
+ return { type: 'bad-shape', body };
+ }
+ return { type: 'ok', result };
+}
+
+function navigate(url) {
+ window.location.href = url;
+}
+
+// Navigates if maybeUrl is a valid same-origin URL; returns an error message otherwise.
+function navigateToSafeUrl(maybeUrl) {
+ if (typeof maybeUrl !== 'string' || !maybeUrl) return genericError;
+ let target;
+ try {
+ target = new URL(maybeUrl, platformUrl);
+ } catch (error) {
+ return genericError;
+ }
+ if (target.origin !== new URL(platformUrl).origin) return genericError;
+ navigate(target.href);
+ return undefined;
+}
+
+function handleSignUpOutcome(result) {
+ switch (result.outcome) {
+ case "code-sent":
+ case "sso-required":
+ return navigateToSafeUrl(result.redirectUrl);
+ case 'proceed-to-signin':
+ return navigateToSafeUrl(result.signInUrl);
+ case 'temporarily-disabled':
+ navigate('/book-a-demo');
+ return undefined;
+ case 'invalid-email':
+ switch (result.reason) {
+ case 'malformed':
+ return invalidEmail;
+ case 'public-domain':
+ return 'Please use your work email address.';
+ case 'blocked':
+ return 'Please use a different email address to sign up.';
+ }
+ default:
+ return genericError;
+ }
+}
+
+function handleBookADemoOutcome(result) {
+ switch (result.outcome) {
+ case 'ok':
+ return navigateToSafeUrl(result.redirectTo);
+ case 'invalid-email':
+ return invalidEmail;
+ default:
+ return genericError;
+ }
+}
+
+function buildPayload(email) {
+ return {
+ email,
+ attribution: window.qawAttribution.getAttribution(),
+ ...getPosthogIds(),
+ };
+}
+
+const FORM_KINDS = [
+ {
+ formSelector: '[data-signup-form]',
+ emailSelector: '[data-signup-email]',
+ feedbackSelector: '[data-signup-feedback]',
+ procedure: 'auth.submitSignUpEmail',
+ handleOutcome: handleSignUpOutcome,
+ submitLabel: 'Get started',
+ },
+ {
+ formSelector: '[data-book-a-demo-form]',
+ emailSelector: '[data-book-a-demo-email]',
+ feedbackSelector: '[data-book-a-demo-feedback]',
+ procedure: 'acquisition.submitBookADemoForm',
+ handleOutcome: handleBookADemoOutcome,
+ submitLabel: 'Book a demo',
+ },
+];
+
+function setupForm(form, kind) {
+ if (boundForms.has(form)) return;
+ boundForms.add(form);
+
+ const emailInput = form.querySelector(kind.emailSelector);
+ if (!emailInput) {
+ renderFatalBanner(`missing ${kind.emailSelector} inside ${kind.formSelector}`);
+ return;
+ }
+
+ const feedbackEl = form.querySelector(kind.feedbackSelector);
+ if (!feedbackEl) {
+ renderFatalBanner(`missing ${kind.feedbackSelector} inside ${kind.formSelector}`);
+ return;
+ }
+
+ const submitButton = form.querySelector('button[type=submit], input[type=submit]');
+ if (!submitButton) {
+ renderFatalBanner(`missing submit button inside ${kind.formSelector}`);
+ return;
+ }
+
+ form.addEventListener(
+ 'submit',
+ async (event) => {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (submitButton.disabled) return;
+ feedbackEl.textContent = '';
+ feedbackEl.style.display = 'none';
+ submitButton.disabled = true;
+ submitButton.classList.add('is-loading');
+ submitButton.value = 'Redirecting...';
+
+ const resetButton = () => {
+ submitButton.disabled = false;
+ submitButton.classList.remove('is-loading');
+ submitButton.value = kind.submitLabel;
+ };
+ const fail = (message) => {
+ feedbackEl.textContent = message;
+ feedbackEl.style.display = 'block';
+ resetButton();
+ };
+
+ const email = (emailInput.value || '').trim();
+
+ try {
+ const outcome = await postTrpc(kind.procedure, buildPayload(email), AbortSignal.timeout(requestTimeoutMs));
+ if (outcome.type === 'http-error' || outcome.type === 'bad-shape') {
+ fail(genericError);
+ return;
+ }
+ const errorMessage = kind.handleOutcome(outcome.result, email);
+ if (errorMessage) fail(errorMessage);
+ } catch (error) {
+ if (error?.name === 'TimeoutError') {
+ fail('Request timed out. Please try again.');
+ } else if (error?.name === 'AbortError') {
+ // Expected on page unload / navigation. Ignore silently.
+ } else {
+ fail('Failed to connect to the server. Please try again.');
+ }
+ }
+ },
+ true,
+ );
+
+ window.addEventListener('pageshow', () => {
+ submitButton.disabled = false;
+ submitButton.classList.remove('is-loading');
+ submitButton.value = kind.submitLabel;
+ });
+}
+
+function initFormHandlers() {
+ FORM_KINDS.forEach((kind) => {
+ document.querySelectorAll(kind.formSelector).forEach((form) => setupForm(form, kind));
+ });
+}
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initFormHandlers);
+} else {
+ initFormHandlers();
+}
+
+// Handle forms (re)created later via Finsweet Attributes Inject.
+// Harmless no-op on Mintlify — Finsweet only exists on the Webflow marketing
+// site, so this array just sits unused here. Left in since it's verbatim.
+window.FinsweetAttributes ||= [];
+window.FinsweetAttributes.push([
+ 'inject',
+ async (instances) => {
+ await Promise.all(instances.map((i) => i.loadingPromise));
+ initFormHandlers();
+ },
+]);
\ No newline at end of file
diff --git a/qawolf/quick-start.mdx b/qawolf/quick-start.mdx
index 2a55316..eddb190 100644
--- a/qawolf/quick-start.mdx
+++ b/qawolf/quick-start.mdx
@@ -8,13 +8,16 @@ description: "Connect your application, generate your first flow, and run your f
## Before you begin
+import TryForFreeForm from '/snippets/try-for-free-embed.mdx';
+
Before getting started, make sure the following are set up:
- - **You have access to a QA Wolf workspace.**\
- Your team's QA Wolf administrator must invite you before you can log in and configure environments.
- - **QA Wolf can access the application you want to test.**\
- If your application is behind a firewall or private network, a **VPN or network allowlist** must be configured so QA Wolf infrastructure can reach it.
+ - **You have access to a QA Wolf workspace.** Create an account below or [Sign in](https://app.qawolf.com/sign-in) to your existing account.
+
+
+
+ - **QA Wolf can access the application you want to test.** If your application is behind a firewall or private network, a [**VPN or network allowlist** must be configured](/qawolf/QA-Wolf-s-static-IPv4-29c5b2a994fb81bf8487d085abf9dc4d) so QA Wolf infrastructure can reach it.
## Step 1: Map your application
diff --git a/snippets/try-for-free-embed.mdx b/snippets/try-for-free-embed.mdx
new file mode 100644
index 0000000..5da938a
--- /dev/null
+++ b/snippets/try-for-free-embed.mdx
@@ -0,0 +1,130 @@
+{/*
+ "Try for free" sign-up embed for Mintlify docs pages, using the real
+ production handler (from the designer/engineer), not the Zapier webhook
+ guessed at earlier — that URL in the homepage's raw HTML turned out to be
+ vestigial. The actual mechanism is a direct call to QA Wolf's own backend:
+ POST https://app.qawolf.com/api/trpc/auth.submitSignUpEmail
+
+ SETUP: this file goes in your Mintlify repo at /snippets/try-for-free-form.mdx
+ (Kirk wants this on 4 pages, so make it a snippet, not 4 copies).
+
+ Then on each target page (Welcome, Quick Start, Mapping AI, Automation AI),
+ add near the top:
+
+ import TryForFreeForm from '/snippets/try-for-free-form.mdx';
+
+ ...and drop wherever the component should render.
+
+ BLOCKERS before this can work at all (both confirmed by reading the actual
+ script, not guessed):
+
+ 1. CORS: this fetches cross-origin from the docs domain to app.qawolf.com.
+ That only works if app.qawolf.com's API allows the docs origin. It almost
+ certainly doesn't yet — this is the real engineering ask for Haroon/
+ Mateus: add https://docs.qawolf.com to the allowed-origins list for
+ /api/trpc/*. No credentials/cookies are sent by this call, so they don't
+ need Access-Control-Allow-Credentials — just the origin added. Until
+ this is done, every submission will fail client-side with a generic
+ "Failed to connect to the server" message and no other clue why.
+
+ 2. window.qawAttribution: the handler script calls
+ window.qawAttribution.getAttribution() with no null-check. If that
+ global isn't loaded on the docs domain, every submission throws and
+ falls into the same generic failure message. Ask whoever owns that
+ script whether it's safe/intended to load on docs.qawolf.com, or get a
+ no-op stub to use instead.
+
+ Until both of those are resolved, this will look "broken" in exactly the
+ same unhelpful way (generic connection error) regardless of which one is
+ actually missing — don't burn time debugging the form markup itself if
+ submissions are failing; check these two first.
+ */}
+
+{/*
+ Styling below is pulled directly from the live homepage component's
+ computed styles (measured via devtools on the actual hero form), not
+ eyeballed from a screenshot: border-radius 10.8px (not a full pill —
+ the earlier draft over-rounded this), 1.5px black border, 3.6px hard
+ shadow, 44px height, 18px "GT Flexa" font at regular (400) weight, not
+ bold, 9.9px/13.5px padding, button fill #0DF2CC. Input is a fixed 300px
+ wide on the real site (not responsive/flexible), matched here too.
+ */}
+
+{/*
+ IMPORTANT — button element type: the handler script controls the loading
+ state by setting `submitButton.value` (e.g. to "Redirecting..."). That only
+ updates visible text on an , not a