From 53e9c72364812e645cf0b22a7c5ebd433da0ce07 Mon Sep 17 00:00:00 2001 From: Ryan Dombrowski Date: Tue, 21 Jul 2026 14:39:13 -0400 Subject: [PATCH] tour: auto-start for first-time visitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tour is the studio's best explanation of itself, and first-run detection already existed — it only relabeled the header button. Now an eligible first visit (no tour-done sentinel, no permalink hash) starts the tour directly on the recorded repair run. A write-probe guards the auto-start: if localStorage can't persist the dismissal, we never auto-launch (it would recur every visit) and the behavior degrades to the manual button. tourOffered dies with the offer label. Visitor history is now part of every e2e test's premise, so the Playwright configs split into two projects: "first-run" (clean storage, runs the new tour-first-run.spec.ts) and "studio" (seeded tour-done, runs everything else as a returning visitor) — zero edits to existing specs. Fail-first: auto-start and non-recurrence failed on the old build ("element(s) not found" for tour-bar); deep-link immunity passed, as the old code never auto-started. Full suite: 93 passed, 4 skipped. Co-Authored-By: Claude Fable 5 --- apps/web/app/studio.tsx | 14 ++++--- e2e/tour-first-run.spec.ts | 41 +++++++++++++++++++ playwright.config.ts | 34 ++++++++++++++-- playwright.production.config.ts | 71 ++++++++++++++++++++++----------- 4 files changed, 128 insertions(+), 32 deletions(-) create mode 100644 e2e/tour-first-run.spec.ts diff --git a/apps/web/app/studio.tsx b/apps/web/app/studio.tsx index 52c0870..43a81c3 100644 --- a/apps/web/app/studio.tsx +++ b/apps/web/app/studio.tsx @@ -428,14 +428,18 @@ export function Studio() { // Guided tour: drives the SAME deep-link mechanism as permalinks, so // every step is a real, reachable UI state. Non-blocking, restartable. const [tourStep, setTourStep] = useState(null); - const [tourOffered, setTourOffered] = useState(false); useEffect(() => { try { - if (!localStorage.getItem("dspack-studio-tour-done") && !location.hash) setTourOffered(true); - } catch { /* storage unavailable: no offer, tour stays reachable via the header button */ } + if (localStorage.getItem("dspack-studio-tour-done") || location.hash) return; + // Auto-start only when the dismissal can be REMEMBERED: a storage that + // reads fine but throws on write would re-launch the tour every visit. + localStorage.setItem("dspack-studio-tour-probe", "1"); + localStorage.removeItem("dspack-studio-tour-probe"); + startTour(0); + } catch { /* storage unavailable: no auto-start, tour stays reachable via the header button */ } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const startTour = (n = 0) => { - setTourOffered(false); setLinkError(null); setView("replay"); setScenarioId(TOUR_STEPS[n].state.scenario!); @@ -535,7 +539,7 @@ export function Studio() {

