From 074cb06a8d8d9f507fbc61c6111cdf0dcb8269f1 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 4 Oct 2026 07:22:17 +0530 Subject: [PATCH] Coordinate dismissible reader signup prompts --- apps/extensions/src/entry.tsx | 38 ++-- apps/extensions/src/pages.tsx | 7 + apps/extensions/src/routes.ts | 2 + apps/extensions/tests/auth.browser.mjs | 20 +- apps/extensions/tests/newtab.test.mjs | 2 + apps/web/README.md | 26 +++ apps/web/src/app/layout.tsx | 33 +-- apps/web/src/components/dev-card-promo.tsx | 25 ++- apps/web/src/components/feed-onboarding.tsx | 24 ++- apps/web/src/components/reader-prompts.tsx | 140 +++++++++++++ .../src/components/signup-nudge.module.css | 23 ++- apps/web/src/components/signup-nudge.tsx | 100 ++++++--- apps/web/tests/browser/feed.mjs | 15 ++ apps/web/tests/reader-prompts.test.tsx | 189 ++++++++++++++++++ apps/web/tests/signup-nudge.test.tsx | 85 +++++++- scripts/testing/feed-onboarding.mjs | 165 +++++++++------ scripts/testing/signup-prompts.mjs | 124 ++++++++++++ 17 files changed, 876 insertions(+), 142 deletions(-) create mode 100644 apps/web/src/components/reader-prompts.tsx create mode 100644 apps/web/tests/reader-prompts.test.tsx create mode 100644 scripts/testing/signup-prompts.mjs diff --git a/apps/extensions/src/entry.tsx b/apps/extensions/src/entry.tsx index 8bb0d0564..948ce6334 100644 --- a/apps/extensions/src/entry.tsx +++ b/apps/extensions/src/entry.tsx @@ -38,6 +38,7 @@ import { PersonalFeed } from "../../web/src/components/personal-feed"; import { ReadLater } from "../../web/src/components/read-later"; import { NotificationPreferencesProvider } from "../../web/src/components/notification-preferences-provider"; import { SignupNudge } from "../../web/src/components/signup-nudge"; +import { ReaderPromptsProvider } from "../../web/src/components/reader-prompts"; import { ReaderNavigationRecovery } from "../../web/src/components/reader-navigation-recovery"; import { extensionRoute, type ExtensionRoute } from "./routes"; @@ -258,28 +259,35 @@ function ExtensionReader() { <> {article && } - ); } +function ExtensionSignupNudge() { + const route = useRoute(); + return ; +} + startExtensionAnalytics(); createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + , ); diff --git a/apps/extensions/src/pages.tsx b/apps/extensions/src/pages.tsx index ca3709782..589137e79 100644 --- a/apps/extensions/src/pages.tsx +++ b/apps/extensions/src/pages.tsx @@ -7,6 +7,7 @@ import { TopicsContent } from "../../web/src/components/topics-content"; import { SourcesContent } from "../../web/src/components/sources-content"; import { ProfileSettings } from "../../web/src/components/profile-settings"; import { Leaderboard } from "../../web/src/components/leaderboard"; +import { DevCardCreator } from "../../web/src/components/dev-card-creator"; import { AppearanceSettings } from "../../web/src/components/appearance-settings"; import { FeedSettings } from "../../web/src/components/feed-settings"; import { NotificationSettings } from "../../web/src/components/notification-settings"; @@ -77,6 +78,12 @@ export function LocalPage({ route }: { route: string }) { const url = new URL(route, "https://devfeed.tech"); const match = extensionRoute(url.pathname); if (!match || match.type !== "local") return null; + if (match.page === "dev-card") + return ( + + + + ); if (match.page === "leaderboard") return ( diff --git a/apps/extensions/src/routes.ts b/apps/extensions/src/routes.ts index 7295c8c95..f242c7a91 100644 --- a/apps/extensions/src/routes.ts +++ b/apps/extensions/src/routes.ts @@ -10,6 +10,7 @@ export type ExtensionRoute = | { type: "local"; page: "source-suggestion" } | { type: "local"; page: "mcp" } | { type: "local"; page: "leaderboard" } + | { type: "local"; page: "dev-card" } | { type: "local"; page: "mcp-authorize" } | { type: "article"; slug: string } | { type: "profile"; username: string } @@ -45,6 +46,7 @@ export function extensionRoute(pathname: string): ExtensionRoute | null { if (pathname === "/mcp/authorize") return { type: "local", page: "mcp-authorize" }; if (pathname === "/mcp") return { type: "local", page: "mcp" }; if (pathname === "/leaderboard") return { type: "local", page: "leaderboard" }; + if (pathname === "/dev-card") return { type: "local", page: "dev-card" }; const settings = settingsRoutes[pathname]; if (settings) return { type: "local", page: "settings", settings }; diff --git a/apps/extensions/tests/auth.browser.mjs b/apps/extensions/tests/auth.browser.mjs index aa8518432..13566aae7 100644 --- a/apps/extensions/tests/auth.browser.mjs +++ b/apps/extensions/tests/auth.browser.mjs @@ -28,6 +28,10 @@ import path from "node:path"; import test from "node:test"; import { chromium } from "playwright"; import { checkFeedOnboarding } from "../../../scripts/testing/feed-onboarding.mjs"; +import { + checkSignupPrompts, + signupPromptArticle, +} from "../../../scripts/testing/signup-prompts.mjs"; import { checkTopicFollow } from "../../../scripts/testing/topic-follow.mjs"; import { onboardingTopics, @@ -305,7 +309,12 @@ test( : [], next_cursor: onboarding ? "60" : null, }); - if (url.pathname.startsWith("/api/v1/articles/")) return send({ article, topic: null }); + if (url.pathname.startsWith("/api/v1/articles/")) + return send({ + article: + signupPromptArticle(article, url.pathname.slice("/api/v1/articles/".length)) ?? article, + topic: null, + }); if (url.pathname === "/api/v1/user/engagement") return send([ { @@ -499,6 +508,15 @@ test( await checkDevCardPromo(page, path.resolve(extension, "../dev-card-promo-" + browser), { extension: true, }); + await checkSignupPrompts( + context, + page.url(), + path.resolve(extension, `../${browser}-signup`), + { + extension: true, + }, + ); + await page.bringToFront(); // Synchronize the clock after promo checks without freezing Date.now(): // focus refreshes must be able to advance past the session throttle. await page.clock.setSystemTime(await page.evaluate(() => Date.now())); diff --git a/apps/extensions/tests/newtab.test.mjs b/apps/extensions/tests/newtab.test.mjs index c7aedc182..73f68ecc2 100644 --- a/apps/extensions/tests/newtab.test.mjs +++ b/apps/extensions/tests/newtab.test.mjs @@ -36,6 +36,8 @@ test("one route registry classifies every extension-owned page", () => { assert.equal(route("/mcp/authorize"), JSON.stringify({ type: "local", page: "mcp-authorize" })); assert.equal(route("/mcp"), JSON.stringify({ type: "local", page: "mcp" })); assert.equal(route("/leaderboard"), JSON.stringify({ type: "local", page: "leaderboard" })); + assert.equal(route("/dev-card"), JSON.stringify({ type: "local", page: "dev-card" })); + assert.equal(linkDestination("/dev-card"), "#/dev-card"); assert.equal(linkDestination("/leaderboard"), "#/leaderboard"); assert.equal( route("/settings/topics"), diff --git a/apps/web/README.md b/apps/web/README.md index 8e416db36..0aa7586f0 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -77,6 +77,32 @@ Shared browser regression helpers test preferences, public directories, onboardi The public API adds the optional `q` parameter to topics/sources. Deploy that API before the corresponding reader/extension release: an older API ignores `q` and cannot provide correct global catalog search. New same-origin topic/source detail endpoints must also be live before distributing the updated extensions. No database migration is needed. These source changes and local validations do not prove a production latency improvement until a separately authorized deployment is measured. +## Reader invitations + +The signup invitation appears after three distinct article routes, once sign-in +status has resolved. **Not now** dismisses automatic account invitations for the +session, including the anonymous Dev Card promotion. Dismissing an anonymous Dev +Card also silences the signup invitation. The banner sits above mobile navigation, +so readers can still change views without dismissing it. This reuses +`devfeed:dev-card-promo-dismissed`, so existing refusals stay respected across +navigation and reloads. Signed-in Dev Card dismissal remains account-specific. +When session storage is blocked, the mounted reader keeps dismissal in memory; +it cannot persist that refusal through a full reload without browser storage. + +Web, Chrome, and Edge share `ReaderPromptsProvider`. Topic onboarding reserves its +place while preferences load. Invitations wait behind it and native article or +account dialogs; an active signup invitation also defers the automatic Dev Card. +Closing or completing topic onboarding pauses automatic invitations for 30 seconds. +Explicit **Preview your card** actions bypass that pause and session refusal, and +can replace a signup banner, while still waiting for an existing modal to close. +There is one active invitation/onboarding prompt per reader. The coordinator +releases it on unmount and restores the existing dialog scroll behavior. + +`scripts/testing/signup-prompts.mjs` runs the dismissal and coordination journeys +against the website and both built extensions; `feed-onboarding.mjs` also checks +onboarding priority and the pause. Review screenshots stay in ignored reports or +extension build output. + ## Dev card signup preview Anonymous feeds and signed-in feeds whose reader has not claimed a username show diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 125958cf6..4d34e8772 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -23,6 +23,7 @@ import { UserProvider } from "@/components/user-account"; import { ThemePreferencesProvider } from "@/components/theme-preferences"; import { FeedPreferencesProvider } from "@/components/feed-preferences"; import { WebSignupNudge } from "@/components/web-signup-nudge"; +import { ReaderPromptsProvider } from "@/components/reader-prompts"; import { ReaderNavigationRecovery } from "@/components/reader-navigation-recovery"; export async function generateMetadata(): Promise { await connection(); @@ -79,21 +80,23 @@ twq('config','pc5f8');`} - - - - - - - {children} - {modal} - - - - - - - + + + + + + + + {children} + {modal} + + + + + + + + {gaId && } {clarityId && } diff --git a/apps/web/src/components/dev-card-promo.tsx b/apps/web/src/components/dev-card-promo.tsx index a62f6e586..f5d616ed0 100644 --- a/apps/web/src/components/dev-card-promo.tsx +++ b/apps/web/src/components/dev-card-promo.tsx @@ -13,8 +13,11 @@ import type { UserProfile, UserStack } from "@/lib/user"; import type { Topic } from "@/lib/types"; import { trackEvent } from "@/lib/analytics"; import styles from "./dev-card-promo.module.css"; +import { + anonymousInvitationDismissedKey as dismissedKey, + useReaderPromptCoordinator, +} from "./reader-prompts"; -const dismissedKey = "devfeed:dev-card-promo-dismissed"; function remembered(key: string) { try { return sessionStorage.getItem(key) === "true"; @@ -32,6 +35,9 @@ function remember(key: string) { export function DevCardPromo({ requested = false }: { requested?: boolean }) { const { user, loading, unavailable, profile, profileUnavailable } = useUser(); + const prompts = useReaderPromptCoordinator(); + const acquire = prompts?.acquire; + const release = prompts?.release; const dismissalKey = user ? `${dismissedKey}:${user.user_id}` : dismissedKey; const [dismissed, setDismissed] = useState(true); const [revealed, setRevealed] = useState(false); @@ -63,7 +69,12 @@ export function DevCardPromo({ requested = false }: { requested?: boolean }) { return () => cancelAnimationFrame(frame); }, [requested, dismissalKey]); const unclaimed = Boolean(profile && !profileUnavailable && !profile.username); - const shown = !dismissed && !loading && (!user || pending || unclaimed); + const shown = + !dismissed && + !loading && + (!prompts || prompts.ready) && + (requested || !!user || !prompts?.guestDismissed) && + (!user || pending || unclaimed); useEffect(() => { if (!shown || personal) return; const controller = new AbortController(); @@ -103,8 +114,10 @@ export function DevCardPromo({ requested = false }: { requested?: boolean }) { let timer: ReturnType; let detailsTimer: ReturnType; const open = () => { - // Let welcome, article, and account dialogs finish before presenting this one. - if (document.querySelector("dialog[open]")) { + if ( + document.querySelector("dialog[open]") || + (acquire && !acquire("dev-card", id, requested)) + ) { timer = setTimeout(open, 250); return; } @@ -130,10 +143,12 @@ export function DevCardPromo({ requested = false }: { requested?: boolean }) { document.body.style.overflow = previousOverflow; if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true }); } + release?.(id); }; - }, [shown, requested, cardReady]); + }, [shown, requested, cardReady, acquire, release, id]); function dismiss() { remember(dismissalKey); + if (!user) prompts?.dismissGuestInvitations(); setDismissed(true); } if (!shown || !cardReady) return null; diff --git a/apps/web/src/components/feed-onboarding.tsx b/apps/web/src/components/feed-onboarding.tsx index d2bf1b527..bcb45e642 100644 --- a/apps/web/src/components/feed-onboarding.tsx +++ b/apps/web/src/components/feed-onboarding.tsx @@ -7,6 +7,7 @@ import type { Topic } from "@/lib/types"; import { useTopicFollows } from "./topic-follows"; import { useUser } from "./user-account"; import styles from "./feed-onboarding.module.css"; +import { useReaderPrompt, useReaderPromptCoordinator } from "./reader-prompts"; export function FeedOnboarding() { const { user } = useUser(); @@ -16,6 +17,7 @@ export function FeedOnboarding() { function TopicOnboarding() { const { user } = useUser(); const follows = useTopicFollows(); + const prompts = useReaderPromptCoordinator(); const eligible = !!user && !follows.loading && !follows.unavailable && !follows.ids.length; const [dismissed, setDismissed] = useState(false); const [selected, setSelected] = useState([]); @@ -24,7 +26,11 @@ function TopicOnboarding() { const [busy, setBusy] = useState(false); const dialog = useRef(null); const saving = useRef(false); - const shown = eligible && !dismissed; + const reserved = useReaderPrompt( + "onboarding", + !!user && !dismissed && (follows.loading || eligible), + ); + const shown = eligible && !dismissed && reserved; useEffect(() => { if (!shown) return; @@ -43,8 +49,10 @@ function TopicOnboarding() { setBusy(true); setError(""); const result = await follows.save(selected); - if (result) setDismissed(true); - else setError("Couldn’t save your topics. Please try again."); + if (result) { + prompts?.pause(); + setDismissed(true); + } else setError("Couldn’t save your topics. Please try again."); saving.current = false; setBusy(false); } @@ -56,6 +64,12 @@ function TopicOnboarding() { Couldn’t load your topics.

) : null; + if (!shown) return null; + + function dismiss() { + prompts?.pause(); + setDismissed(true); + } return ( { event.preventDefault(); - if (!busy) setDismissed(true); + if (!busy) dismiss(); }} >
setDismissed(true)} + onClick={dismiss} > diff --git a/apps/web/src/components/reader-prompts.tsx b/apps/web/src/components/reader-prompts.tsx new file mode 100644 index 000000000..4c9b5e0bf --- /dev/null +++ b/apps/web/src/components/reader-prompts.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react"; + +// Preserve existing anonymous Dev Card dismissals as a refusal of account invitations. +export const anonymousInvitationDismissedKey = "devfeed:dev-card-promo-dismissed"; +export const promptPauseMs = 30_000; +type Prompt = "onboarding" | "dev-card" | "signup"; +type Lease = { owner: string; kind: Prompt }; +type Coordinator = { + ready: boolean; + guestDismissed: boolean; + activeOwner: string | null; + modalOpen: boolean; + paused: boolean; + acquire: (kind: Prompt, owner: string, requested?: boolean) => boolean; + release: (owner: string) => void; + dismissGuestInvitations: () => void; + pause: () => void; +}; + +const Context = createContext(null); +export const useReaderPromptCoordinator = () => useContext(Context); + +export function invitationDismissed() { + try { + return sessionStorage.getItem(anonymousInvitationDismissedKey) === "true"; + } catch { + return false; + } +} + +export function rememberInvitationDismissal() { + try { + sessionStorage.setItem(anonymousInvitationDismissedKey, "true"); + } catch { + // The mounted reader also retains dismissal in memory. + } +} + +export function ReaderPromptsProvider({ children }: { children: React.ReactNode }) { + const [ready, setReady] = useState(false); + const [guestDismissed, setGuestDismissed] = useState(false); + const [active, setActive] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [paused, setPaused] = useState(false); + const lease = useRef(null); + const quiet = useRef(false); + const timer = useRef | undefined>(undefined); + + useEffect(() => { + const frame = requestAnimationFrame(() => { + setGuestDismissed(invitationDismissed()); + setModalOpen(Boolean(document.querySelector("dialog[open]"))); + setReady(true); + }); + // One observer coordinates native article/account dialogs with invitations. + const observer = new MutationObserver(() => { + setModalOpen(Boolean(document.querySelector("dialog[open]"))); + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ["open"], + childList: true, + subtree: true, + }); + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + clearTimeout(timer.current); + }; + }, []); + + const acquire = useCallback((kind: Prompt, owner: string, requested = false) => { + if (lease.current?.owner === owner) return true; + if (kind !== "onboarding" && !requested && quiet.current) return false; + if (document.querySelector("dialog[open]")) return false; + // Explicit previews and onboarding can replace a banner, never another modal. + if (lease.current && !(lease.current.kind === "signup" && (requested || kind === "onboarding"))) + return false; + const next = { owner, kind }; + lease.current = next; + setActive(next); + return true; + }, []); + const release = useCallback((owner: string) => { + if (lease.current?.owner !== owner) return; + lease.current = null; + setActive(null); + }, []); + const dismissGuestInvitations = useCallback(() => { + rememberInvitationDismissal(); + setGuestDismissed(true); + }, []); + const pause = useCallback(() => { + quiet.current = true; + setPaused(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => { + quiet.current = false; + setPaused(false); + }, promptPauseMs); + }, []); + + return ( + + {children} + + ); +} + +// Reserve onboarding while its preferences load. Invitations cannot jump ahead of it. +export function useReaderPrompt(kind: "onboarding" | "signup", eligible: boolean) { + const coordinator = useReaderPromptCoordinator(); + const owner = useId(); + const acquire = coordinator?.acquire; + const release = coordinator?.release; + const ready = coordinator?.ready; + const activeOwner = coordinator?.activeOwner; + const modalOpen = coordinator?.modalOpen; + const paused = coordinator?.paused; + useEffect(() => { + if (!eligible) release?.(owner); + else if (ready) acquire?.(kind, owner); + }, [eligible, ready, activeOwner, modalOpen, paused, acquire, release, kind, owner]); + useEffect(() => () => release?.(owner), [release, owner]); + return eligible && (!coordinator || (ready && activeOwner === owner)); +} diff --git a/apps/web/src/components/signup-nudge.module.css b/apps/web/src/components/signup-nudge.module.css index 362e8661d..3bbf518e4 100644 --- a/apps/web/src/components/signup-nudge.module.css +++ b/apps/web/src/components/signup-nudge.module.css @@ -4,7 +4,7 @@ right: 24px; bottom: 24px; display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: 1fr; gap: 16px; width: min(470px, calc(100vw - 32px)); padding: 16px 18px; @@ -49,25 +49,32 @@ align-items: center; gap: 8px; align-self: end; + justify-content: flex-end; } .signup { - min-height: 34px; + min-height: 44px; padding: 7px 12px; white-space: nowrap; } +.dismiss { + min-height: 44px; + padding: 7px 12px; + white-space: nowrap; +} + +@media (max-width: 800px) { + .nudge { + bottom: calc(72px + max(11px, env(safe-area-inset-bottom))); + } +} + @media (max-width: 560px) { .nudge { right: 16px; - bottom: 16px; - grid-template-columns: 1fr; gap: 12px; } - - .actions { - justify-content: flex-end; - } } @media (prefers-reduced-motion: reduce) { diff --git a/apps/web/src/components/signup-nudge.tsx b/apps/web/src/components/signup-nudge.tsx index 9187e238b..a3fdee2cc 100644 --- a/apps/web/src/components/signup-nudge.tsx +++ b/apps/web/src/components/signup-nudge.tsx @@ -1,8 +1,14 @@ "use client"; -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { readerLoginLink } from "@/lib/reader-runtime"; import { useUser } from "./user-account"; +import { + invitationDismissed, + rememberInvitationDismissal, + useReaderPrompt, + useReaderPromptCoordinator, +} from "./reader-prompts"; import styles from "./signup-nudge.module.css"; const seenKey = "devfeed:signup-nudge-articles"; @@ -16,45 +22,69 @@ function subscribe(listener: () => void) { return () => window.removeEventListener(changedEvent, listener); } -function readSeenArticles() { - try { - const value = JSON.parse(sessionStorage.getItem(seenKey) ?? "[]"); - return new Set( - Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [], - ); - } catch { - return new Set(); - } -} - -function saveSeenArticles(seen: Set) { - try { - sessionStorage.setItem(seenKey, JSON.stringify([...seen].slice(-20))); - } catch { - // The in-memory state still handles this visit when storage is unavailable. - } - window.dispatchEvent(new Event(changedEvent)); -} - -function seenArticleCount() { - return readSeenArticles().size; +function createVisitStore() { + let seen = new Set(); + let dismissed = false; + const count = () => { + try { + const value = JSON.parse(sessionStorage.getItem(seenKey) ?? "[]"); + if (Array.isArray(value)) + for (const item of value.slice(-20)) + if (typeof item === "string" && articlePath.test(`/articles/${item}`)) seen.add(item); + } catch { + // Keep navigation and dismissal usable when browser storage is blocked. + } + seen = new Set([...seen].slice(-20)); + return seen.size; + }; + return { + count, + dismissed: () => dismissed || invitationDismissed(), + visit: (slug: string) => { + count(); + if (seen.has(slug)) return; + seen.add(slug); + seen = new Set([...seen].slice(-20)); + try { + sessionStorage.setItem(seenKey, JSON.stringify([...seen])); + } catch { + // This store remains mounted across reader navigation. + } + window.dispatchEvent(new Event(changedEvent)); + }, + dismiss: () => { + dismissed = true; + rememberInvitationDismissal(); + window.dispatchEvent(new Event(changedEvent)); + }, + }; } export function SignupNudge({ pathname }: { pathname: string }) { const { user, loading, unavailable } = useUser(); - const seenCount = useSyncExternalStore(subscribe, seenArticleCount, () => 0); + const [visit] = useState(createVisitStore); + const seenCount = useSyncExternalStore(subscribe, visit.count, () => 0); + const dismissed = useSyncExternalStore(subscribe, visit.dismissed, () => false); + const prompts = useReaderPromptCoordinator(); + const eligible = + seenCount >= threshold && + !loading && + !unavailable && + !user && + !dismissed && + !prompts?.guestDismissed && + !prompts?.modalOpen && + !authPath.test(pathname); + const shown = useReaderPrompt("signup", eligible); useEffect(() => { if (loading || unavailable || user) return; const slug = articlePath.exec(pathname)?.[1]; if (!slug) return; - const seen = readSeenArticles(); - if (seen.has(slug)) return; - seen.add(slug); - saveSeenArticles(seen); - }, [loading, unavailable, pathname, user]); + visit.visit(slug); + }, [loading, unavailable, pathname, user, visit]); - if (seenCount < threshold || user || authPath.test(pathname)) return null; + if (!shown) return null; const returnTo = `${pathname}${typeof window !== "undefined" ? window.location.search : ""}`; const link = readerLoginLink(returnTo); @@ -66,6 +96,16 @@ export function SignupNudge({ pathname }: { pathname: string }) {

Save articles, follow topics, and get recommendations shaped by your interests.

+ Create account diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs index 5ce8aa524..12d40f47d 100644 --- a/apps/web/tests/browser/feed.mjs +++ b/apps/web/tests/browser/feed.mjs @@ -45,6 +45,10 @@ import { fileURLToPath } from "node:url"; import { build } from "esbuild"; import { chromium } from "playwright"; import { checkFeedOnboarding } from "../../../../scripts/testing/feed-onboarding.mjs"; +import { + checkSignupPrompts, + signupPromptArticle, +} from "../../../../scripts/testing/signup-prompts.mjs"; import { checkTopicFollow } from "../../../../scripts/testing/topic-follow.mjs"; import { onboardingTopics, @@ -79,6 +83,14 @@ let savedTopicIds = [topic.id]; const fixture = createServer(async (req, res) => { const requestUrl = new URL(req.url, "http://localhost"); const path = requestUrl.pathname; + const promptArticle = path.startsWith("/v1/articles/") + ? signupPromptArticle(article, path.slice("/v1/articles/".length)) + : null; + if (promptArticle) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(promptArticle)); + return; + } if (path.startsWith("/v1/user/must-reads")) { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(await mustReadsFixture.response(path.endsWith("/presentation")))); @@ -446,6 +458,8 @@ try { await page.setViewportSize({ width: 1440, height: 1000 }); await checkLeaderboard(page, `${root}/reports/reader-feed/leaderboard-guest`); await checkDevCardPromo(page, `${root}/reports/reader-feed/dev-card-promo`); + await checkSignupPrompts(context, origin, `${root}/reports/reader-feed/signup`); + await page.bringToFront(); await checkSidebarGitHub(page, `${root}/reports/reader-feed/sidebar-github.png`); await checkMcp(page, `${root}/reports/reader-feed/mcp`); const whatsNew = page @@ -556,6 +570,7 @@ try { savedTopicIds = []; // The earlier background-refresh page deliberately overrides hasFocus on every navigation. const onboardingPage = await context.newPage(); + await onboardingPage.clock.install(); await checkFeedOnboarding(onboardingPage, origin, `${output}/web`); await onboardingPage.close(); await page.bringToFront(); diff --git a/apps/web/tests/reader-prompts.test.tsx b/apps/web/tests/reader-prompts.test.tsx new file mode 100644 index 000000000..b3c13bb28 --- /dev/null +++ b/apps/web/tests/reader-prompts.test.tsx @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { StrictMode } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ReaderPromptsProvider, promptPauseMs } from "@/components/reader-prompts"; +import { SignupNudge } from "@/components/signup-nudge"; +import { DevCardPromo } from "@/components/dev-card-promo"; +import { FeedOnboarding } from "@/components/feed-onboarding"; +import type { UserProfile } from "@/lib/user"; + +const state = vi.hoisted(() => ({ + user: null as { user_id: string } | null, + loading: false, + unavailable: false, + profile: null as UserProfile | null, + profileUnavailable: false, +})); +const follows = vi.hoisted(() => ({ + ids: [] as string[], + loading: false, + unavailable: false, + refresh: vi.fn(), + save: vi.fn(), +})); +vi.mock("@/components/user-account", () => ({ useUser: () => state })); +vi.mock("@/components/topic-follows", () => ({ useTopicFollows: () => follows })); +vi.mock("@/components/infinite-choices", () => ({ InfiniteChoices: () => null })); +vi.mock("@/components/dev-card-artwork", () => ({ DevCardArtwork: () =>
Card artwork
})); + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(performance, "now").mockReturnValue(30_000); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + profile: { username: "asaharan", display_name: "Featured Reader", stack: [] }, + }), + ), + ); + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute("open", ""); + }; + HTMLDialogElement.prototype.close = function () { + this.removeAttribute("open"); + }; + sessionStorage.clear(); + sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify(["one", "two", "three"])); + state.user = null; + state.loading = false; + state.unavailable = false; + state.profile = null; + state.profileUnavailable = false; + follows.ids = []; + follows.loading = false; + follows.unavailable = false; +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + expect(document.body.style.overflow).toBe(""); +}); + +async function advance(ms = 1500) { + await act(() => vi.advanceTimersByTimeAsync(ms)); +} +function invites({ signup = true, requested = false, onboarding = false } = {}) { + return ( + + {onboarding && } + + {signup && } + + ); +} + +it.each([false, true])( + "lets the banner finish and silences automatic Dev Card invitations on dismissal (strict=%s)", + async (strict) => { + render(strict ? {invites()} : invites()); + await advance(); + expect(screen.getByRole("button", { name: "Not now" })).toBeTruthy(); + expect(screen.queryByRole("dialog", { name: "Your dev card preview" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Not now" })); + await advance(40_000); + expect(screen.queryByRole("link", { name: "Create account" })).toBeNull(); + expect(screen.queryByRole("dialog", { name: "Your dev card preview" })).toBeNull(); + }, +); + +it("silences signup after an anonymous Dev Card dismissal", async () => { + const reader = render(invites({ signup: false })); + await advance(); + await advance(1200); + fireEvent.click(screen.getByRole("button", { name: "Dismiss dev card preview" })); + reader.rerender(invites()); + await advance(); + expect(screen.queryByRole("link", { name: "Create account" })).toBeNull(); +}); + +it("prioritizes topic onboarding, including preference loading, and pauses after closing it", async () => { + state.user = { user_id: "reader" }; + state.profile = { display_name: "Reader", avatar_url: null, username: null }; + follows.loading = true; + const reader = render(invites({ onboarding: true })); + await advance(40_000); + expect(screen.queryByRole("dialog")).toBeNull(); + follows.loading = false; + reader.rerender(invites({ onboarding: true })); + await advance(); + expect(screen.getByRole("dialog", { name: "Choose your topics" })).toBeTruthy(); + expect(screen.queryByRole("dialog", { name: "Your dev card preview" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Close" })); + await advance(promptPauseMs - 1); + expect(screen.queryByRole("dialog")).toBeNull(); + await advance(251); + expect(screen.getByRole("dialog", { name: "Your dev card preview" })).toBeTruthy(); +}); + +it("allows an explicitly requested preview after dismissing signup", async () => { + const reader = render(invites()); + await advance(); + fireEvent.click(screen.getByRole("button", { name: "Not now" })); + reader.rerender(invites({ requested: true })); + await advance(); + expect(screen.getByRole("dialog", { name: "Your dev card preview" })).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Create account" })).toBeNull(); +}); + +it("gives an explicit preview priority over a visible signup banner", async () => { + const reader = render(invites()); + await advance(); + expect(screen.getByRole("button", { name: "Not now" })).toBeTruthy(); + reader.rerender(invites({ requested: true })); + await advance(); + expect(screen.getByRole("dialog", { name: "Your dev card preview" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Not now" })).toBeNull(); +}); + +it("hides signup while a native article dialog is open and resumes after it closes", async () => { + render( + + + , + ); + await advance(); + const article = document.createElement("dialog"); + await act(async () => { + document.body.append(article); + article.showModal(); + }); + expect(screen.queryByRole("button", { name: "Not now" })).toBeNull(); + await act(async () => { + article.close(); + article.remove(); + }); + expect(screen.getByRole("button", { name: "Not now" })).toBeTruthy(); +}); + +it("retains refusal when storage writes fail and invites remount under the same reader", async () => { + const reader = render(invites()); + await advance(); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + fireEvent.click(screen.getByRole("button", { name: "Not now" })); + reader.rerender( + +
Reader route
+
, + ); + reader.rerender(invites()); + await advance(40_000); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByRole("button", { name: "Not now" })).toBeNull(); +}); + +it("keeps signed-in Dev Card completion separate from a guest refusal", async () => { + const reader = render(invites()); + await advance(); + fireEvent.click(screen.getByRole("button", { name: "Not now" })); + state.user = { user_id: "reader" }; + state.profile = { display_name: "Reader", avatar_url: null, username: null }; + reader.rerender(invites({ signup: false })); + await advance(); + expect(screen.getByRole("link", { name: /Finish your dev card/ })).toBeTruthy(); +}); diff --git a/apps/web/tests/signup-nudge.test.tsx b/apps/web/tests/signup-nudge.test.tsx index 0e2e72ded..d0c009b4d 100644 --- a/apps/web/tests/signup-nudge.test.tsx +++ b/apps/web/tests/signup-nudge.test.tsx @@ -1,7 +1,9 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { SignupNudge } from "@/components/signup-nudge"; +import { anonymousInvitationDismissedKey } from "@/components/reader-prompts"; const userState = vi.hoisted(() => ({ user: null as { user_id: string } | null, @@ -22,9 +24,90 @@ beforeEach(() => { afterEach(() => { cleanup(); + vi.restoreAllMocks(); window.history.replaceState({}, "", "/"); }); +function qualify() { + sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify(["one", "two", "three"])); +} + +it("remembers keyboard dismissal across routes and remounts for the session", async () => { + qualify(); + const reader = view("/latest"); + const keyboard = userEvent.setup(); + screen.getByRole("button", { name: "Not now" }).focus(); + await keyboard.keyboard("{Enter}"); + expect(screen.queryByRole("complementary", { name: "Create a DevFeed account" })).toBeNull(); + expect(sessionStorage.getItem(anonymousInvitationDismissedKey)).toBe("true"); + reader.rerender(); + expect(screen.queryByRole("button", { name: "Not now" })).toBeNull(); + reader.unmount(); + view("/latest"); + expect(screen.queryByRole("button", { name: "Not now" })).toBeNull(); +}); + +it("respects existing anonymous Dev Card dismissal", () => { + qualify(); + sessionStorage.setItem(anonymousInvitationDismissedKey, "true"); + view("/latest"); + expect(screen.queryByRole("link", { name: "Create account" })).toBeNull(); +}); + +it("does not apply another account's Dev Card dismissal to a guest", () => { + qualify(); + sessionStorage.setItem(`${anonymousInvitationDismissedKey}:other-reader`, "true"); + view("/latest"); + expect(screen.getByRole("link", { name: "Create account" })).toBeTruthy(); +}); + +it.each(["loading", "unavailable"] as const)( + "stays hidden while authentication is %s", + (status) => { + qualify(); + userState[status] = true; + view("/articles/four"); + expect(screen.queryByRole("link", { name: "Create account" })).toBeNull(); + expect(JSON.parse(sessionStorage.getItem("devfeed:signup-nudge-articles")!)).toHaveLength(3); + }, +); + +it("counts distinct valid article routes and ignores malformed stored values", () => { + sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify([null, {}, "bad/slug"])); + const reader = view("/articles/one"); + reader.rerender(); + reader.rerender(); + reader.rerender(); + expect(screen.queryByRole("link", { name: "Create account" })).toBeNull(); + reader.rerender(); + expect(screen.getByRole("link", { name: "Create account" })).toBeTruthy(); +}); + +it("retains article progress and dismissal in memory when storage is blocked", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + const reader = view("/articles/one"); + reader.rerender(); + reader.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Not now" })); + reader.rerender(); + expect(screen.queryByRole("link", { name: "Create account" })).toBeNull(); +}); + +it("keeps the current article and filters in the sign-in return link", () => { + qualify(); + window.history.replaceState({}, "", "/articles/three?topic=python&sort=latest"); + view("/articles/three"); + const href = screen.getByRole("link", { name: "Create account" }).getAttribute("href")!; + expect(new URL(href, window.location.origin).searchParams.get("return_to")).toBe( + "/articles/three?topic=python&sort=latest", + ); +}); + function view(pathname: string) { return render(); } diff --git a/scripts/testing/feed-onboarding.mjs b/scripts/testing/feed-onboarding.mjs index 97c768f0f..bb585eb4b 100644 --- a/scripts/testing/feed-onboarding.mjs +++ b/scripts/testing/feed-onboarding.mjs @@ -1,70 +1,111 @@ import assert from "node:assert/strict"; import { mkdir } from "node:fs/promises"; +import { promptFeatured, promptFeaturedPath } from "./signup-prompts.mjs"; // Run the same modal journey against the website and both built extensions. export async function checkFeedOnboarding(page, home, output) { await mkdir(output, { recursive: true }); await page.bringToFront(); - await page.goto(home); - const dialog = page.getByRole("dialog", { name: "Choose your topics" }); - await dialog.waitFor(); - await page.getByRole("region", { name: "Feed controls" }).waitFor(); - const search = dialog.getByRole("searchbox", { name: "Search topics" }); - const save = dialog.getByRole("button", { name: "Save", exact: true }); - await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor(); - assert.deepEqual( - await dialog - .locator("label") - .allTextContents() - .then((names) => names.slice(0, 3)), - ["TypeScript", "Python", "React"], - ); - assert.ok(await save.isDisabled()); - await dialog.locator("div.pagination[data-has-more]").scrollIntoViewIfNeeded(); - await dialog.getByText("Couldn’t load more topics.").waitFor(); - await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).check(); - await dialog.getByRole("button", { name: "Try again", exact: true }).click(); - await dialog.getByText("Couldn’t load more topics.").waitFor({ state: "detached" }); - assert.ok(await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).isChecked()); - await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).uncheck(); - await page.screenshot({ path: `${output}/onboarding-desktop.png` }); - await page.keyboard.press("Escape"); - await dialog.waitFor({ state: "detached" }); - await page.reload(); - await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor(); - await page.setViewportSize({ width: 390, height: 844 }); - await search.fill("no matching topic"); - await dialog.getByText("No topics match your search.").waitFor(); - await search.fill("typescript"); - const choice = dialog.getByRole("checkbox", { name: "TypeScript", exact: true }); - await choice.focus(); - await page.keyboard.press("Space"); - assert.ok(await choice.isChecked()); - await search.fill(""); - await dialog.getByRole("checkbox", { name: "Python", exact: true }).check(); - assert.ok(await save.isDisabled()); - await dialog.getByRole("checkbox", { name: "React", exact: true }).check(); - assert.equal(await save.isDisabled(), false); - await dialog.getByRole("checkbox", { name: "Docker", exact: true }).check(); - await dialog.getByText("4 selected", { exact: true }).waitFor(); - await dialog.getByRole("checkbox", { name: "Docker", exact: true }).uncheck(); - await page.screenshot({ path: `${output}/onboarding-mobile.png` }); - const bounds = await dialog.boundingBox(); - const buttonBounds = await save.boundingBox(); - assert.ok(bounds && bounds.x >= 0 && bounds.y >= 0 && bounds.y + bounds.height <= 844); - assert.ok(buttonBounds && buttonBounds.y + buttonBounds.height <= 844); - assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)); - await save.focus(); - await page.evaluate(() => document.querySelector(".sidebar a")?.focus()); - assert.ok(await dialog.evaluate((element) => element.contains(document.activeElement))); - await save.click(); - await dialog.getByText("Couldn’t save your topics. Please try again.").waitFor(); - assert.equal(await dialog.getByRole("checkbox", { checked: true }).count(), 3); - await save.click(); - await dialog.waitFor({ state: "detached" }); - await page.locator(".article-card").first().waitFor({ timeout: 15000 }); - await page.reload(); - await page.locator(".article-card").first().waitFor(); - assert.equal(await dialog.count(), 0); - await page.setViewportSize({ width: 1440, height: 1000 }); + const profilePath = "**/api/v1/user/settings/profile"; + const unclaimedProfile = (route) => + route.fulfill({ + json: { display_name: "Reader", avatar_url: null, username: null, stack: [] }, + }); + await page.route(profilePath, unclaimedProfile); + await page.route(promptFeaturedPath, promptFeatured); + // Earlier account journeys can already have declined this invitation. + if (page.url() !== "about:blank") + await page.evaluate(() => { + for (const key of Object.keys(sessionStorage)) + if (key.startsWith("devfeed:dev-card-promo-dismissed:")) sessionStorage.removeItem(key); + }); + try { + await page.goto(home); + if (page.url().startsWith("chrome-extension:")) await page.reload(); + const dialog = page.getByRole("dialog", { name: "Choose your topics" }); + await dialog.waitFor(); + await page.locator('dialog[aria-label="Your dev card preview"]').waitFor({ state: "attached" }); + await page.clock.fastForward(35_000); + const card = page.getByRole("dialog", { name: "Your dev card preview" }); + assert.equal(await card.isVisible(), false, "topic onboarding takes priority over Dev Cards"); + assert.equal(await page.getByRole("button", { name: "Not now" }).count(), 0); + await page.getByRole("region", { name: "Feed controls" }).waitFor(); + const search = dialog.getByRole("searchbox", { name: "Search topics" }); + const save = dialog.getByRole("button", { name: "Save", exact: true }); + await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor(); + assert.deepEqual( + await dialog + .locator("label") + .allTextContents() + .then((names) => names.slice(0, 3)), + ["TypeScript", "Python", "React"], + ); + assert.ok(await save.isDisabled()); + await dialog.locator("div.pagination[data-has-more]").scrollIntoViewIfNeeded(); + await dialog.getByText("Couldn’t load more topics.").waitFor(); + await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).check(); + await dialog.getByRole("button", { name: "Try again", exact: true }).click(); + await dialog.getByText("Couldn’t load more topics.").waitFor({ state: "detached" }); + assert.ok(await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).isChecked()); + await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).uncheck(); + await page.screenshot({ path: `${output}/onboarding-desktop.png` }); + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "detached" }); + await page.clock.fastForward(29_000); + assert.equal(await card.isVisible(), false, "closing onboarding leaves time to read"); + await page.clock.fastForward(1500); + await card.waitFor(); + await card.getByRole("button", { name: "Dismiss dev card preview" }).click(); + // Allow another invitation after saving, so the completion pause is exercised too. + await page.evaluate(() => { + for (const key of Object.keys(sessionStorage)) + if (key.startsWith("devfeed:dev-card-promo-dismissed:")) sessionStorage.removeItem(key); + }); + await page.reload(); + await dialog.getByRole("checkbox", { name: "TypeScript", exact: true }).waitFor(); + await page.setViewportSize({ width: 390, height: 844 }); + await search.fill("no matching topic"); + await dialog.getByText("No topics match your search.").waitFor(); + await search.fill("typescript"); + const choice = dialog.getByRole("checkbox", { name: "TypeScript", exact: true }); + await choice.focus(); + await page.keyboard.press("Space"); + assert.ok(await choice.isChecked()); + await search.fill(""); + await dialog.getByRole("checkbox", { name: "Python", exact: true }).check(); + assert.ok(await save.isDisabled()); + await dialog.getByRole("checkbox", { name: "React", exact: true }).check(); + assert.equal(await save.isDisabled(), false); + await dialog.getByRole("checkbox", { name: "Docker", exact: true }).check(); + await dialog.getByText("4 selected", { exact: true }).waitFor(); + await dialog.getByRole("checkbox", { name: "Docker", exact: true }).uncheck(); + await page.screenshot({ path: `${output}/onboarding-mobile.png` }); + const bounds = await dialog.boundingBox(); + const buttonBounds = await save.boundingBox(); + assert.ok(bounds && bounds.x >= 0 && bounds.y >= 0 && bounds.y + bounds.height <= 844); + assert.ok(buttonBounds && buttonBounds.y + buttonBounds.height <= 844); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)); + await save.focus(); + await page.evaluate(() => document.querySelector(".sidebar a")?.focus()); + assert.ok(await dialog.evaluate((element) => element.contains(document.activeElement))); + await save.click(); + await dialog.getByText("Couldn’t save your topics. Please try again.").waitFor(); + assert.equal(await dialog.getByRole("checkbox", { checked: true }).count(), 3); + await save.click(); + await dialog.waitFor({ state: "detached" }); + await page.clock.fastForward(29_000); + assert.equal(await card.isVisible(), false, "saving onboarding also leaves time to read"); + await page.clock.fastForward(1500); + await card.waitFor(); + await card.getByRole("button", { name: "Dismiss dev card preview" }).click(); + await page.locator(".article-card").first().waitFor({ timeout: 15000 }); + await page.reload(); + await page.locator(".article-card").first().waitFor(); + assert.equal(await dialog.count(), 0); + await page.setViewportSize({ width: 1440, height: 1000 }); + console.log("Topic onboarding: priority and the pause after closing/saving passed."); + } finally { + await page.unroute(profilePath, unclaimedProfile); + await page.unroute(promptFeaturedPath, promptFeatured); + } } diff --git a/scripts/testing/signup-prompts.mjs b/scripts/testing/signup-prompts.mjs new file mode 100644 index 000000000..b95f6d344 --- /dev/null +++ b/scripts/testing/signup-prompts.mjs @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; + +const slugs = ["signup-prompt-one", "signup-prompt-two", "signup-prompt-three"]; +const invitationKey = "devfeed:dev-card-promo-dismissed"; +export const promptFeaturedPath = /\/api\/v1\/users\/asaharan(?:\?.*)?$/; +export const promptFeatured = (route) => + route.fulfill({ + json: { + profile: { + username: "asaharan", + display_name: "Featured Reader", + avatar_url: null, + stack: [], + }, + }, + }); + +export function signupPromptArticle(article, slug) { + const index = slugs.indexOf(slug); + return index < 0 + ? null + : { + ...article, + id: article.id.slice(0, -1) + (index + 1), + slug, + title: `Signup prompt article ${index + 1}`, + }; +} + +// One browser journey runs against the website and the actual Chrome/Edge packages. +export async function checkSignupPrompts(context, home, output, { extension = false } = {}) { + await mkdir(output, { recursive: true }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const href = (path) => (extension ? `${home.split("#")[0]}#${path}` : new URL(path, home).href); + const banner = page.getByRole("complementary", { name: "Create a DevFeed account" }); + const preview = page.getByRole("dialog", { name: "Your dev card preview" }); + await page.route(promptFeaturedPath, promptFeatured); + await page.clock.install(); + await page.emulateMedia({ reducedMotion: "reduce" }); + try { + for (const [index, slug] of slugs.entries()) { + await page.goto(href(`/articles/${slug}`)); + const article = page.getByRole("dialog", { name: "Article preview" }); + await article.getByRole("heading", { name: `Signup prompt article ${index + 1}` }).waitFor(); + assert.equal(await banner.count(), 0, "reading an article takes priority over signup"); + await article.getByRole("button", { name: "Close preview" }).click(); + await article.waitFor({ state: "detached" }); + if (index < 2) assert.equal(await banner.count(), 0, "wait for three distinct articles"); + } + await banner.waitFor(); + await page.clock.fastForward(35_000); + assert.equal(await preview.isVisible(), false, "the banner holds back automatic Dev Cards"); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.clock.runFor(100); + await page.locator(".article-card").first().waitFor(); + await banner.waitFor(); + await page.screenshot({ path: `${output}/signup-desktop.png`, animations: "disabled" }); + await page.setViewportSize({ width: 390, height: 844 }); + const dismiss = banner.getByRole("button", { name: "Not now" }); + const bounds = await dismiss.boundingBox(); + assert.ok(bounds && bounds.height >= 44, "dismissal has a touch-sized target"); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + const bannerBounds = await banner.boundingBox(); + const navigationBounds = await page.locator(".mobile-nav").boundingBox(); + assert.ok( + bannerBounds && + navigationBounds && + bannerBounds.y + bannerBounds.height <= navigationBounds.y - 8, + "the invitation leaves mobile navigation accessible", + ); + const signup = new URL( + await banner.getByRole("link", { name: "Create account" }).getAttribute("href"), + page.url(), + ); + assert.equal(signup.pathname, "/login"); + assert.equal( + new URL(signup.searchParams.get("return_to"), home).pathname, + extension ? "/extension/login-complete" : "/latest", + ); + await page.screenshot({ path: `${output}/signup-mobile.png`, animations: "disabled" }); + await dismiss.focus(); + await page.keyboard.press("Enter"); + await banner.waitFor({ state: "detached" }); + assert.equal(await page.evaluate((key) => sessionStorage.getItem(key), invitationKey), "true"); + await page.goto(href(`/articles/${slugs[0]}`)); + await page.getByRole("button", { name: "Close preview" }).click(); + await page.reload(); + await page.clock.fastForward(40_000); + assert.equal(await banner.count(), 0, "dismissal survives navigation and reload"); + assert.equal(await preview.count(), 0, "the same refusal silences the automatic Dev Card"); + + await page.goto(href("/dev-card")); + await page.getByRole("button", { name: "Preview your card" }).click(); + await preview.waitFor(); + assert.equal(await banner.count(), 0); + await preview.getByRole("button", { name: "Dismiss dev card preview" }).click(); + + // Exercise the reverse order in a fresh reader session. + await page.evaluate(() => sessionStorage.clear()); + await page.goto(href("/latest")); + await page.reload(); + await page.locator('dialog[aria-label="Your dev card preview"]').waitFor({ state: "attached" }); + await page.clock.fastForward(35_000); + await preview.waitFor(); + await preview.getByRole("button", { name: "Dismiss dev card preview" }).click(); + await page.evaluate( + (seen) => sessionStorage.setItem("devfeed:signup-nudge-articles", JSON.stringify(seen)), + slugs, + ); + await page.reload(); + await page.clock.fastForward(40_000); + assert.equal(await banner.count(), 0, "dismissing the card also silences signup"); + assert.equal(await preview.count(), 0); + assert.deepEqual(errors, []); + console.log( + "Signup invitations: mutual dismissal, dialog priority, and explicit preview passed.", + ); + } finally { + await page.close(); + } +}