From c4783ce07936e41cade1eae8d9b5163e136770a0 Mon Sep 17 00:00:00 2001 From: Stuart Date: Mon, 28 Sep 2026 17:05:00 +1000 Subject: [PATCH 1/3] fix(modals): preserve scroll lock and focus return across open/replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #100 review findings on the shared modal shell: F1 (Greptile P2): a repeated open of the already active modal — the walkthrough's blur and delayed Tab handlers both do this — saved "hidden" as the page's previous overflow and re-recorded the already-inert background. The eventual close restored "hidden" and left the background inert and aria-hidden, locking the page. A repeated open is now idempotent: it keeps the return target and the overflow saved before the first open, and setBackgroundInert records each child's pre-modal state once. F2 (Devin): openModal captured the return target after closing the replaced dialog, so a replacement opened from a control inside the previous dialog restored focus into the dialog it hid on close. The return target is now captured before the close and, when the active element lived inside the replaced dialog, falls back to whatever opened that dialog. Regression coverage: e2e/gate-modal-behavior.spec.js fails on base head 6dd8edb (close leaves overflow "hidden"; replacement close focuses a control inside the hidden walkthrough) and passes with this fix. F3 root cause (stu391 CI flake): the spec's signed-in fixture is an out-of-runs free user (usage_count 2, free limit 2), so the async identity resolution runs showPaywallExhausted, which closes an open walkthrough. When that landed after the spec's reopen click the tour was closed asynchronously and waitForWalkthroughOpen never settled (captured stack: resolveIdentity → updateUsageDisplay → showPaywallExhausted → closeModal). reopenWalkthrough now waits for the persisted usage decision before clicking. The app-side race (passive usage rendering closing a user-opened modal) is left untouched here; app.js is outside this change's ownership. --- e2e/gate-modal-behavior.spec.js | 118 +++++++++++++++++++++++++ e2e/stu391-tutorial-onboarding.spec.js | 29 +++++- src/sharedControls.js | 34 ++++++- 3 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 e2e/gate-modal-behavior.spec.js diff --git a/e2e/gate-modal-behavior.spec.js b/e2e/gate-modal-behavior.spec.js new file mode 100644 index 0000000..cb98d6a --- /dev/null +++ b/e2e/gate-modal-behavior.spec.js @@ -0,0 +1,118 @@ +import { test, expect } from "@playwright/test"; +import { applyDefaultRoutes, guestIdentity } from "./fixtures/apiFixtures.js"; + +/** + * Gate fixes for the PR #100 findings on the shared modal shell + * (src/sharedControls.js): + * + * F1 — a repeated open of the already active modal must preserve the page's + * pre-modal overflow and background-inert state. The walkthrough's blur + * and delayed Tab handlers re-open that same modal, so an open while it + * is already active used to save "hidden" as the previous overflow and + * overwrite the saved background state — closing then left the page + * unable to scroll with an inert, aria-hidden background. + * + * F2 — opening a replacement dialog while another is open must never leave the + * replacement's close restoring focus into the dialog it hid. openModal + * used to capture the return target after closing the previous dialog, so + * a replacement opened from a control inside the previous dialog saved + * that hidden control as its return target. + * + * Both tests fail on the pre-fix shell (base head 6dd8edb) and pass after. + */ + +const WALKTHROUGH = "#walkthrough-modal"; +const API_KEYS = "#api-keys-modal"; +const TRIGGER = "#gate-test-trigger"; + +async function waitForApp(page) { + await page.waitForFunction( + () => + typeof window.openWalkthroughModal === "function" && + typeof window.openApiKeysModal === "function", + ); +} + +test.describe("shared modal shell gate behaviour", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("hasSeenWalkthrough", "true")); + await applyDefaultRoutes(page, { identity: guestIdentity() }); + }); + + test("a repeated open of the active modal keeps the page scroll lock and background state intact", async ({ + page, + }) => { + await page.goto("/"); + await waitForApp(page); + + expect(await page.evaluate(() => document.body.style.overflow)).not.toBe("hidden"); + + // Open through the app path, then re-open the SAME modal — exactly what the + // walkthrough's blur and delayed Tab handlers do while the tour is already up. + await page.evaluate(() => window.openWalkthroughModal()); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + expect(await page.evaluate(() => document.body.style.overflow)).toBe("hidden"); + + await page.evaluate(() => window.openWalkthroughModal()); + expect(await page.evaluate(() => document.body.style.overflow)).toBe("hidden"); + + await page.evaluate(() => window.closeWalkthroughModal()); + await expect(page.locator(WALKTHROUGH)).not.toHaveClass(/open/); + + // The close restores the value saved before the FIRST open, not "hidden". + expect(await page.evaluate(() => document.body.style.overflow)).not.toBe("hidden"); + + // The background is interactive again: not inert and not aria-hidden. + const background = await page.evaluate(() => { + const header = document.querySelector("header.topbar"); + return { inert: header.inert, ariaHidden: header.getAttribute("aria-hidden") }; + }); + expect(background.inert).toBe(false); + expect(background.ariaHidden).toBeNull(); + }); + + test("a replacement dialog never returns focus into the dialog it hid", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + + // A real trigger outside the dialog, focused before the first open, becomes + // the walkthrough's return target. + await page.evaluate(() => { + document.getElementById("gate-test-trigger")?.remove(); + const trigger = document.createElement("button"); + trigger.id = "gate-test-trigger"; + trigger.textContent = "Open tour"; + document.body.append(trigger); + trigger.focus(); + window.openWalkthroughModal(); + }); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + await page.waitForFunction( + (id) => document.getElementById(id)?.contains(document.activeElement), + "walkthrough-modal", + { timeout: 3000 }, + ); + + // Replace the tour while focus lives inside it. The shared editor opens + // without pre-closing the tour (openApiKeysModal is the standalone path), + // so this exercises the replacement branch of openModal. + await page.evaluate(() => window.openApiKeysModal()); + await expect(page.locator(API_KEYS)).toHaveClass(/open/); + await expect(page.locator(WALKTHROUGH)).not.toHaveClass(/open/); + + await page.evaluate(() => window.closeApiKeysModal()); + await expect(page.locator(API_KEYS)).not.toHaveClass(/open/); + + // The replacement's close returns focus to what opened the tour — never to + // a control inside the dialog it hid. + const focused = await page.evaluate(() => ({ + id: document.activeElement?.id || "", + insideWalkthrough: document + .getElementById("walkthrough-modal") + .contains(document.activeElement), + })); + expect(focused.insideWalkthrough).toBe(false); + expect(focused.id).toBe("gate-test-trigger"); + await expect(page.locator(TRIGGER)).toBeFocused(); + }); +}); diff --git a/e2e/stu391-tutorial-onboarding.spec.js b/e2e/stu391-tutorial-onboarding.spec.js index 1702317..0fe2a95 100644 --- a/e2e/stu391-tutorial-onboarding.spec.js +++ b/e2e/stu391-tutorial-onboarding.spec.js @@ -103,16 +103,43 @@ async function clickInsideWalkthrough(page, selector) { await target.click(); } +// The identity response drives the startup usage decision: resolveIdentity +// persists the metered count and, in the same synchronous block, runs +// updateUsageDisplay — which surfaces the exhausted-paywall state and, for an +// out-of-runs free user, closes any open walkthrough (showPaywallExhausted). +// Opening the tour while that decision is still in flight lets the async close +// land after the click and take the just-opened tour with it, so the open never +// settles. Wait for the persisted count (written in the same synchronous block +// as the decision) before clicking; this is app-readiness, not a fixed sleep, +// and it holds for guests and signed-in users alike. +async function waitForUsageDecision(page) { + await page.waitForFunction( + () => { + try { + const raw = localStorage.getItem("ccc_usage"); + return !!raw && Number.isFinite(JSON.parse(raw).count); + } catch { + return false; + } + }, + null, + { timeout: 8000 }, + ); +} + // Reopen the tutorial from the nav. On a cold start the deferred app.js module // may not have wired openWalkthroughModal yet, so a click can land while the // handler is undefined and silently navigate to #tutorial instead of opening // the modal — wait for the wire-up (state) before clicking, then wait for the -// open animation to finish. +// open animation to finish. The startup usage decision is awaited too: the +// paywall it may surface closes an open walkthrough, so a click that races it +// would be undone asynchronously. async function reopenWalkthrough(page) { await page.waitForFunction( () => typeof window.openWalkthroughModal === "function", { timeout: 8000 }, ); + await waitForUsageDecision(page); await page.click("#wt-reopen"); await waitForWalkthroughOpen(page); } diff --git a/src/sharedControls.js b/src/sharedControls.js index eebbcae..d9834f6 100644 --- a/src/sharedControls.js +++ b/src/sharedControls.js @@ -21,7 +21,13 @@ function focusableElements(modal) { function setBackgroundInert(modal) { for (const child of document.body.children) { if (child === modal || child.tagName === "SCRIPT") continue; - inertState.set(child, { inert: child.inert, ariaHidden: child.getAttribute("aria-hidden") }); + // Record each child's pre-modal state once. A repeated open of the already + // active modal runs this again while the background is already inert, and + // re-recording would overwrite the original state with the inert one — so + // the eventual close would never restore the background. + if (!inertState.has(child)) { + inertState.set(child, { inert: child.inert, ariaHidden: child.getAttribute("aria-hidden") }); + } child.inert = true; child.setAttribute("aria-hidden", "true"); } @@ -43,11 +49,31 @@ function isDismissible(modal) { export function openModal(modalOrId, options = {}) { const modal = typeof modalOrId === "string" ? document.getElementById(modalOrId) : modalOrId; if (!modal) return false; - if (activeModal && activeModal !== modal) closeModal(activeModal, { restoreFocus: false, force: true }); - returnTarget = options.trigger || document.activeElement; + // A repeated open of the already active modal (the walkthrough's blur and + // delayed Tab handlers both do this) must be idempotent: it keeps the return + // target and the saved overflow from the first open. + const isReopen = activeModal === modal; + const replacing = activeModal && !isReopen ? activeModal : null; + const candidate = options.trigger || document.activeElement; + + if (replacing) { + // Capture the replaced dialog's return target before closeModal clears it. + // Focus must never be restored into the dialog a replacement hides, so an + // active element that lives in the replaced dialog falls back to whatever + // opened that dialog. + const fallback = returnTarget; + closeModal(replacing, { restoreFocus: false, force: true }); + returnTarget = candidate && replacing.contains(candidate) ? fallback : candidate; + } else if (!isReopen) { + returnTarget = candidate; + } + activeModal = modal; - savedOverflow = document.body.style.overflow; + // Only the first open saves the page's previous overflow. Saving again while + // this modal holds the page locked would save "hidden" and restore it on + // close, leaving the page unable to scroll. + if (!isReopen) savedOverflow = document.body.style.overflow; setBackgroundInert(modal); document.body.style.overflow = "hidden"; modal.classList.add("open"); From f3b57870996c43eefceb0f3b1d9ce7e24d89499e Mon Sep 17 00:00:00 2001 From: Stuart Date: Mon, 28 Sep 2026 17:36:10 +1000 Subject: [PATCH 2/3] fix(paywall): stop passive usage renders from closing an open tour showPaywallExhausted closed any open walkthrough whenever the exhausted surface rendered, including the passive startup render that follows the async identity response. A user who opened the tour before the usage decision resolved lost it as the response landed. Gate the close on an explicit dismissWalkthrough option passed only by canRunPipeline, so an out-of-runs run attempt still surfaces the paywall in place of the tour. The STU-391 regression test gates the identity response to pin the race: the tour stays open for a passive resolution, while a run attempt still closes it and opens the pricing modal. The stu391 waitForUsageDecision helper is removed, restoring the flow that exercises the race itself. --- app.js | 15 ++++- e2e/stu391-tutorial-onboarding.spec.js | 91 ++++++++++++++++++-------- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/app.js b/app.js index 75ddcf0..8ae18c2 100644 --- a/app.js +++ b/app.js @@ -5880,7 +5880,7 @@ async function canRunPipeline() { const { count } = getUsage() const limit = getRunLimit() if (count >= limit) { - showPaywallExhausted(count, limit, { openModal: true }) + showPaywallExhausted(count, limit, { openModal: true, dismissWalkthrough: true }) return false } const warningThreshold = Math.floor(limit * 0.8) @@ -5898,8 +5898,17 @@ function hidePaywallExhausted() { function showPaywallExhausted(count, limit, options = {}) { setGenerationStageVisible(true); - const walkthroughModal = document.getElementById('walkthrough-modal') - if (walkthroughModal) closeModal(walkthroughModal, { restoreFocus: false }) + + // The exhausted surface also renders passively whenever usage is re-read — + // startup identity resolution and post-run reconciliation both land here via + // updateUsageDisplay. Only a run attempt may take an open tour away: when the + // async startup decision resolves to exhausted after the user has opened the + // tour, the tour must survive it. canRunPipeline passes dismissWalkthrough + // because an out-of-runs run must surface the paywall in place of the tour. + if (options.dismissWalkthrough) { + const walkthroughModal = document.getElementById('walkthrough-modal') + if (walkthroughModal) closeModal(walkthroughModal, { restoreFocus: false }) + } const readyState = document.getElementById('ready-state') if (readyState) readyState.classList.add('hidden') diff --git a/e2e/stu391-tutorial-onboarding.spec.js b/e2e/stu391-tutorial-onboarding.spec.js index 0fe2a95..85b206b 100644 --- a/e2e/stu391-tutorial-onboarding.spec.js +++ b/e2e/stu391-tutorial-onboarding.spec.js @@ -8,6 +8,7 @@ import { sampleProjectList, ENDPOINTS, } from "./fixtures/apiFixtures.js"; +import { PLAN_LIMITS } from "../src/plansSurface.js"; /** * STU-391 — tutorials and connection onboarding in the new visual system. @@ -103,43 +104,19 @@ async function clickInsideWalkthrough(page, selector) { await target.click(); } -// The identity response drives the startup usage decision: resolveIdentity -// persists the metered count and, in the same synchronous block, runs -// updateUsageDisplay — which surfaces the exhausted-paywall state and, for an -// out-of-runs free user, closes any open walkthrough (showPaywallExhausted). -// Opening the tour while that decision is still in flight lets the async close -// land after the click and take the just-opened tour with it, so the open never -// settles. Wait for the persisted count (written in the same synchronous block -// as the decision) before clicking; this is app-readiness, not a fixed sleep, -// and it holds for guests and signed-in users alike. -async function waitForUsageDecision(page) { - await page.waitForFunction( - () => { - try { - const raw = localStorage.getItem("ccc_usage"); - return !!raw && Number.isFinite(JSON.parse(raw).count); - } catch { - return false; - } - }, - null, - { timeout: 8000 }, - ); -} - // Reopen the tutorial from the nav. On a cold start the deferred app.js module // may not have wired openWalkthroughModal yet, so a click can land while the // handler is undefined and silently navigate to #tutorial instead of opening // the modal — wait for the wire-up (state) before clicking, then wait for the -// open animation to finish. The startup usage decision is awaited too: the -// paywall it may surface closes an open walkthrough, so a click that races it -// would be undone asynchronously. +// open animation to finish. The startup usage decision is deliberately NOT +// awaited: with the app fix a passive exhausted resolution no longer closes an +// open tour, so the click may land before or after the decision — the race +// itself is exercised, and the tour survives either order. async function reopenWalkthrough(page) { await page.waitForFunction( () => typeof window.openWalkthroughModal === "function", { timeout: 8000 }, ); - await waitForUsageDecision(page); await page.click("#wt-reopen"); await waitForWalkthroughOpen(page); } @@ -366,4 +343,62 @@ test.describe("STU-391 tutorial + connection onboarding", () => { await page.waitForTimeout(100); } }); + + // Regression: the startup usage decision must never close a tour the user is + // reading. resolveIdentity lands its metered count asynchronously and, when + // that count is exhausted, renders the paywall state through + // updateUsageDisplay -> showPaywallExhausted. That passive render used to + // close any open walkthrough, so a user who opened the tour before the + // decision resolved lost it as the response landed. The identity response is + // gated here so the tour is guaranteed open when the decision arrives — the + // exact sequence the original flake pinned — and the tour must survive it. + // A run attempt is not passive: an out-of-runs run must still surface the + // paywall and take the tour with it. + test("the startup paywall decision never closes an open tour; an out-of-runs run attempt still surfaces the paywall", async ({ page }) => { + let releaseIdentity; + const identityGate = new Promise((resolve) => { + releaseIdentity = resolve; + }); + await applyDefaultRoutes(page); + // Hold the identity response until the tour is open, then answer with a + // free user at the tier limit. Registered after applyDefaultRoutes, which + // matches routes in reverse registration order, so this handler wins. + await page.route(ENDPOINTS.identity, async (route) => { + await identityGate; + await route.fulfill({ + status: 200, + body: JSON.stringify({ + status: "guest", + user_id: "guest-exhausted-0001", + identity_token: null, + usage_count: PLAN_LIMITS.free, + usage_month: new Date().toISOString().slice(0, 7), + }), + contentType: "application/json", + }); + }); + + await page.goto("/"); + + // Fresh user: the tour auto-opens before resolveIdentity has fetched, so it + // is already open when the decision lands. + await waitForWalkthroughOpen(page); + releaseIdentity(); + + // The decision has landed: the exhausted surface renders and the metered + // count is persisted. The passive render must leave the open tour alone. + await expect(page.locator("#paywall-exhausted")).toBeVisible(); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + await expect(page.locator(WALKTHROUGH)).toBeVisible(); + + // A run attempt is not passive: an out-of-runs run must surface the + // paywall in place of the tour. + await page.evaluate(() => { + document.getElementById("pipeline-input").value = "A circular progress gauge"; + window.runThinkingPipeline(); + }); + await expect(page.locator(WALKTHROUGH)).not.toHaveClass(/open/); + await expect(page.locator("#paywall-exhausted")).toBeVisible(); + await expect(page.locator("#pricing-modal")).toHaveClass(/open/); + }); }); From 57e2426a924a98d23a4a29bcb29edd258f29b96a Mon Sep 17 00:00:00 2001 From: Stuart Date: Tue, 29 Sep 2026 02:06:12 +1000 Subject: [PATCH 3/3] fix(paywall): drop the superseded dismissWalkthrough opt-in for base's guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #103 added an opt-in `options.dismissWalkthrough` flag to showPaywallExhausted: always render the paywall, and only tear down an open tour when a caller passes the flag. The milestone base already carries a different fix (37ffe47c): `updateUsageDisplay` guards its call with `!hasActiveModal()`, deferring the ENTIRE paywall while any dialog is open. The two are mutually exclusive, and the test written against the opt-in asserted a state base's guard makes unreachable — a passive exhausted identity resolution surfacing `#paywall-exhausted` beside a still-open tour. Base's mechanism wins: `showPaywallExhausted` returns to closing the walkthrough unconditionally, so app.js is now identical to the milestone base, and the opt-in and its comment are gone. The test now pins base's actual contract: a passive startup identity resolution for an exhausted free user defers the paywall entirely and leaves the open tour alone; once the tour is closed, the next usage decision does surface the paywall (deferral is a delay, not a suppression); and an active out-of-runs `runThinkingPipeline()` still surfaces the paywall, closes the walkthrough, and opens pricing. The decision's persisted metered count is used as the "the decision landed" signal, so the negative assertion cannot pass vacuously — removing `!hasActiveModal()` makes it fail. --- app.js | 12 ++--- e2e/stu391-tutorial-onboarding.spec.js | 74 ++++++++++++++++++++------ 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/app.js b/app.js index c251479..e4a5f13 100644 --- a/app.js +++ b/app.js @@ -5888,7 +5888,7 @@ async function canRunPipeline() { const { count } = getUsage() const limit = getRunLimit() if (count >= limit) { - showPaywallExhausted(count, limit, { openModal: true, dismissWalkthrough: true }) + showPaywallExhausted(count, limit, { openModal: true }) return false } const warningThreshold = Math.floor(limit * 0.8) @@ -5906,14 +5906,8 @@ function hidePaywallExhausted() { function showPaywallExhausted(count, limit, options = {}) { setGenerationStageVisible(true); - - // Only a run attempt may take an open tour away. canRunPipeline passes - // dismissWalkthrough because an out-of-runs run must surface the paywall in - // place of the tour. - if (options.dismissWalkthrough) { - const walkthroughModal = document.getElementById('walkthrough-modal') - if (walkthroughModal) closeModal(walkthroughModal, { restoreFocus: false }) - } + const walkthroughModal = document.getElementById('walkthrough-modal') + if (walkthroughModal) closeModal(walkthroughModal, { restoreFocus: false }) const readyState = document.getElementById('ready-state') if (readyState) readyState.classList.add('hidden') diff --git a/e2e/stu391-tutorial-onboarding.spec.js b/e2e/stu391-tutorial-onboarding.spec.js index 85b206b..dfab665 100644 --- a/e2e/stu391-tutorial-onboarding.spec.js +++ b/e2e/stu391-tutorial-onboarding.spec.js @@ -344,17 +344,23 @@ test.describe("STU-391 tutorial + connection onboarding", () => { } }); - // Regression: the startup usage decision must never close a tour the user is - // reading. resolveIdentity lands its metered count asynchronously and, when - // that count is exhausted, renders the paywall state through - // updateUsageDisplay -> showPaywallExhausted. That passive render used to - // close any open walkthrough, so a user who opened the tour before the - // decision resolved lost it as the response landed. The identity response is - // gated here so the tour is guaranteed open when the decision arrives — the - // exact sequence the original flake pinned — and the tour must survive it. - // A run attempt is not passive: an out-of-runs run must still surface the - // paywall and take the tour with it. - test("the startup paywall decision never closes an open tour; an out-of-runs run attempt still surfaces the paywall", async ({ page }) => { + // Contract: the startup usage decision is PASSIVE, and a passive exhausted + // decision must never take a tour the user is reading away from them. + // resolveIdentity lands its metered count asynchronously and, when that count + // is exhausted, renders the paywall state through updateUsageDisplay -> + // showPaywallExhausted. updateUsageDisplay defers the ENTIRE paywall while + // any dialog is open, so the exhausted surface is held back — it is not + // rendered alongside the tour, and the tour is not closed either. The identity + // response is gated so the tour is guaranteed open when the decision arrives + // (the exact sequence the original flake pinned), and the persisted metered + // count is cleared first so "the decision landed" is observed, not assumed: + // resolveIdentity writes it and calls updateUsageDisplay in one synchronous + // block, so the key reappearing at the limit means the decision has fully run. + // Once no dialog is open, the very next usage decision DOES surface the + // paywall — the deferral is a delay, never a suppression. A run attempt is not + // passive: an out-of-runs run must still surface the paywall, take the tour + // with it, and open pricing. + test("the startup paywall decision defers the paywall while a tour is open; an out-of-runs run attempt still surfaces it", async ({ page }) => { let releaseIdentity; const identityGate = new Promise((resolve) => { releaseIdentity = resolve; @@ -383,21 +389,57 @@ test.describe("STU-391 tutorial + connection onboarding", () => { // Fresh user: the tour auto-opens before resolveIdentity has fetched, so it // is already open when the decision lands. await waitForWalkthroughOpen(page); + // Nothing has been metered yet at this point, so dropping the key leaves the + // decision's own write as the only thing that can satisfy the wait below. + await page.evaluate(() => localStorage.removeItem("ccc_usage")); releaseIdentity(); - // The decision has landed: the exhausted surface renders and the metered - // count is persisted. The passive render must leave the open tour alone. - await expect(page.locator("#paywall-exhausted")).toBeVisible(); + // The decision has landed — the exhausted count is persisted. + await page.waitForFunction( + (limit) => { + const raw = localStorage.getItem("ccc_usage"); + if (!raw) return false; + try { + return JSON.parse(raw).count >= limit; + } catch { + return false; + } + }, + PLAN_LIMITS.free, + { timeout: 8000 }, + ); + + // The passive decision defers the paywall entirely while the tour is open: + // the exhausted surface is not rendered, and the tour the user is reading + // is neither hidden nor closed. + await expect(page.locator("#paywall-exhausted")).toBeHidden(); await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + await expect(page.locator(WALKTHROUGH)).toHaveAttribute("aria-hidden", "false"); await expect(page.locator(WALKTHROUGH)).toBeVisible(); - // A run attempt is not passive: an out-of-runs run must surface the - // paywall in place of the tour. + // The tour is closed the way a user closes it, so the next startup decision + // runs with no dialog open. + await page.check("#walkthrough-dont-show"); + await clickInsideWalkthrough(page, ".wt-gotit-btn"); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + + // Deferral is a delay, not a suppression: the next passive usage decision + // with nothing open renders the exhausted surface. + await page.reload(); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + await expect(page.locator("#paywall-exhausted")).toBeVisible(); + + // A run attempt is not passive: an out-of-runs run must surface the paywall + // in place of the tour, and open pricing so the user can act on it. + await reopenWalkthrough(page); + await expect(page.locator(WALKTHROUGH)).toHaveClass(/open/); + await page.evaluate(() => { document.getElementById("pipeline-input").value = "A circular progress gauge"; window.runThinkingPipeline(); }); await expect(page.locator(WALKTHROUGH)).not.toHaveClass(/open/); + await expect(page.locator(WALKTHROUGH)).toHaveAttribute("aria-hidden", "true"); await expect(page.locator("#paywall-exhausted")).toBeVisible(); await expect(page.locator("#pricing-modal")).toHaveClass(/open/); });