diff --git a/e2e/tour-first-run.spec.ts b/e2e/tour-first-run.spec.ts new file mode 100644 index 0000000..8842209 --- /dev/null +++ b/e2e/tour-first-run.spec.ts @@ -0,0 +1,41 @@ +/** + * First-visit tour auto-start, against the static artifact. + * + * Runs ONLY in the "first-run" Playwright project, which (unlike "studio") + * seeds no storageState: every test here starts as a genuine first-time + * visitor. The rest of the suite runs as a returning visitor by construction. + */ +import { expect, test } from "@playwright/test"; + +test("a first-time visitor lands in the tour, on a real recorded state", async ({ page }) => { + await page.goto("/"); + + // Step 1 of the tour: the failed gate moment of the real repair run — + // the same state tour-xray-wire.spec.ts asserts after a manual start. + await expect(page.getByTestId("tour-bar")).toBeVisible(); + await expect(page.getByTestId("tour-title")).toContainText("argues back"); + await expect(page.getByTestId("gate-ticker")).toContainText("S3✗"); +}); + +test("the tour never recurs after dismissal, and stays restartable", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("tour-bar")).toBeVisible(); + await page.getByTestId("tour-skip").click(); + await expect(page.getByTestId("tour-bar")).toHaveCount(0); + + await page.reload(); + // A dismissed tour is remembered: the studio loads directly, with the + // manual entry point still present. + await expect(page.getByTestId("scenario-project-deletion")).toBeVisible(); + await expect(page.getByTestId("tour-bar")).toHaveCount(0); + await expect(page.getByTestId("tour-start")).toBeVisible(); +}); + +test("deep-linked first-time visitors are never hijacked by the tour", async ({ page }) => { + await page.goto("/#s=project-deletion&f=clean&e=11"); + // The permalink resolves to its own state... + await expect(page.getByTestId("gate-ticker")).toBeVisible(); + await expect(page.getByTestId("scenario-project-deletion")).toBeVisible(); + // ...and the tour stays out of the way. + await expect(page.getByTestId("tour-bar")).toHaveCount(0); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 6441a9b..aa67334 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,6 +9,8 @@ import { defineConfig } from "@playwright/test"; * Run `pnpm --filter web build` first (CI does; the webServer commands below * do not rebuild to keep local iteration fast). */ +const BASE_URL = "http://localhost:3311"; + export default defineConfig({ testDir: "e2e", timeout: 30_000, @@ -18,16 +20,40 @@ export default defineConfig({ // so parallel workers can race each other through it. One worker keeps // every run deterministic; the static production suite stays parallel. workers: 1, - // prod-smoke asserts the DEPLOYED site's properties (no local agent, - // injected analytics); it runs via playwright.production.config.ts only. - testIgnore: ["prod-smoke.spec.ts"], use: { - baseURL: "http://localhost:3311", + baseURL: BASE_URL, // Deterministic CI: the homepage is alive by default (auto-plays the // first recording); under reduced motion it jumps to the finished // surface instead, which is also the stable state tests start from. contextOptions: { reducedMotion: "reduce" }, }, + // The tour auto-starts for first-time visitors, so visitor history is part + // of every test's premise. Two projects make it explicit: "first-run" + // starts with clean storage (the auto-start spec), "studio" is seeded as a + // returning visitor so every other spec keeps asserting the plain studio. + projects: [ + { + name: "first-run", + testMatch: "tour-first-run.spec.ts", + }, + { + name: "studio", + // prod-smoke asserts the DEPLOYED site's properties (no local agent, + // injected analytics); it runs via playwright.production.config.ts only. + testIgnore: ["prod-smoke.spec.ts", "tour-first-run.spec.ts"], + use: { + storageState: { + cookies: [], + origins: [ + { + origin: BASE_URL, + localStorage: [{ name: "dspack-studio-tour-done", value: "1" }], + }, + ], + }, + }, + }, + ], webServer: [ { command: "node e2e/serve-static.mjs", diff --git a/playwright.production.config.ts b/playwright.production.config.ts index 04180c7..7df7b55 100644 --- a/playwright.production.config.ts +++ b/playwright.production.config.ts @@ -8,34 +8,59 @@ import { defineConfig } from "@playwright/test"; * * npx playwright test --config playwright.production.config.ts */ +const PROD = process.env.PROD_URL ?? "https://studio.aesthetic-function.com"; + export default defineConfig({ testDir: "e2e", - testMatch: [ - "replay.spec.ts", - "alive.spec.ts", - "permalinks.spec.ts", - "tour-xray-wire.spec.ts", - "receipts.spec.ts", - "fork.spec.ts", - "fixture-006.spec.ts", - "inspector.spec.ts", - "wire.spec.ts", - "a11y.spec.ts", - "studio-shell.spec.ts", - // Replay + restyle only; agent-free by construction. - "design-swap.spec.ts", - // The deployed site has no agent, which is exactly the state this suite - // reproduces locally by blocking the port — it runs as-is in production. - "break-offline.spec.ts", - // Agent-free by construction (blocks the agent port itself); the - // validator, config, and downloads are static-site capabilities. - "take-home.spec.ts", - "prod-smoke.spec.ts", - ], timeout: 30_000, retries: 1, use: { - baseURL: process.env.PROD_URL ?? "https://studio.aesthetic-function.com", + baseURL: PROD, contextOptions: { reducedMotion: "reduce" }, }, + // Mirrors playwright.config.ts: "first-run" asserts the deployed + // first-visit tour auto-start with clean storage; "studio" runs everything + // else as a returning visitor (seeded tour-done) so no spec meets the tour. + projects: [ + { + name: "first-run", + testMatch: "tour-first-run.spec.ts", + }, + { + name: "studio", + testMatch: [ + "replay.spec.ts", + "alive.spec.ts", + "permalinks.spec.ts", + "tour-xray-wire.spec.ts", + "receipts.spec.ts", + "fork.spec.ts", + "fixture-006.spec.ts", + "inspector.spec.ts", + "wire.spec.ts", + "a11y.spec.ts", + "studio-shell.spec.ts", + // Replay + restyle only; agent-free by construction. + "design-swap.spec.ts", + // The deployed site has no agent, which is exactly the state this suite + // reproduces locally by blocking the port — it runs as-is in production. + "break-offline.spec.ts", + // Agent-free by construction (blocks the agent port itself); the + // validator, config, and downloads are static-site capabilities. + "take-home.spec.ts", + "prod-smoke.spec.ts", + ], + use: { + storageState: { + cookies: [], + origins: [ + { + origin: new URL(PROD).origin, + localStorage: [{ name: "dspack-studio-tour-done", value: "1" }], + }, + ], + }, + }, + }, + ], });