feat(film): author 19 interaction scenes on the storm-intel template

One scene per remaining manifest clip (territory-map, lead-create, lead-verify, dispatch, kanban, sub-tasks, pro-canvas, estimates, owner-snapshot, operator-dashboard, people-skills, project-detail, documents, org-access, role-perspective, ai-assistant, breadth-flash, landing-hero, crane-close). All follow the wait-for-load -> cursor-driven interactions -> payoff-hold pattern with guarded selectors. Selectors pinned from page source; some flagged for capture-time verification.
This commit is contained in:
Satyam Rastogi
2026-05-30 22:07:59 +05:30
parent e58428126f
commit f76a9da7b4
19 changed files with 1488 additions and 0 deletions
@@ -0,0 +1,78 @@
// AI Assistant — supporting scene (~35s). LynkedUp AI Concierge live Q&A.
// focus the chat input → type the LOCKED demo question verbatim → send → WAIT for the
// assistant's reply to render (assistant bubble count increases) → dwell on the
// streamed answer. End on the answer.
//
// NOTE: this calls a LIVE LLM (openai/gpt-oss-120b via Groq). Keep the wait tolerant —
// the reply is awaited up to 25s and all waits are guarded with .catch().
//
// Key selectors (verified against src/pages/AiAssistantPage.jsx +
// src/components/Chatbot.jsx — inline mode, since AiAssistantPage renders <Chatbot inline>):
// - Input: input#chat-input-inline (sr-only label "Message", placeholder "Type a message...").
// - Send: icon-only <button> (Send icon) immediately after the input; it is disabled
// until input.trim() is non-empty. We press Enter (onKeyPress→handleSend) which is the
// robust path; a click fallback targets the button next to the input.
// - Messages: each bubble row is a flex div; ASSISTANT rows use "justify-start" and the
// bubble has class "rounded-bl-none"; USER rows use "justify-end"/"rounded-br-none".
// Assistant text renders inside a ".prose" container. We count ".prose" blocks (only
// assistant/markdown messages render .prose) and wait for that count to grow.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
const DEMO_Q = "Which of my projects are over budget, and what's my biggest compliance risk right now?";
// 1) Wait for the inline chat to render (seeded greeting is the first assistant bubble).
await page.locator("#chat-input-inline").first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(1800); // open on the loaded AI Concierge
// 2) Focus the chat input and type the locked demo question verbatim.
const input = page.locator("#chat-input-inline").first();
if (await input.count()) {
const ib = await input.boundingBox().catch(() => null);
if (ib) await cursor.moveTo(ib.x + Math.min(ib.width / 2, 180), ib.y + ib.height / 2, 800);
await input.click().catch(() => {});
// Count assistant markdown bubbles BEFORE sending, to detect the new reply.
const beforeCount = await page.locator(".prose").count().catch(() => 0);
await input.type("Which of my projects are over budget, and what's my biggest compliance risk right now?", { delay: 35 });
await dwell(1200); // let the typed question sit on screen
// 3) Send — press Enter (onKeyPress → handleSend). Fallback to the adjacent Send button.
await input.press("Enter").catch(() => {});
let sent = false;
// If Enter didn't clear the field, the value will still be present; try the button.
const stillThere = await input.inputValue().catch(() => "");
if (stillThere && stillThere.length > 0) {
const sendBtn = page.locator("#chat-input-inline ~ button").first();
if (await sendBtn.count()) {
await cursor.clickLocator(sendBtn, 600);
sent = true;
}
} else {
sent = true;
}
void sent;
// 4) Wait for the assistant reply: the user bubble adds no .prose, so the next
// .prose increase is the assistant's answer. Tolerant 25s timeout for the live LLM.
await page.waitForFunction(
(n) => document.querySelectorAll(".prose").length > n,
beforeCount,
{ timeout: 25000 }
).catch(() => {});
// 5) Dwell on the streamed answer.
await dwell(6000);
// Pan the cursor up onto the latest assistant bubble for the closing hold.
const lastAssistant = page.locator(".prose").last();
if (await lastAssistant.count()) {
const lb = await lastAssistant.boundingBox().catch(() => null);
if (lb) await cursor.moveTo(lb.x + Math.min(lb.width / 2, 200), lb.y + Math.min(lb.height / 2, 80), 900);
}
}
// Final payoff hold on the answer.
await dwell(2800);
};
@@ -0,0 +1,43 @@
// Breadth Flash — fast "everything else" montage (~18s). One product, many surfaces.
// As owner, rip through five routes back-to-back: each gets a wait-for-load + ~2.5s
// dwell with a gentle cursor drift. NO deep interaction. Each route is wrapped in
// try/catch so one bad/empty route never aborts the montage.
//
// Routes: /owner/vendors, /owner/documents, /owner/estimates, /admin/leaderboard,
// /owner/projects.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
const routes = [
"/owner/vendors",
"/owner/documents",
"/owner/estimates",
"/admin/leaderboard",
"/owner/projects",
];
// Gentle drift so each held frame has subtle motion without interacting.
const drift = async () => {
const w = page.viewportSize() || { width: 1440, height: 900 };
await cursor.moveTo(w.width * 0.40, w.height * 0.38, 850).catch(() => {});
await cursor.moveTo(w.width * 0.62, w.height * 0.52, 850).catch(() => {});
};
for (const route of routes) {
try {
await ctx.spaNavigate(route);
// Generic load settle: let any route-level "Loading" spinner clear, then idle.
await page.getByText(/loading/i).first()
.waitFor({ state: "hidden", timeout: 6000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(400); // let content paint before the held frame
await drift();
await dwell(2100); // ~2.5s total per surface
} catch {
// Bad/empty route — skip it, keep the montage moving.
}
}
// Brief final hold on the last surface (/owner/projects).
await dwell(1200);
};
@@ -0,0 +1,58 @@
// crane-close.mjs — PUBLIC, route "/" (~22s). The swinging-crane CTA closer.
// Scroll to the end "Ready to Build?" CTA card (it hangs from a blueprint crane and
// swings like a pendulum, class .crane-swing), let the swing settle, then ease the
// cursor onto the "Schedule Demo" CTA and HOLD while it swings. Do NOT navigate away.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(1000);
// 1) Scroll to the very end (the crane CTA sits just before the footer).
await page
.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }))
.catch(() => {});
// Wait for the smooth scroll + swing animation to begin/settle.
await dwell(2500);
// 2) Wait for the swinging CTA card to be visible; scroll it into view to be safe.
// The card carries the unique "Ready to Build?" headline.
const craneCard = page.locator(".crane-swing").first();
const heading = page.getByRole("heading", { name: /ready to build/i }).first();
const anchor = (await craneCard.count()) ? craneCard : heading;
if (await anchor.count()) {
await anchor.scrollIntoViewIfNeeded().catch(() => {});
await anchor.waitFor({ state: "visible", timeout: 12000 }).catch(() => {});
}
await dwell(2500); // hold while the pendulum swings
// 3) Ease the cursor onto the CTA button ("Schedule Demo") inside the swinging card
// and hold — hover only, never click (we must not navigate to /login).
const cta = page
.getByRole("link", { name: /schedule demo/i })
.last(); // the crane card's CTA is the last on the page (hero has the first)
if (await cta.count()) {
const box = await cta.boundingBox().catch(() => null);
if (box) {
await cursor.moveTo(box.x + box.width / 2, box.y + box.height / 2, 1000);
}
} else if (await heading.count()) {
// Fallback: drift onto the headline region of the card.
const hb = await heading.boundingBox().catch(() => null);
if (hb) await cursor.moveTo(hb.x + hb.width / 2, hb.y + hb.height / 2, 1000);
}
await dwell(3500);
// 4) Small secondary drift to the "Start Free Trial" CTA to show both, still no click.
const cta2 = page.getByRole("link", { name: /start free trial/i }).first();
if (await cta2.count()) {
const b2 = await cta2.boundingBox().catch(() => null);
if (b2) {
await cursor.moveTo(b2.x + b2.width / 2, b2.y + b2.height / 2, 900);
await dwell(2500);
}
}
// 5) Final hold on the swinging card — the closing brand beat.
await dwell(3000);
};
@@ -0,0 +1,70 @@
// Dispatch — HERO scene (~60s). LynkDispatch AI dispatch engine walkthrough.
// load 3-panel board → click a lead card in the queue (left) → AI scores reps in the
// right "AI Recommendations" panel → ACCEPT the top AI pick (Assign) → watch the
// "Dispatched" confirmation + efficiency stat tick → toggle Storm Mode (banner + amber
// theme) → open the Dispatch Log drawer to reveal the running assignment history.
//
// Key selectors (verified against LynkDispatchPage.jsx + DispatchLeadQueue.jsx +
// DispatchRecommendationDrawer.jsx):
// - Lead cards: <motion.div> with customer name text — target by the customer name <p>.
// - AI panel "Assign" button: getByRole button name /assign/i (RepCard primary CTA).
// - Storm Mode toggle: header button name /storm mode/i.
// - Dispatch Log: header button name /^log$/i (has "Log" label + count badge).
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the board to settle — the lead queue + map render client-side.
await page.locator(".leaflet-container").first().waitFor({ state: "visible", timeout: 20000 }).catch(() => {});
await page.getByText(/loading/i).first().waitFor({ state: "hidden", timeout: 12000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2000); // open on the fully-loaded dispatch board
// 2) Click a lead card in the queue (left panel). The "All" tab is the default view.
// Target a real seeded customer name so we land on a card (not a chip/tab).
// The card is the clickable ancestor; clicking the name text bubbles to onSelect.
let leadCard = page.getByText(/Trevor Holloway|Sandra Kim|David Okonkwo/i).first();
if (!(await leadCard.count())) {
// Fallback: first lead card = first element carrying a "View Details" action.
leadCard = page.getByText(/View Details/i).first();
}
if (await leadCard.count()) {
await cursor.clickLocator(leadCard, 800);
await dwell(2600); // AI "scoring reps" skeleton → results animate in
}
// 3) Wait for the AI recommendation results (the "Top AI Recommendations" divider /
// "AI Says — Top Pick" block) to finish the processing skeleton.
await page.getByText(/AI Says|Top AI Recommendations|Top Pick/i).first()
.waitFor({ state: "visible", timeout: 8000 }).catch(() => {});
await dwell(2200); // dwell on the scored rep cards
// 4) Accept the AI-recommended rep — click the top RepCard "Assign" button.
const assignBtn = page.getByRole("button", { name: /^\s*assign\s*$/i }).first();
if (await assignBtn.count()) {
await cursor.clickLocator(assignBtn, 700);
// "Dispatched" confirmation overlay shows for ~2.4s; efficiency % ticks in header.
await page.getByText(/dispatched/i).first().waitFor({ state: "visible", timeout: 6000 }).catch(() => {});
await dwell(3000);
}
// 5) Toggle Storm Mode — amber banner slides in + theme shifts + Storm Intel appears.
const stormToggle = page.getByRole("button", { name: /storm mode/i }).first();
if (await stormToggle.count()) {
await cursor.clickLocator(stormToggle, 700);
await page.getByText(/storm mode active/i).first()
.waitFor({ state: "visible", timeout: 6000 }).catch(() => {});
await dwell(3000); // hold on the storm-themed board
}
// 6) Open the Dispatch Log drawer to reveal the running assignment history — payoff.
const logBtn = page.getByRole("button", { name: /^\s*log\s*$/i }).first();
if (await logBtn.count()) {
await cursor.clickLocator(logBtn, 700);
await page.getByText(/dispatch log/i).first()
.waitFor({ state: "visible", timeout: 6000 }).catch(() => {});
await dwell(2800); // hold on the opened log drawer
}
// Final payoff hold on the revealed drawer.
await dwell(2800);
};
@@ -0,0 +1,58 @@
// Documents — supporting scene (~25s). Owner Document Control / review queue browse.
// load the review queue → pan the cursor over the document cards → open a document
// preview via its "View" (eye) action → dwell on the card + status badges. End on the
// focused document.
//
// Key selectors (verified against src/pages/owner/DocumentManagement.jsx +
// src/components/documents/DocumentReviewQueue.jsx):
// - Queue: filter tabs ("Pending Review", "Expired", "Approved", "All") + a scroll list
// of document cards. Default filter is "pending_review".
// - Doc cards: <div> per document with an <h4> name + a status badge span
// (e.g. "Pending Review" / "approved" / "expired") and AI Confidence line.
// - View action: an icon-only <button title="View"> (Eye icon) firing a toast preview.
// There is no full-page preview modal — the card + badges are the visual payload.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the review queue to render. The "Review Queue" header is the anchor;
// the first doc card's <h4> confirms the list populated.
await page.getByText(/loading/i).first().waitFor({ state: "hidden", timeout: 15000 }).catch(() => {});
await page.getByText(/review queue/i).first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(1800); // open on the loaded Document Control queue
// 2) Pan the cursor over the document cards to browse the queue.
const firstCard = page.locator("h4").first();
if (await firstCard.count()) {
const b1 = await firstCard.boundingBox().catch(() => null);
if (b1) await cursor.moveTo(b1.x + Math.min(b1.width, 200), b1.y + b1.height + 30, 900);
await dwell(1600);
}
// 3) Show the full library via the "All" filter tab so more cards (and varied status
// badges) are visible, then dwell.
const allTab = page.getByRole("button", { name: /^\s*all\s*$/i }).first();
if (await allTab.count()) {
await cursor.clickLocator(allTab, 700);
await dwell(2000); // queue expands to every document
}
// 4) Open a document preview — click the first card's "View" (eye) action.
const viewBtn = page.locator('button[title="View"]').first();
if (await viewBtn.count()) {
await viewBtn.scrollIntoViewIfNeeded().catch(() => {});
await cursor.clickLocator(viewBtn, 750);
await dwell(2200); // preview toast + focus on the selected document
}
// 5) Dwell on a card's status badges (the compliance signal).
const badge = page.getByText(/pending review|approved|expired/i).first();
if (await badge.count()) {
const bb = await badge.boundingBox().catch(() => null);
if (bb) await cursor.moveTo(bb.x + bb.width / 2, bb.y + bb.height / 2, 800);
await dwell(2000);
}
// Final payoff hold on the open document.
await dwell(2600);
};
@@ -0,0 +1,84 @@
// Estimates — scene (~35s). Role: owner. Route: /owner/estimates.
//
// Verified against src/pages/EstimatesPage.jsx + src/components/estimates/EstimateDetailModal.jsx:
// The page opens on the "Estimates" tab showing a grid of EstimateCards (each a clickable
// <motion.div> with the client name as an <h3> + status badge + $ total). Clicking a card
// sets detailEstimate, which opens EstimateDetailModal — a right-side drawer (max-w-2xl)
// with a status tracker, Materials + Labor line-item tables, and a footer financial summary
// ending in "Total (Client Price)".
// Flow: load list → click an estimate card → drawer opens → reveal Materials/Labor breakdown
// → scroll the "Total (Client Price)" footer into view → hold on the breakdown.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the list to render. KPI strip + the "Estimates" page heading are stable.
await page
.getByText(/loading/i)
.first()
.waitFor({ state: "hidden", timeout: 12000 })
.catch(() => {});
await page
.getByRole("heading", { name: /^\s*estimates\s*$/i })
.first()
.waitFor({ state: "visible", timeout: 15000 })
.catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2000); // open on the loaded estimate grid
// 2) Pick the first estimate card. Cards are not buttons; the client name is an <h3>
// inside the clickable card. Grab the heading, then click the enclosing card.
const cardHeading = page
.locator("h3.truncate")
.first();
let clickTarget = cardHeading;
if (await cardHeading.count()) {
// Prefer clicking the card container (the heading's clickable ancestor).
const container = cardHeading.locator(
'xpath=ancestor::div[contains(@class,"rounded-2xl")][1]'
);
if (await container.count()) clickTarget = container.first();
}
if (await clickTarget.count()) {
await clickTarget.scrollIntoViewIfNeeded().catch(() => {});
await dwell(600);
await cursor.clickLocator(clickTarget, 900);
}
// 3) Wait for the detail drawer to open. The drawer header repeats the client name and
// shows the share/export bar (WhatsApp button) — a reliable "drawer is open" signal.
await page
.getByRole("button", { name: /whatsapp/i })
.first()
.waitFor({ state: "visible", timeout: 8000 })
.catch(() => {});
await dwell(2400); // hold on the opened estimate detail
// 4) Reveal the material/cost breakdown: scroll the "Materials" line-item table into view.
const materials = page.getByText(/^\s*materials\s*$/i).first();
if (await materials.count()) {
await materials.scrollIntoViewIfNeeded().catch(() => {});
const box = await materials.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + 40, 800);
await dwell(2600); // dwell on the itemized materials
}
// 5) Drift to the Labor table if present.
const labor = page.getByText(/^\s*labor\s*$/i).first();
if (await labor.count()) {
await labor.scrollIntoViewIfNeeded().catch(() => {});
const box = await labor.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + 40, 800);
await dwell(2000);
}
// 6) End on the payoff: scroll the footer grand total ("Total (Client Price)") into view.
const total = page.getByText(/total\s*\(client price\)/i).first();
if (await total.count()) {
await total.scrollIntoViewIfNeeded().catch(() => {});
const box = await total.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width - 80, box.y + box.height / 2, 800);
await dwell(2000);
}
await dwell(2800); // final hold on the cost breakdown + grand total
};
@@ -0,0 +1,95 @@
// Kanban — HERO scene (~55s). The DRAG is the star.
// load board (stage columns visible) → DRAG the "Derek Holloway" lead card from the
// "New Lead" stage into the "Contacted" stage (real pointer drag for @dnd-kit's
// PointerSensor, activationConstraint distance:6, mirrored to the visible cursor) →
// a campaign toast (20s countdown) fires → open its Edit modal to reveal the message →
// close + click the card to show its LeadInfoDrawer. End on the drawer.
//
// Key selectors (verified against KanbanPage.jsx + KanbanColumn.jsx + KanbanCard.jsx +
// mockStore KANBAN_COLUMNS/LEADS):
// - Card: getByText('Derek Holloway') → closest draggable card root. dnd id = lead.id.
// - Columns: KanbanColumn root is a w-[272px] flex col; header <h3> carries column.name
// ("New Lead", "Contacted"). Drop target = the "Contacted" column container box.
// - Campaign toast: Sonner custom toast with "Campaign Message" + "Edit" / "Send Now".
// - Edit modal: heading "Edit Campaign Message".
// - LeadInfoDrawer opens on card click (openCard).
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the board — stage column headers render client-side.
await page.getByRole("heading", { name: /new lead/i }).first()
.waitFor({ state: "visible", timeout: 20000 }).catch(() => {});
await page.getByText(/loading/i).first().waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2200); // open on the loaded pipeline
// 2) Resolve the card + the target column box.
const cardName = page.getByText("Derek Holloway", { exact: true }).first();
// The draggable card root is the nearest ancestor div that carries dnd listeners;
// the visual card (KanbanCardDisplay) is a rounded-xl border box — walk up to it.
const card = cardName.locator("xpath=ancestor::div[contains(@class,'rounded-xl')][1]");
// Target column: the "Contacted" stage header → its column container (w-[272px] root).
const contactedHeader = page.getByRole("heading", { name: /^contacted$/i }).first();
const targetCol = contactedHeader.locator("xpath=ancestor::div[contains(@class,'w-[272px]')][1]");
const cardEl = (await card.count()) ? card : cardName;
if ((await cardEl.count()) && (await targetCol.count())) {
const cb = await cardEl.boundingBox();
const colb = await targetCol.boundingBox();
if (cb && colb) {
// Move the visible cursor onto the card, then press to grab.
await cursor.moveTo(cb.x + cb.width / 2, cb.y + cb.height / 2, 800);
await dwell(400);
await page.mouse.down();
// Tiny nudge to satisfy dnd-kit's 6px activation distance.
await page.mouse.move(cb.x + cb.width / 2 + 8, cb.y + cb.height / 2 + 4, { steps: 5 });
await page.evaluate(([x, y]) => window.__cursorMove?.(x, y), [cb.x + cb.width / 2 + 8, cb.y + cb.height / 2 + 4]);
// Drive the drag to the target column in eased steps, mirroring the cursor.
const tx = colb.x + colb.width / 2;
const ty = colb.y + 80;
for (const p of [0.3, 0.6, 1]) {
const nx = cb.x + (tx - cb.x) * p;
const ny = cb.y + (ty - cb.y) * p;
await page.mouse.move(nx, ny, { steps: 8 });
await page.evaluate(([x, y]) => window.__cursorMove?.(x, y), [nx, ny]);
await dwell(220);
}
await dwell(400); // hover over the drop column (ring highlights)
await page.mouse.up();
await dwell(1600); // card lands in Contacted, layout re-flows
}
}
// 3) A campaign toast (20s countdown) fires ~300ms after drop. Dwell on it.
await page.getByText(/campaign message/i).first()
.waitFor({ state: "visible", timeout: 6000 }).catch(() => {});
await dwell(2600);
// 4) Open the campaign Edit modal to reveal the personalised message.
const editBtn = page.getByRole("button", { name: /^\s*edit\s*$/i }).first();
if (await editBtn.count()) {
await cursor.clickLocator(editBtn, 700);
await page.getByRole("heading", { name: /edit campaign message/i }).first()
.waitFor({ state: "visible", timeout: 5000 }).catch(() => {});
await dwell(3000); // hold on the editable campaign message
// Close the modal (Cancel) so we can show the lead drawer next.
const cancelBtn = page.getByRole("button", { name: /^\s*cancel\s*$/i }).first();
if (await cancelBtn.count()) {
await cursor.clickLocator(cancelBtn, 600);
await dwell(1200);
}
}
// 5) Click the moved card to open its LeadInfoDrawer — the payoff detail view.
const movedCard = page.getByText("Derek Holloway", { exact: true }).first();
if (await movedCard.count()) {
await cursor.clickLocator(movedCard, 700);
await dwell(2800); // hold on the opened lead drawer
}
// Final payoff hold.
await dwell(2800);
};
@@ -0,0 +1,53 @@
// landing-hero.mjs — PUBLIC, route "/" (~12s). Opening brand shot, NO clicks.
// Wait for the hero to render, then gently drift the cursor across the headline
// ("ROOFING / REVOLUTIONIZED") toward the primary CTA ("Schedule Demo").
// Slow moves (1000ms) + small dwells. Never clicks — this is the brand open.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the hero to actually render (network settle + the H1 headline visible).
await page.waitForLoadState("networkidle").catch(() => {});
const headline = page
.getByRole("heading", { level: 1 })
.filter({ hasText: /roofing/i })
.first();
await headline.waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
// Fallback: any visible "REVOLUTIONIZED" text.
if (!(await headline.count())) {
await page
.getByText(/revolutionized/i)
.first()
.waitFor({ state: "visible", timeout: 15000 })
.catch(() => {});
}
await dwell(1800); // open on the fully-rendered hero
// 2) Drift across the headline area (top-center) — slow, cinematic.
const headBox = await headline.boundingBox().catch(() => null);
if (headBox) {
await cursor.moveTo(headBox.x + headBox.width * 0.25, headBox.y + headBox.height * 0.5, 1000);
await dwell(1600);
await cursor.moveTo(headBox.x + headBox.width * 0.75, headBox.y + headBox.height * 0.5, 1000);
await dwell(1600);
} else {
// Fallback drift across the upper-center of the viewport.
const vp = page.viewportSize() || { width: 1280, height: 720 };
await cursor.moveTo(vp.width * 0.35, vp.height * 0.3, 1000);
await dwell(1600);
await cursor.moveTo(vp.width * 0.65, vp.height * 0.3, 1000);
await dwell(1600);
}
// 3) Ease down toward the primary CTA ("Schedule Demo") — hover only, never click.
const cta = page.getByRole("link", { name: /schedule demo/i }).first();
if (await cta.count()) {
const ctaBox = await cta.boundingBox().catch(() => null);
if (ctaBox) {
await cursor.moveTo(ctaBox.x + ctaBox.width / 2, ctaBox.y + ctaBox.height / 2, 1000);
await dwell(1200);
}
}
// 4) Final brand hold on the hero.
await dwell(2800);
};
@@ -0,0 +1,95 @@
// lead-create.mjs — agentCody, route "/emp/fa/leads/new" (~60s, HERO).
// On-camera lead capture. The form is an accordion of sections (Contact / Property /
// Job Details ...). Contact is open by default. We type into the visible fields
// (placeholders, not labels), pick a Lead Source from the custom dropdown (portaled to
// <body>), then Save and land on the success toast.
//
// Field map (read from the section components):
// Contact : First Name placeholder "John", Last Name "Smith", phone "(555) 000-0000"
// Property : Street Address "123 Main St", City "Plano"
// Job : "Lead Source" custom-select (button) → option list (we pick "Storm Chase"
// which needs no extra required fields, unlike "Door Knock"/"Referral").
// Save button text "Save Lead". Success toast: "Lead saved — <name>".
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// Type helper: move cursor to the field, clear, then type on camera.
const typeInto = async (loc, text) => {
if (!(await loc.count())) return;
await loc.scrollIntoViewIfNeeded().catch(() => {});
await cursor.clickLocator(loc, 700);
await loc.fill("").catch(() => {});
await loc.type(text, { delay: 45 });
await dwell(800);
};
// Open a collapsed accordion section by its header button (Contact/Property/Job Details).
const openSection = async (re) => {
const header = page.getByRole("button", { name: re }).first();
if (await header.count()) {
await cursor.clickLocator(header, 700);
await dwell(1200);
}
};
// 1) Wait for the form to render — the "New Lead" title + the First Name field.
await page.getByRole("heading", { name: /new lead/i }).first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await page.getByPlaceholder(/^john$/i).first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await dwell(1500);
// 2) Quick Capture is on by default — show the toggle by hovering "Quick".
const quickToggle = page.getByRole("button", { name: /^quick$/i }).first();
if (await quickToggle.count()) {
const qb = await quickToggle.boundingBox().catch(() => null);
if (qb) {
await cursor.moveTo(qb.x + qb.width / 2, qb.y + qb.height / 2, 700);
await dwell(1200);
}
}
// 3) CONTACT — first name, last name, phone.
await typeInto(page.getByPlaceholder(/^john$/i).first(), "Marcus");
await typeInto(page.getByPlaceholder(/^smith$/i).first(), "Whitfield");
await typeInto(page.getByPlaceholder(/\(555\)\s*000-0000/).first(), "9725550148");
// 4) PROPERTY — open the section, then address + city.
await openSection(/^property$/i);
await typeInto(page.getByPlaceholder(/123 main st/i).first(), "4821 Legacy Dr");
// City defaults to empty in Quick mode; the placeholder is "Plano".
await typeInto(page.getByPlaceholder(/^plano$/i).first(), "Plano");
// 5) JOB DETAILS — open the section, then pick a Lead Source from the custom dropdown.
await openSection(/job details/i);
// The Lead Source trigger is a button showing the placeholder until chosen.
const sourceTrigger = page
.getByRole("button", { name: /how did you find this lead\?/i })
.first();
if (await sourceTrigger.count()) {
await cursor.clickLocator(sourceTrigger, 700);
await dwell(1000);
// Options render in a portaled dropdown on <body>. Pick "Storm Chase" (no extra
// required fields). Match exactly to avoid the placeholder header text.
const opt = page.getByRole("button", { name: /^storm chase$/i }).first();
if (await opt.count()) {
await cursor.clickLocator(opt, 700);
await dwell(1600);
} else {
// Fallback: close the dropdown by pressing Escape so Save can still proceed.
await page.keyboard.press("Escape").catch(() => {});
}
}
// 6) SAVE — desktop save bar button "Save Lead". Wait for the sonner success toast.
const saveBtn = page.getByRole("button", { name: /save lead/i }).first();
if (await saveBtn.count()) {
await cursor.clickLocator(saveBtn, 800);
}
// 7) End on the confirmation toast (saved/success/created).
const toast = page
.getByText(/lead saved|saved|success|created/i)
.first();
await toast.waitFor({ state: "visible", timeout: 12000 }).catch(() => {});
await dwell(3000); // hold on the success confirmation
};
@@ -0,0 +1,83 @@
// lead-verify.mjs — OWNER, route "/lead-verification" (~30s).
// Open a lead's verification details, then step through the verify action and show the
// verified state.
// 1) Click a customer cell → LeadDetailsModal opens (the "details" reveal).
// 2) Close it, open the row's "More actions" (kebab) menu → "Verify Lead".
// 3) A ConfirmDialog appears ("Verify this lead?") → click "Verify & Convert".
// End on the confirmation. All interactions guarded.
//
// Selector notes: rows are a <table>; the kebab button has aria-label "More actions",
// the menu item is "Verify Lead", and the confirm button is "Verify & Convert".
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the page + table to render.
await page.getByRole("heading", { name: /lead verification/i }).first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
// First data row's customer button (clicking opens the details modal).
const firstRow = page.locator("table tbody tr").first();
await firstRow.waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await dwell(1800);
// 2) Open the lead's verification details by clicking its customer cell.
const customerBtn = firstRow.locator("button").nth(1); // [0]=Lead ID, [1]=Customer
if (await customerBtn.count()) {
await cursor.clickLocator(customerBtn, 800);
await dwell(800);
}
// The details modal (role=dialog) should appear — hold on it.
const detailsModal = page.locator('[role="dialog"]').first();
if (await detailsModal.count()) {
await detailsModal.waitFor({ state: "visible", timeout: 10000 }).catch(() => {});
await dwell(2800);
// Close the modal (Escape is wired up) so we can reach the row actions.
await page.keyboard.press("Escape").catch(() => {});
await dwell(1200);
}
// 3) Open the row's "More actions" kebab menu, then click "Verify Lead".
// Walk rows until we find one whose Verify menu item is enabled (already-Verified
// leads have it disabled).
const rows = page.locator("table tbody tr");
const rowCount = await rows.count();
let opened = false;
for (let i = 0; i < Math.min(rowCount, 5); i++) {
const row = rows.nth(i);
const kebab = row.getByRole("button", { name: /more actions/i }).first();
if (!(await kebab.count())) continue;
await cursor.clickLocator(kebab, 700);
await dwell(900);
const verifyItem = page.getByRole("button", { name: /verify lead/i }).first();
if ((await verifyItem.count()) && (await verifyItem.isEnabled().catch(() => false))) {
await cursor.clickLocator(verifyItem, 800);
await dwell(1200);
opened = true;
break;
}
// Not verifiable — close this menu and try the next row.
await page.keyboard.press("Escape").catch(() => {});
await dwell(500);
}
// 4) Confirm in the dialog: "Verify & Convert".
if (opened) {
const confirmBtn = page.getByRole("button", { name: /verify & convert/i }).first();
if (await confirmBtn.count()) {
await confirmBtn.waitFor({ state: "visible", timeout: 8000 }).catch(() => {});
await dwell(1500); // let the audience read the confirmation copy
await cursor.clickLocator(confirmBtn, 800);
await dwell(2000);
}
}
// 5) Show the verified state — a "Verified" pill/badge now in the list. Move to it.
const verifiedTag = page.getByText(/^verified$/i).first();
if (await verifiedTag.count()) {
const vb = await verifiedTag.boundingBox().catch(() => null);
if (vb) await cursor.moveTo(vb.x + vb.width / 2, vb.y + vb.height / 2, 800);
}
// 6) Final hold on the verified result.
await dwell(3000);
};
@@ -0,0 +1,67 @@
// Operator Dashboard — scene (~30s). Role: agentCody. Route: /emp/fa/dashboard.
//
// Verified against src/pages/Dashboard.jsx:
// The page shows a Loader ("Loading Dashboard...") for ~800ms, then a top metric grid
// (animated AnimatedNumber values, incl. "Recognized Revenue"), a live weather widget
// (RealWeatherWidget — fetches OpenWeather; shows temp °F / Wind mph / Humidity), and the
// "Top Sales Reps" leaderboard (TopSalesLeaderboard). Company-wide analytics blocks are
// gated to ADMIN/OWNER, so for a field agent they may be absent — everything used here
// (metrics, weather, leaderboard) renders for all roles.
// Flow: wait for load INCLUDING the weather fetch → let the revenue counter run (dwell near
// it) → pan to the weather widget → pan to the leaderboard → end on the leaderboard.
// Read-only dashboard: mostly dwell/pan, no destructive clicks.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the dashboard loader to clear and content to mount.
await page
.getByText(/loading dashboard/i)
.first()
.waitFor({ state: "hidden", timeout: 12000 })
.catch(() => {});
await page
.getByRole("heading", { name: /dashboard overview/i })
.first()
.waitFor({ state: "visible", timeout: 15000 })
.catch(() => {});
// 1b) Wait for the weather widget's OpenWeather fetch to resolve (may be live or demo-mock).
await page
.waitForResponse((r) => /openweather|weather/i.test(r.url()), { timeout: 15000 })
.catch(() => null);
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(1500); // open on the loaded dashboard
// 2) Let the animated revenue counter run — dwell near the "Recognized Revenue" metric
// while AnimatedNumber tweens up.
const revenue = page.getByText(/recognized revenue/i).first();
if (await revenue.count()) {
await revenue.scrollIntoViewIfNeeded().catch(() => {});
const box = await revenue.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y - 30, 900);
await dwell(3000); // ~3s for the counter to finish animating
} else {
await dwell(3000);
}
// 3) Pan to the live weather widget. Anchor on a unique label inside it ("Humidity"),
// falling back to "mph" wind units, then dwell on the widget.
let weatherAnchor = page.getByText(/humidity/i).first();
if (!(await weatherAnchor.count())) weatherAnchor = page.getByText(/mph/i).first();
if (await weatherAnchor.count()) {
await weatherAnchor.scrollIntoViewIfNeeded().catch(() => {});
const box = await weatherAnchor.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y - 80, 900);
await dwell(2800); // dwell on the live weather widget
}
// 4) Pan to the leaderboard and end there.
const leaderboard = page.getByText(/top sales reps/i).first();
if (await leaderboard.count()) {
await leaderboard.scrollIntoViewIfNeeded().catch(() => {});
const box = await leaderboard.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + 140, 900);
await dwell(2600);
}
await dwell(2800); // final hold on the leaderboard
};
@@ -0,0 +1,92 @@
// Org Access — HERO scene (~55s). Owner governance walkthrough on /owner/settings:
// wait for Org Settings to render → expand the Access Control Matrix accordion →
// toggle ONE permission cell in the matrix → open the Change Log to reveal the new
// entry → collapse, expand the Commission Rules accordion → reveal a role override
// (Add Role Override, or the org-default Edit). Dwell on every reveal; end on payoff.
//
// Key selectors (verified against src/pages/owner/OrgSettings.jsx):
// - Section accordions: each is a <button> in SettingsSection whose <h2> is the title
// text — target by getByRole('button', { name: /Access Control Matrix/i }) etc.
// Only "Organization Settings" is open by default; the rest start collapsed.
// - Matrix permission cell: a non-Owner toggle <button> carrying
// title="Grant|Revoke <action> on <module> for <role>" — match title /Grant|Revoke/.
// - Change Log toggle: a <button> in the matrix toolbar showing "Log" text (History
// icon). It is NOT a role-named chip — match the exact short "Log" / "Log (n)" label.
// - Commission Rules: "Add Role Override" dashed button, and the Org Default "Edit"
// button. Revealing the add-override form is the commission payoff.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the page to render. The header H1 "Org Settings" is the load marker.
await page.getByRole("heading", { name: /org settings/i }).first()
.waitFor({ state: "visible", timeout: 20000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2200); // open on the loaded settings page + quick-stats row
// 2) Expand the Access Control Matrix accordion (collapsed by default).
const accessHeader = page.getByRole("button", { name: /access control matrix/i }).first();
if (await accessHeader.count()) {
await cursor.clickLocator(accessHeader, 800);
// Wait for matrix content to mount — the "Save Changes" button lives in its toolbar.
await page.getByRole("button", { name: /save changes/i }).first()
.waitFor({ state: "visible", timeout: 8000 }).catch(() => {});
await dwell(2600); // hold on the expanded matrix (role columns + permission grid)
}
// 3) Toggle ONE permission cell. The desktop matrix cells are toggle buttons whose
// title starts with "Grant" (currently revoked) or "Revoke" (currently granted).
// Pick one in the middle of the grid so the change reads clearly on camera.
let cell = page.locator('button[title^="Grant"]').first();
if (!(await cell.count())) cell = page.locator('button[title^="Revoke"]').first();
if (await cell.count()) {
await cursor.clickLocator(cell, 800);
await dwell(2400); // the emerald check animates in — hold on the flipped cell
}
// 4) Open the Change Log to reveal the entry just created. The toggle reads "Log"
// or "Log (1)" after the toggle above — match the short label, avoid role chips.
const logToggle = page.getByRole("button", { name: /^\s*log(\s*\(\d+\))?\s*$/i }).first();
if (await logToggle.count()) {
await cursor.clickLocator(logToggle, 800);
await page.getByText(/change log/i).first()
.waitFor({ state: "visible", timeout: 6000 }).catch(() => {});
await dwell(3000); // hold on the freshly-logged permission change
}
// 5) Move to Commission Rules. Collapse Access first to keep the viewport tidy, then
// expand the Commission Rules accordion.
if (await accessHeader.count()) {
await cursor.clickLocator(accessHeader, 700);
await dwell(800);
}
const commissionHeader = page.getByRole("button", { name: /commission rules/i }).first();
if (await commissionHeader.count()) {
await cursor.clickLocator(commissionHeader, 800);
// Org Default block + hierarchy chips render — "Org Default Commission" is the marker.
await page.getByText(/org default commission/i).first()
.waitFor({ state: "visible", timeout: 8000 }).catch(() => {});
await dwell(2600); // hold on the commission hierarchy
}
// 6) Reveal a role override. Prefer the "Add Role Override" dashed button (opens the
// role/type/rate form); fall back to the Org Default "Edit" reveal if absent.
const addOverride = page.getByRole("button", { name: /add role override/i }).first();
if (await addOverride.count()) {
await cursor.clickLocator(addOverride, 800);
// The override form (Role / Type / Rate selects + "Add Override") slides in.
await page.getByRole("button", { name: /^\s*add override\s*$/i }).first()
.waitFor({ state: "visible", timeout: 6000 }).catch(() => {});
await dwell(3200); // hold on the revealed role-override form — commission payoff
} else {
const editOrg = page.getByRole("button", { name: /^\s*edit\s*$/i }).first();
if (await editOrg.count()) {
await cursor.clickLocator(editOrg, 800);
await dwell(3200);
}
}
// 7) Final payoff hold — gentle cursor drift across the revealed governance controls.
const pos = cursor.position || { x: 700, y: 500 };
await cursor.moveTo(pos.x - 120, pos.y + 40, 900).catch(() => {});
await dwell(3000);
};
@@ -0,0 +1,102 @@
// Owner Snapshot — HERO scene (~55s). Role: owner. Route: /owner/snapshot.
//
// Verified against src/pages/owner/OwnerSnapshot.jsx + src/components/owner/FinancialKPICards.jsx
// + src/components/owner/FinancialDetailsModal.jsx:
// Sections in order: quick stats → "Financial Overview" (FinancialKPICards: "Collected to
// Date", "Outstanding AR", "Pending Payouts", "YTD Vendor Spend" — each clickable, opens
// FinancialDetailsModal: a portal dialog with "<label> Details" heading + a transaction
// table). Then "Pipeline Analytics" (Pipeline Funnel / Commission by Rep / Storm Attribution
// SpotlightCards), then a Charts Row ("Budget vs Actual Spend" bar chart + "Project Status"
// recharts pie).
// Flow: load KPIs/charts → click a financial KPI card → modal opens, dwell on its table →
// close → dwell on the Budget bar chart + Project Status pie → pan to Commission by Rep +
// Storm Attribution panels → end on a rich analytics panel.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for KPIs/charts. The greeting heading + the "Financial Overview" section header
// are stable; recharts SVGs render shortly after.
await page
.getByText(/loading/i)
.first()
.waitFor({ state: "hidden", timeout: 12000 })
.catch(() => {});
await page
.getByRole("heading", { name: /financial overview/i })
.first()
.waitFor({ state: "visible", timeout: 15000 })
.catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2400); // open on the loaded snapshot, animated numbers settling
// 2) Click a financial KPI card → opens FinancialDetailsModal. Match the card by its
// title text ("Collected to Date"), then click its clickable SpotlightCard ancestor.
const kpiTitle = page.getByText(/collected to date/i).first();
let kpiTarget = kpiTitle;
if (await kpiTitle.count()) {
const container = kpiTitle.locator(
'xpath=ancestor::*[contains(@class,"cursor-pointer")][1]'
);
if (await container.count()) kpiTarget = container.first();
}
if (await kpiTarget.count()) {
await kpiTarget.scrollIntoViewIfNeeded().catch(() => {});
await dwell(500);
await cursor.clickLocator(kpiTarget, 900);
}
// 3) Wait for the FinancialDetailsModal (portal). Heading is "...Details"; body is a table.
const modal = page.getByRole("dialog").first();
await modal.waitFor({ state: "visible", timeout: 8000 }).catch(() => {});
await page
.getByRole("heading", { name: /details/i })
.first()
.waitFor({ state: "visible", timeout: 6000 })
.catch(() => {});
await dwell(2800); // dwell on the drilled-in transaction breakdown
// 4) Close the modal (X / "Close modal" aria-label) and return to the dashboard.
const closeBtn = page.getByRole("button", { name: /close modal|^close$/i }).first();
if (await closeBtn.count()) {
await cursor.clickLocator(closeBtn, 700).catch(() => {});
} else {
await page.keyboard.press("Escape").catch(() => {});
}
await dwell(1400);
// 5) Dwell on the Budget vs Actual bar chart.
const budgetChart = page.getByText(/budget vs actual spend/i).first();
if (await budgetChart.count()) {
await budgetChart.scrollIntoViewIfNeeded().catch(() => {});
const box = await budgetChart.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + 140, 900);
await dwell(2600);
}
// 6) Drift to the Project Status pie chart.
const statusChart = page.getByText(/^\s*project status\s*$/i).first();
if (await statusChart.count()) {
const box = await statusChart.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + 150, 800);
await dwell(2400);
}
// 7) Pan to the Commission by Rep panel.
const commission = page.getByText(/commission by rep/i).first();
if (await commission.count()) {
await commission.scrollIntoViewIfNeeded().catch(() => {});
const box = await commission.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + 120, 900);
await dwell(2400);
}
// 8) End on the Storm Attribution panel (storm pipeline/revenue share bars).
const storm = page.getByText(/storm attribution/i).first();
if (await storm.count()) {
await storm.scrollIntoViewIfNeeded().catch(() => {});
const box = await storm.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + 130, 900);
await dwell(2400);
}
await dwell(2800); // final hold on the rich analytics panel
};
@@ -0,0 +1,91 @@
// People & Skills — HERO scene (~50s). LynkedUp Pro People Directory walkthrough.
// load directory list → type in the search input to filter the roster →
// click a person row → right detail panel populates → open the Skill Scores section,
// set a new score on a skill input, click its Save button (skill bar updates) →
// pan to the masked "Sensitive Information" row and dwell. End on the updated profile.
//
// Key selectors (verified against src/pages/owner/PeopleDirectory.jsx):
// - Search input: input#search-people (placeholder "Search by name or email...").
// - Person rows: clickable <div> inside the grouped list; each shows an <h3> name.
// We target a seeded name <h3> and click its row ancestor.
// - Detail panel: <h2> with the selected person's name (self.preferredName||name).
// - Skill Scores: <Section> titled "Skill Scores"; each skill has a numeric
// <input type="number"> (w-16) + a Save button that only appears once the value is
// "dirty" (skillDraft set). The SkillBar (rounded-full h-1.5) reflects the score.
// - Sensitive Information: <Section> titled "Sensitive Information" with masked rows.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the directory list to render (roster is built client-side from the store).
await page.getByText(/loading/i).first().waitFor({ state: "hidden", timeout: 15000 }).catch(() => {});
await page.locator("#search-people").first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2000); // open on the loaded People Directory
// 2) Type in the search input to demonstrate live filtering of the roster.
const searchInput = page.locator("#search-people").first();
if (await searchInput.count()) {
const box = await searchInput.boundingBox().catch(() => null);
if (box) await cursor.moveTo(box.x + box.width / 2, box.y + box.height / 2, 800);
await searchInput.click().catch(() => {});
await searchInput.type("a", { delay: 60 }); // broad query keeps results visible
await dwell(2200); // watch the grouped list filter down
}
// 3) Click the first person row in the filtered list. Each row carries an <h3> name;
// click the row container so the onClick(handleSelectPerson) fires.
let personRow = page.locator("div.cursor-pointer").filter({ has: page.locator("h3") }).first();
if (!(await personRow.count())) {
personRow = page.locator("h3").first();
}
if (await personRow.count()) {
await cursor.clickLocator(personRow, 800);
await dwell(2600); // right detail panel populates with the profile
}
// Confirm the detail panel header (person name <h2>) is visible before continuing.
await page.locator("h2").filter({ hasText: /\w/ }).first()
.waitFor({ state: "visible", timeout: 8000 }).catch(() => {});
await dwell(1500);
// 4) Find the Skill Scores section and edit a skill score. Scroll the section into
// view (detail body is its own scroll container), then set a new value.
const skillHeading = page.getByRole("button", { name: /skill scores/i }).first();
if (await skillHeading.count()) {
await skillHeading.scrollIntoViewIfNeeded().catch(() => {});
const hb = await skillHeading.boundingBox().catch(() => null);
if (hb) await cursor.moveTo(hb.x + hb.width / 2, hb.y + hb.height / 2, 800);
await dwell(1500); // dwell on the skill bars
// The numeric skill inputs are w-16 number fields inside this section.
const skillInput = page.locator('input[type="number"]').first();
if (await skillInput.count()) {
await skillInput.scrollIntoViewIfNeeded().catch(() => {});
const ib = await skillInput.boundingBox().catch(() => null);
if (ib) await cursor.moveTo(ib.x + ib.width / 2, ib.y + ib.height / 2, 700);
await skillInput.click().catch(() => {});
await skillInput.fill("").catch(() => {});
await skillInput.type("88", { delay: 90 }); // marks the draft dirty → reveals Save
await dwell(1400);
// The per-skill Save button (title="Save score") only appears when dirty.
const skillSave = page.locator('button[title="Save score"]').first();
if (await skillSave.count()) {
await cursor.clickLocator(skillSave, 700);
await dwell(2600); // SkillBar animates to the persisted score
}
}
}
// 5) Pan the cursor to the masked "Sensitive Information" row and hold on it.
const sensitive = page.getByRole("button", { name: /sensitive information/i }).first();
if (await sensitive.count()) {
await sensitive.scrollIntoViewIfNeeded().catch(() => {});
const sb = await sensitive.boundingBox().catch(() => null);
if (sb) await cursor.moveTo(sb.x + sb.width / 2, sb.y + sb.height / 2, 900);
await dwell(2600); // dwell on the privacy-masked sensitive data rows
}
// Final payoff hold on the populated, updated profile.
await dwell(2800);
};
@@ -0,0 +1,124 @@
// Pro Canvas — HERO scene (~60s). Role: owner. Route: /owner/pro-canvas.
//
// IMPORTANT (verified against src/pages/ProCanvas.jsx): this route does NOT render a
// drawing canvas with template/measurement tools. It renders a gamified, FUT-style
// "operator" dashboard (player card, season stats, Log Action hub, daily missions,
// live-event banner, badges, rewards path, leaderboard). There is no template-selection
// modal or measurement tool to open. So we showcase what reliably exists:
// load → pan the player card + season-stat bars → open the "Log Action" modal (a real
// form that grants XP) and hold → close it → reveal the Badges/Trophy Room → reveal the
// Rewards/Season path → if a Level-Up modal happens to fire, dwell on it → end on the
// populated leaderboard. Every interaction is guarded; priority is NOT crashing.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the gamified canvas to finish mounting. The XP CountUp + energy bars
// animate on load, so we anchor on a stable header element then let the bars run.
await page
.getByText(/loading/i)
.first()
.waitFor({ state: "hidden", timeout: 12000 })
.catch(() => {});
await page
.getByRole("heading", { name: /lynkedup\s*pro/i })
.first()
.waitFor({ state: "visible", timeout: 15000 })
.catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2200); // open on the loaded hero, animated counters settling
// 2) Deliberate pan across the hero: dwell on the FUT player card (top-left),
// then drift to the "Season Stats" XP/streak bars.
const card = page.getByText(/field runner|lvl\s*12/i).first();
if (await card.count()) {
const box = await card.boundingBox().catch(() => null);
if (box) {
await cursor.moveTo(box.x + box.width / 2, box.y + box.height / 2, 900);
await dwell(2400);
}
}
const seasonStats = page.getByText(/season stats/i).first();
if (await seasonStats.count()) {
const box = await seasonStats.boundingBox().catch(() => null);
if (box) {
await cursor.moveTo(box.x + box.width / 2, box.y + 60, 800);
await dwell(2200);
}
}
// 3) Open a measurable, reliable interaction: the "Log Action" hub. The "Door Knocked"
// button opens a LogActionModal (real form, "Predicted Reward: +XP"). Hold on it.
const knockBtn = page.getByRole("button", { name: /door knocked/i }).first();
if (await knockBtn.count()) {
await cursor.clickLocator(knockBtn, 900);
// The modal title is "Log Door Knocked" with a glowing +XP reward chip.
await page
.getByText(/predicted reward/i)
.first()
.waitFor({ state: "visible", timeout: 6000 })
.catch(() => {});
await dwell(2800); // hold on the XP-reward form
// Close the modal (X button in the modal header) before moving on.
const closeBtn = page.getByRole("button", { name: /close|^x$/i }).first();
if (await closeBtn.count()) {
await cursor.clickLocator(closeBtn, 700).catch(() => {});
} else {
// Fallback: Escape, then click the dimmed backdrop top-left.
await page.keyboard.press("Escape").catch(() => {});
}
await dwell(1200);
}
// 4) Reveal the "Badges & Achievements" panel — clicking it opens the Trophy Room modal
// (unlocked + locked badge grid). This is a strong gamification payoff.
const badges = page.getByText(/badges\s*&\s*achievements/i).first();
if (await badges.count()) {
await cursor.clickLocator(badges, 900);
await page
.getByText(/trophy room/i)
.first()
.waitFor({ state: "visible", timeout: 6000 })
.catch(() => {});
await dwell(2800);
const closeBtn = page.getByRole("button", { name: /close|^x$/i }).first();
if (await closeBtn.count()) await cursor.clickLocator(closeBtn, 700).catch(() => {});
else await page.keyboard.press("Escape").catch(() => {});
await dwell(1000);
}
// 5) Reveal the "Rewards & Checkpoints" season path — opens the Season Rewards modal
// (milestone roadmap with XP/cash tiers).
const rewards = page.getByText(/rewards\s*&\s*checkpoints/i).first();
if (await rewards.count()) {
await cursor.clickLocator(rewards, 900);
await page
.getByText(/season rewards/i)
.first()
.waitFor({ state: "visible", timeout: 6000 })
.catch(() => {});
await dwell(2600);
const closeBtn = page.getByRole("button", { name: /close|^x$/i }).first();
if (await closeBtn.count()) await cursor.clickLocator(closeBtn, 700).catch(() => {});
else await page.keyboard.press("Escape").catch(() => {});
await dwell(1000);
}
// 6) If a Level-Up modal happens to be on screen (gamification can fire it), dwell on it.
const levelUp = page.getByText(/level up!/i).first();
if (await levelUp.count()) {
await dwell(3000);
}
// 7) End on a populated payoff: pan the cursor down to the full Leaderboard panel and hold.
const leaderboard = page.getByText(/^\s*leaderboard\s*$/i).first();
if (await leaderboard.count()) {
await leaderboard.scrollIntoViewIfNeeded().catch(() => {});
const box = await leaderboard.boundingBox().catch(() => null);
if (box) {
await cursor.moveTo(box.x + box.width / 2, box.y + 120, 900);
}
await dwell(2000);
}
await dwell(3000); // final hold on the gamified leaderboard
};
@@ -0,0 +1,64 @@
// Project Detail — HERO scene (~55s). Owner Projects list → deep project detail tour.
// load the projects list → click a project row → app navigates to /projects/:id →
// wait for the detail "command center" → click a curated set of tabs in order:
// Overview → Budget → Milestones → Estimates → Docs → Team. Dwell ~3s per tab.
// End on the Team tab.
//
// Key selectors (verified against src/pages/owner/OwnerProjectList.jsx +
// OwnerProjectDetail.jsx):
// - List: default "Construction" section renders a desktop <table>; each <tr> row has
// onClick → navigate(`/projects/${id}`). We click the first body row.
// - Detail tabs: a row of <button>s; each renders an icon + a <span> with the tab
// label (hidden on mobile, visible at sm+). Tab labels: "Overview", "Budget & Costs",
// "Milestones", "Estimates", "Docs", "Team". We match by visible label text.
// - Detail load anchor: the "Project Progression" heading on the Overview tab.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the projects list table to render.
await page.getByText(/loading/i).first().waitFor({ state: "hidden", timeout: 15000 }).catch(() => {});
await page.locator("table tbody tr").first().waitFor({ state: "visible", timeout: 15000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2000); // open on the loaded Projects list
// 2) Click the first project row → navigates to /projects/:id.
let projectRow = page.locator("table tbody tr").first();
if (!(await projectRow.count())) {
// Fallback to the mobile card view if the desktop table is not present.
projectRow = page.locator("div.cursor-pointer").first();
}
if (await projectRow.count()) {
await cursor.clickLocator(projectRow, 800);
await page.waitForURL(/\/projects\//, { timeout: 10000 }).catch(() => {});
await dwell(1200);
}
// 3) Wait for the project detail command center to settle (Overview tab is default).
await page.getByText(/project progression/i).first()
.waitFor({ state: "visible", timeout: 12000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2600); // dwell on the Overview tab
// Helper: click a tab button by its visible label and dwell on the panel.
const openTab = async (labelRe, hold = 3000) => {
const tab = page.getByRole("button", { name: labelRe }).first();
if (await tab.count()) {
await tab.scrollIntoViewIfNeeded().catch(() => {});
await cursor.clickLocator(tab, 750);
await dwell(hold);
}
};
// 4) Curated tab tour. Overview is already shown; step through the rest in order.
await openTab(/overview/i, 2600); // re-affirm Overview (cursor lands on the tab)
await openTab(/budget/i, 3000); // "Budget & Costs"
await openTab(/milestones/i, 3000);
await openTab(/estimates/i, 3000);
await openTab(/docs/i, 3000);
// 5) End on the Team tab — the payoff.
await openTab(/^\s*team\s*$/i, 3000);
// Final payoff hold on the Team tab.
await dwell(2800);
};
@@ -0,0 +1,55 @@
// Role Perspective — access-control proof (~35s). One product, three lenses. Starts as
// owner on /owner/snapshot (full visibility), then re-logs-in as a field agent and a
// subcontractor to contrast each role's narrower view. No deep interaction — the cut is
// the message; the cursor just pans across each view while it holds.
//
// Load markers (verified against the rendered pages):
// - Owner snapshot (/owner/snapshot) renders FinancialKPICards → cards titled
// "Collected to Date" / "Outstanding AR" / "Pending Payouts" / "YTD Vendor Spend".
// - Field-agent dashboard (/emp/fa/dashboard, src/pages/Dashboard.jsx) shows a <Loader>
// for ~800ms (isLoading) then KPI content — wait for any "Loading" to clear.
// - Sub dashboard (/subcontractor/dashboard) renders StatCards (e.g. "Clock", "Wallet").
// ctx.login re-auths; ctx.spaNavigate does the in-app route change.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// Pan the cursor across whatever is on screen to keep the hold alive.
const pan = async () => {
const w = page.viewportSize() || { width: 1440, height: 900 };
await cursor.moveTo(w.width * 0.30, w.height * 0.35, 900).catch(() => {});
await cursor.moveTo(w.width * 0.68, w.height * 0.45, 950).catch(() => {});
await cursor.moveTo(w.width * 0.50, w.height * 0.62, 900).catch(() => {});
};
// ── 1) OWNER — full visibility. Already logged in + on /owner/snapshot. ──
await page.getByText(/collected to date|outstanding ar|pending payouts/i).first()
.waitFor({ state: "visible", timeout: 20000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(800);
await pan(); // "Owner — full visibility": sweep the complete KPI suite
await dwell(1400);
// ── 2) FIELD AGENT — the operator's narrower view. ──
await ctx.login("agentCody").catch(() => {});
await ctx.spaNavigate("/emp/fa/dashboard").catch(() => {});
// Dashboard shows a Loader (~800ms) then KPI cards — wait for the spinner to clear.
await page.getByText(/loading/i).first()
.waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(1200);
await pan(); // sweep the agent's scoped dashboard
await dwell(1800);
// ── 3) SUBCONTRACTOR — the most restricted view. ──
await ctx.login("subCarlos").catch(() => {});
await ctx.spaNavigate("/subcontractor/dashboard").catch(() => {});
await page.getByText(/loading/i).first()
.waitFor({ state: "hidden", timeout: 10000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(1200);
await pan(); // sweep the sub's work-only view
await dwell(1800);
// ── Final hold on the sub's view — the payoff of the three-lens contrast. ──
await dwell(2500);
};
@@ -0,0 +1,70 @@
// Sub-Tasks — scene (~35s). Subcontractor task management.
// load the tasks table → open a task (click its title → Task Details modal with full
// meta + status history) → close → change the task's status via the row's action menu
// (More actions → Cancel → confirm) → the row's status badge updates to "Cancelled".
// End on the updated task row.
//
// Key selectors (verified against owner/SubcontractorTasksPage.jsx + TaskViewModal.jsx):
// - Task open: each TaskRow's title is a <button> (onView) → opens TaskViewModal
// ("Task Details" heading). Target the first data row's title button.
// - Status control: there is no inline status <select>; status changes flow through the
// row action menu (MoreVertical, aria-label "More actions") → Edit / Reassign / Cancel.
// "Cancel" → ConfirmDialog ("Cancel Task" button) sets status → "Cancelled".
// - Updated state: StatusBadge text becomes "Cancelled" in the row.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the table to render (rows render client-side from the mock store).
await page.getByRole("heading", { name: /subcontractor tasks/i }).first()
.waitFor({ state: "visible", timeout: 20000 }).catch(() => {});
await page.locator("table tbody tr").first()
.waitFor({ state: "visible", timeout: 12000 }).catch(() => {});
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2000); // open on the loaded task list
const firstRow = page.locator("table tbody tr").first();
// 2) Open a task — click its title button → "Task Details" modal.
// The title is the first button in the row (onView).
const titleBtn = firstRow.getByRole("button").first();
if (await titleBtn.count()) {
await cursor.clickLocator(titleBtn, 800);
await page.getByRole("heading", { name: /task details/i }).first()
.waitFor({ state: "visible", timeout: 6000 }).catch(() => {});
await dwell(3000); // hold on the task detail + status history
// Close the modal to return to the table.
const closeBtn = page.getByRole("button", { name: /^\s*close\s*$/i }).first();
if (await closeBtn.count()) {
await cursor.clickLocator(closeBtn, 600);
await dwell(1400);
}
}
// 3) Change the task's status via the row's action menu.
const moreBtn = firstRow.getByRole("button", { name: /more actions/i }).first();
if (await moreBtn.count()) {
await cursor.clickLocator(moreBtn, 700);
await dwell(1400); // menu (Edit / Reassign / Cancel) drops open
// Click "Cancel" in the action menu (the status-changing item).
const cancelItem = page.getByRole("button", { name: /^\s*cancel\s*$/i }).first();
if (await cancelItem.count()) {
await cursor.clickLocator(cancelItem, 700);
// Confirmation dialog appears — confirm with "Cancel Task".
const confirmBtn = page.getByRole("button", { name: /cancel task/i }).first();
await confirmBtn.waitFor({ state: "visible", timeout: 5000 }).catch(() => {});
if (await confirmBtn.count()) {
await dwell(1600); // read the confirmation
await cursor.clickLocator(confirmBtn, 700);
// Row status badge updates to "Cancelled".
await page.getByText(/cancelled/i).first()
.waitFor({ state: "visible", timeout: 5000 }).catch(() => {});
await dwell(2800); // hold on the updated task row
}
}
}
// Final payoff hold on the updated task.
await dwell(2800);
};
@@ -0,0 +1,106 @@
// territory-map.mjs — OWNER, route "/owner/maps" (~55s, HERO).
// Full territory walkthrough on the Leaflet map:
// load map+tiles → click an EMPTY spot to drop a new property pin (reverse-geocode →
// detail drawer opens in create/edit mode) → step through the drawer (set Canvassing
// Status — the agent/canvasser-style assignment control) → then click an EXISTING
// parcel polygon to reveal its populated detail panel. End on a populated drawer.
// Everything is guarded so a missing element never crashes the scene.
export default async (page, cursor, ctx) => {
const dwell = (ms) => page.waitForTimeout(ms);
// 1) Wait for the map. The page shows a full-screen "Loading Territory Map..." loader
// (~1s) before the Leaflet container mounts.
await page
.getByText(/loading territory map/i)
.first()
.waitFor({ state: "hidden", timeout: 20000 })
.catch(() => {});
const map = page.locator(".leaflet-container").first();
await map.waitFor({ state: "visible", timeout: 20000 });
// Let the OSM tiles settle.
await page.waitForLoadState("networkidle").catch(() => {});
await dwell(2000); // open on the loaded territory map
const box = await map.boundingBox();
if (!box) {
await dwell(3000);
return;
}
// 2) Drop a new property pin on an EMPTY patch of map. Clicking empty map triggers a
// reverse-geocode (Nominatim) then opens the PropertyDetailDrawer in create mode.
// Pick a spot offset from existing parcels (upper-left quadrant).
const drop = { x: box.x + box.width * 0.32, y: box.y + box.height * 0.30 };
const waitGeocode = page
.waitForResponse((r) => /nominatim\.openstreetmap\.org\/reverse/.test(r.url()), { timeout: 20000 })
.catch(() => null);
await cursor.click(drop.x, drop.y, 900);
await waitGeocode;
// Drawer opens (role=dialog "Property Details"). Wait for the address to resolve
// ("Fetching Address..."/"Resolving Location..." → real content).
const drawer = page.getByRole("dialog", { name: /property details/i }).first();
await drawer.waitFor({ state: "visible", timeout: 12000 }).catch(() => {});
await page
.getByText(/resolving location|fetching address/i)
.first()
.waitFor({ state: "hidden", timeout: 15000 })
.catch(() => {});
await dwell(2800); // hold on the new-entry drawer
// 3) Set a Canvassing Status — this is the assignment-style control for a new lead.
// In create mode the drawer opens already editing, so the status pills are present.
const hotLead = drawer.getByRole("button", { name: /^hot lead$/i }).first();
if (await hotLead.count()) {
await cursor.clickLocator(hotLead, 800);
await dwell(2500);
}
// 4) Show the Create Entry action (the populated drawer footer). Just reveal/hover it —
// we don't need to actually persist, the payoff is the rich detail panel.
const createBtn = drawer.getByRole("button", { name: /create entry|save changes/i }).first();
if (await createBtn.count()) {
const cb = await createBtn.boundingBox().catch(() => null);
if (cb) {
await cursor.moveTo(cb.x + cb.width / 2, cb.y + cb.height / 2, 800);
await dwell(2000);
}
}
// 5) Close the new-entry drawer, then click an EXISTING parcel polygon to reveal its
// populated detail panel (the data-rich payoff).
const closeBtn = drawer.getByRole("button", { name: /close property details/i }).first();
if (await closeBtn.count()) {
await cursor.clickLocator(closeBtn, 700);
await dwell(1500);
}
// Click over a dense central area where colored parcels live. Leaflet polygon clicks
// open the drawer in view mode (with an "Edit Details" footer button).
const parcel = { x: box.x + box.width * 0.55, y: box.y + box.height * 0.55 };
await cursor.click(parcel.x, parcel.y, 900);
await dwell(1500);
// If that opened a NEW-entry geocode drawer instead of selecting a parcel, that's still
// a valid populated panel; if it selected a parcel we get "Edit Details".
const drawer2 = page.getByRole("dialog", { name: /property details/i }).first();
if (await drawer2.count()) {
await drawer2.waitFor({ state: "visible", timeout: 12000 }).catch(() => {});
await page
.getByText(/resolving location|fetching address/i)
.first()
.waitFor({ state: "hidden", timeout: 12000 })
.catch(() => {});
await dwell(2500);
// Reveal the edit affordance on the selected parcel, if present.
const editBtn = drawer2.getByRole("button", { name: /edit details/i }).first();
if (await editBtn.count()) {
await cursor.clickLocator(editBtn, 800);
await dwell(2500);
}
}
// 6) Final hold on the populated property detail panel.
await dwell(3000);
};