diff --git a/app.js b/app.js index 792ba4b..d52f546 100644 --- a/app.js +++ b/app.js @@ -989,7 +989,14 @@ async function initializeApiKeys() { // --- API KEY UI FUNCTIONS --- -function openApiKeysModal() { +// Onboarding-scoped editor re-entry. The walkthrough's own "connect account" +// step sets this before opening the shared API-key editor so that closing it +// returns to (and advances) the tour. Settings opened from any other surface +// (account card, gear, commit flow) stays false, so closing those never +// launches or advances the walkthrough. +let walkthroughSettingsActive = false; + +function openApiKeysModalBody() { const modal = document.getElementById("api-keys-modal"); openModal(modal); @@ -1003,17 +1010,47 @@ function openApiKeysModal() { } } +function openApiKeysModal() { + // A standalone settings visit, not onboarding: closing it must not touch the + // walkthrough. + walkthroughSettingsActive = false; + openApiKeysModalBody(); +} + +// Entered by the walkthrough's "Connect your FlutterFlow account" step. Routes +// the user to the real account control and remembers, on this one close, to +// return to the originating tour step. +function walkthroughConnectAccount() { + walkthroughSettingsActive = true; + // Close the tour first so the shared editor never stacks on top of it. + const wt = document.getElementById("walkthrough-modal"); + if (wt) closeModal(wt); + openApiKeysModalBody(); +} + function closeApiKeysModal(event) { if (event && event.target !== event.currentTarget) return; const modal = document.getElementById("api-keys-modal"); if (modal) { closeModal(modal); } - // Show walkthrough again after closing API keys - const walkthroughModal = document.getElementById("walkthrough-modal"); - if (walkthroughModal) { - advanceWalkthrough(); - openModal(walkthroughModal); + const fromWalkthrough = walkthroughSettingsActive; + walkthroughSettingsActive = false; + if (fromWalkthrough) { + // Return to the originating walkthrough step. Advance only when the + // account is genuinely connected, per the STU-384 canonical connection + // state (ffConnectionState), which is set only from a successful + // listProjects outcome — never from the mere presence of stored key bytes. + // A failed or cancelled connection returns to the same connect step so the + // user can retry instead of being pushed forward past an unconnected + // account. + const walkthroughModal = document.getElementById("walkthrough-modal"); + if (walkthroughModal) { + if (ffConnectionState === "connected") { + advanceWalkthrough(); + } + openModal(walkthroughModal); + } } } @@ -1030,38 +1067,17 @@ function updateWalkthroughUI() { if (!steps.length) return; steps.forEach((stepEl, idx) => { const i = idx + 1; + stepEl.classList.remove("wt-current", "wt-done", "wt-pending"); + const numEl = stepEl.querySelector(".wt-step-num"); if (i === walkthroughStep) { - stepEl.classList.remove("opacity-60", "bg-gray-50", "border-gray-200"); - stepEl.classList.add("bg-blue-50", "border-blue-200"); - const numEl = stepEl.querySelector("div:first-child"); - if (numEl) { - numEl.classList.remove("bg-gray-400"); - numEl.classList.add("bg-blue-500"); - numEl.innerHTML = i; - } + stepEl.classList.add("wt-current"); + if (numEl) numEl.textContent = i; } else if (i < walkthroughStep) { - stepEl.classList.remove("opacity-60", "bg-blue-50", "border-blue-200"); - stepEl.classList.add("bg-green-50", "border-green-200"); - const numEl = stepEl.querySelector("div:first-child"); - if (numEl) { - numEl.classList.remove("bg-blue-500", "bg-gray-400"); - numEl.classList.add("bg-green-500"); - numEl.innerHTML = "✓"; - } + stepEl.classList.add("wt-done"); + if (numEl) numEl.textContent = "✓"; } else { - stepEl.classList.add("opacity-60", "bg-gray-50", "border-gray-200"); - stepEl.classList.remove( - "bg-blue-50", - "border-blue-200", - "bg-green-50", - "border-green-200", - ); - const numEl = stepEl.querySelector("div:first-child"); - if (numEl) { - numEl.classList.remove("bg-blue-500", "bg-green-500"); - numEl.classList.add("bg-gray-400"); - numEl.innerHTML = i; - } + stepEl.classList.add("wt-pending"); + if (numEl) numEl.textContent = i; } }); } @@ -1207,10 +1223,11 @@ function updateApiKeyStatusIndicators() { async function saveApiKeys() { const flutterflowInput = document.getElementById("flutterflow-api-key-input"); const projectSelect = document.getElementById("flutterflow-projects-select"); + const enteredKey = flutterflowInput.value.trim(); // Only save if user entered a new value - if (flutterflowInput.value.trim()) { - await saveApiKey("flutterflow", flutterflowInput.value); + if (enteredKey) { + await saveApiKey("flutterflow", enteredKey); } const selectedProjectId = projectSelect?.value.trim() || ""; @@ -1226,6 +1243,15 @@ async function saveApiKeys() { // Reinitialize keys await initializeApiKeys(); + // If a key was (re)entered, wait for the REAL connection outcome before the + // editor closes, so the walkthrough advance decision in closeApiKeysModal + // sees a settled canonical state rather than an in-flight "validating" or + // the mere presence of stored bytes. STU-384 owns this state; it is only + // "connected" after a real listProjects returns at least one project. + if (enteredKey) { + await validateFlutterFlowConnection(); + } + // Update UI loadApiKeyInputs(); @@ -6894,6 +6920,7 @@ window.copyCode = copyCode; window.retryWithDifferentModel = retryWithDifferentModel; window.openApiKeysModal = openApiKeysModal; window.closeApiKeysModal = closeApiKeysModal; +window.walkthroughConnectAccount = walkthroughConnectAccount; window.closeWalkthroughModal = closeWalkthroughModal; window.openWalkthroughModal = openWalkthroughModal; window.advanceWalkthrough = advanceWalkthrough; diff --git a/e2e/stu390-logo-loop.spec.js b/e2e/stu390-logo-loop.spec.js index c5bf199..7ea4b46 100644 --- a/e2e/stu390-logo-loop.spec.js +++ b/e2e/stu390-logo-loop.spec.js @@ -99,6 +99,37 @@ async function steadyFramesIn(page, ms = 500) { return after - before; } +// Measures the logo's painted frames against the number of actual display +// frames over the SAME wall-clock window. A single loop paints at most once per +// display frame (ratio ~1); a leaked second loop paints ~2x per display frame +// (ratio ~2). Because both counts share one window, CPU contention sags them +// together and cannot inflate the ratio — unlike comparing two separate window +// counts, which is how a slower CI runner stalls the baseline and trips a +// `framesB < framesA * c` style assertion. This is the load-robust spelling of +// "the loop resumed without accumulating a second loop." +async function framesPerDisplayFrame(page, ms = 500) { + await page.evaluate(() => { + if (!window.__displayFrames) { + window.__displayFrames = 0; + (function tick() { + window.__displayFrames += 1; + requestAnimationFrame(tick); + })(); + } + }); + await page.waitForTimeout(250); // let the display counter run at steady rate + const before = await page.evaluate(() => [ + window.__pipelineLogo?.frameCount() ?? 0, + window.__displayFrames || 0, + ]); + await page.waitForTimeout(ms); + const after = await page.evaluate(() => [ + window.__pipelineLogo?.frameCount() ?? 0, + window.__displayFrames || 0, + ]); + return { logo: after[0] - before[0], display: after[1] - before[1] }; +} + // Bring the pipeline arena onto screen without a backend run. The progress // panel lives in #generation-stage (hidden until a run begins) inside // #main-stage-container (opacity 0 unless .visible). showPipelineProgress @@ -224,8 +255,6 @@ test.describe("Decorative mark lifecycle in the pipeline arena", () => { await page.setViewportSize(DESKTOP); await openHome(page); await revealPipeline(page); - const framesOn = await steadyFramesIn(page); - expect(framesOn).toBeGreaterThan(5); await page.emulateMedia({ reducedMotion: "reduce" }); await expect.poll(() => running(page), { timeout: 3000 }).toBe(false); @@ -247,12 +276,18 @@ test.describe("Decorative mark lifecycle in the pipeline arena", () => { expect(pieces.a).toBe(1); expect(pieces.b).toBe(1); - // Live toggle back on resumes without accumulating loops. + // Live toggle back on resumes without accumulating a second loop. Compare + // the resumed logo frames against display frames in the same window rather + // than against a separate baseline window: the ratio is load-invariant + // (contention slows both counters together), so it cannot trip on a slow CI + // runner while still failing if the loop genuinely doubled (~2x per frame). await page.emulateMedia({ reducedMotion: "no-preference" }); await expect.poll(() => running(page), { timeout: 3000 }).toBe(true); - const framesResumed = await steadyFramesIn(page); - expect(framesResumed).toBeGreaterThan(5); - expect(framesResumed).toBeLessThan(framesOn * 1.7); + const resumed = await framesPerDisplayFrame(page, 500); + expect(resumed.logo, "the loop should have resumed painting").toBeGreaterThan(5); + expect(resumed.display, "the window should contain real display frames").toBeGreaterThan(0); + // One loop paints at most once per display frame; a doubled loop would ~2x. + expect(resumed.logo).toBeLessThan(resumed.display * 1.6); }); }); diff --git a/e2e/stu391-tutorial-onboarding.spec.js b/e2e/stu391-tutorial-onboarding.spec.js new file mode 100644 index 0000000..1702317 --- /dev/null +++ b/e2e/stu391-tutorial-onboarding.spec.js @@ -0,0 +1,342 @@ +import { test, expect } from "@playwright/test"; +import { + applyDefaultRoutes, + guestIdentity, + signedInSession, + freeSubscription, + err, + sampleProjectList, + ENDPOINTS, +} from "./fixtures/apiFixtures.js"; + +/** + * STU-391 — tutorials and connection onboarding in the new visual system. + * + * The welcome/tutorial modal is the onboarding surface. A fresh user sees it + * once; dismiss is remembered through the existing hasSeenWalkthrough flag and + * a Tutorial nav entry reopens it. The walkthrough's "connect account" step + * routes to the REAL API-key editor (STU-384): on success the walkthrough + * returns to its next step and then to the composer, and on failure it returns + * to the SAME step instead of advancing. Closing settings opened OUTSIDE + * onboarding must never launch or advance the walkthrough. The tutorial video + * is keyboard-capable (native controls), responsive and never autoplays. + */ +const KEY = "ff-secret-token-abc123"; +const PROJ = "proj-abc-123"; + +function signedInContext(email = "metered@example.com") { + return { + [ENDPOINTS.identity]: { + status: 200, + body: JSON.stringify({ + status: "recognized", + user_id: "acct-0001", + identity_token: "identity-token", + usage_count: 2, + usage_month: new Date().toISOString().slice(0, 7), + }), + contentType: "application/json", + }, + [ENDPOINTS.authRefreshSession]: { + status: 200, + body: JSON.stringify({ email, sessionToken: "session-token" }), + contentType: "application/json", + }, + [ENDPOINTS.getSubscription]: freeSubscription(), + }; +} + +const WALKTHROUGH = "#walkthrough-modal"; + +// The walkthrough modal animates in (overlay fade ~0.25s, content scale-in +// ~0.3s via `.modal-content` transform). `toBeVisible` and `.open`/`aria-hidden` +// all pass the instant the transition starts, so a following interaction would +// race the open animation — during that window the modal is not yet fully on +// top and e.g. `#main-stage` can still intercept the pointer. Wait state-based +// for the animation to genuinely settle (computed transform back to scale(1) +// and overlay fully opaque) so a subsequent click cannot land mid-open. This is +// a state condition tied to the animation, not a wall-clock sleep. +async function waitForWalkthroughOpen(page) { + await page.waitForFunction( + () => { + const overlay = document.getElementById("walkthrough-modal"); + if (!overlay || !overlay.classList.contains("open")) return false; + if (overlay.getAttribute("aria-hidden") !== "false") return false; + if (getComputedStyle(overlay).opacity !== "1") return false; + const content = overlay.querySelector(".modal-content"); + if (!content) return false; + + // The open animation drives the content from `scale(0.96) translateY(8px)` + // to `scale(1) translateY(0)`, so a settled `.open` modal computes to the + // identity matrix. Do not match an exact string (a refactor that removes + // the transform yields `none`; a scale-authoring change yields a different + // matrix spelling): parse the 1:1 matrix and accept only a settled value. + // Mid-animation still returns false (scale < 1 or a translate is present), + // so a following click cannot land during the open transition. Unknown + // transform forms fall through to `true` so the wait can never deadlock. + const transform = getComputedStyle(content).transform; + if (!transform || transform === "none") return true; + const m = /^matrix\(([^)]*)\)$/.exec(transform) || /^matrix3d\(([^)]*)\)$/.exec(transform); + if (!m) return true; + const nums = m[1].split(",").map((n) => parseFloat(n)); + if (m[0].startsWith("matrix3d")) { + // matrix3d: scale X/Y at [0]/[5], translate at [12]/[13]. + return nums[0] === 1 && nums[5] === 1 && nums[12] === 0 && nums[13] === 0; + } + // matrix(a,b,c,d,tx,ty): scale at [0]/[3], translate at [4]/[5]. + return nums[0] === 1 && nums[3] === 1 && nums[4] === 0 && nums[5] === 0; + }, + { timeout: 8000 }, + ); + await expect(page.locator(WALKTHROUGH)).toBeVisible(); +} + +// A walkthrough interaction follows either the initial auto-open or a reopen +// (e.g. returning from the API-key editor). Assert the target itself is visible +// AND the modal has finished opening before clicking, then let Playwright's own +// actionability confirm the click lands on the element — so the click cannot +// race the open animation. +async function clickInsideWalkthrough(page, selector) { + await waitForWalkthroughOpen(page); + const target = page.locator(selector); + await expect(target).toBeVisible(); + await target.click(); +} + +// 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. +async function reopenWalkthrough(page) { + await page.waitForFunction( + () => typeof window.openWalkthroughModal === "function", + { timeout: 8000 }, + ); + await page.click("#wt-reopen"); + await waitForWalkthroughOpen(page); +} + +test.describe("STU-391 tutorial + connection onboarding", () => { + test("fresh user sees the tutorial once with a keyboard-capable, non-autoplaying video", async ({ page }) => { + await applyDefaultRoutes(page, { identity: guestIdentity() }); + await page.goto("/"); + + await expect(page.locator(WALKTHROUGH)).toBeVisible(); + await expect(page.locator("#walkthrough-step1")).toHaveClass(/wt-current/); + await expect(page.locator("#walkthrough-step1")).toContainText( + "Connect your FlutterFlow account", + ); + + const video = page.locator("#wt-tutorial-video"); + await expect(video).toHaveAttribute("controls", ""); + // The video must not autoplay unexpectedly. + expect(await video.evaluate((el) => el.hasAttribute("autoplay"))).toBe(false); + expect(await video.evaluate((el) => el.paused)).toBe(true); + // preload is conservative (metadata), not autoplay/buffering-first. + await expect(video).toHaveAttribute("preload", "metadata"); + // Prove real keyboard reach: Tab forward from the modal chrome and confirm + // the video itself receives focus. The native `controls` attribute is what + // makes it tabbable/operable — this exercises the actual Tab sequence + // rather than the near-vacuous tabIndex >= 0 check. + await page.locator("#walkthrough-modal .modal-close").focus(); + await expect(page.locator("#walkthrough-modal .modal-close")).toBeFocused(); + let reachedVideo = false; + for (let i = 0; i < 8 && !reachedVideo; i++) { + await page.keyboard.press("Tab"); + reachedVideo = await video.evaluate((el) => document.activeElement === el); + } + expect(reachedVideo).toBe(true); + }); + + test("reduced-motion still renders the onboarding usable", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await applyDefaultRoutes(page, { identity: guestIdentity() }); + await page.goto("/"); + + await expect(page.locator(WALKTHROUGH)).toBeVisible(); + await expect(page.locator("#walkthrough-step1")).toBeVisible(); + // The primary action remains clickable under reduced motion. + await clickInsideWalkthrough(page, "#walkthrough-step1 .wt-step-link"); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + }); + + test("dismissing onboarding is remembered for the returning user", async ({ page }) => { + await applyDefaultRoutes(page, { identity: guestIdentity() }); + await page.goto("/"); + await expect(page.locator(WALKTHROUGH)).toBeVisible(); + + await page.check("#walkthrough-dont-show"); + await clickInsideWalkthrough(page, ".wt-gotit-btn"); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + + expect( + await page.evaluate(() => localStorage.getItem("hasSeenWalkthrough")), + ).toBe("true"); + + // Returning-user load does not re-show it. + await page.reload(); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + }); + + test("a returning user reopens the tutorial from the Tutorial entry", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page, { identity: guestIdentity() }); + await page.goto("/"); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + + await reopenWalkthrough(page); + await expect(page.locator("#walkthrough-step1")).toHaveClass(/wt-current/); + }); + + test("connecting from onboarding routes to the real editor and, on success, returns to the next step then the composer", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + await reopenWalkthrough(page); + + // Connect step opens the REAL account editor, not a fake flow. + await clickInsideWalkthrough(page, "#walkthrough-step1 .wt-step-link"); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + + const input = page.locator("#flutterflow-api-key-input"); + await input.fill(KEY); + await input.blur(); + await expect( + page.locator(`#flutterflow-projects-select option[value="${PROJ}"]`), + ).toHaveCount(1); + await page.locator("#flutterflow-projects-select").selectOption(PROJ); + await page.locator("#api-keys-modal .bg-blue-500").click(); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + + // saveApiKeys closes the editor inside a 1s timeout, then returns to the + // walkthrough at the next step. Poll for the walkthrough rather than + // sleeping on a fixed window so the assertion is immune to CI load, then + // let the robust click wait for the reopen animation to settle. + await expect(page.locator(WALKTHROUGH)).toBeVisible({ timeout: 8000 }); + await expect(page.locator("#walkthrough-step2")).toHaveClass(/wt-current/); + + // "Add Prompt" returns to the composer. + await clickInsideWalkthrough(page, "#walkthrough-step2 .wt-step-link"); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + await expect(page.locator("#pipeline-input")).toBeFocused(); + // Advancing only happens when the connection genuinely succeeded, which is + // the behavioural proof that connecting wrote through the real editor and + // its projects fetch returned a real project. + }); + + test("a cancelled connection (no key entered) returns to the same onboarding step, not forward", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page, { + ...signedInContext(), + // Listed projects fail: the real editor rejects the key. + [ENDPOINTS.flutterFlowListProjects]: err(401, { error: "unauthorized" }), + [ENDPOINTS.flutterFlowLegacyListProjects]: err(401, { error: "unauthorized" }), + }); + await page.goto("/"); + await reopenWalkthrough(page); + await expect(page.locator("#walkthrough-step1")).toHaveClass(/wt-current/); + + await clickInsideWalkthrough(page, "#walkthrough-step1 .wt-step-link"); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + + // Cancel path: close the editor without having typed/stored a key. + await page.evaluate(() => window.closeApiKeysModal()); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + + // Returned to the SAME step — no advance past an unconnected account. + await expect(page.locator(WALKTHROUGH)).toBeVisible(); + await expect(page.locator("#walkthrough-step1")).toHaveClass(/wt-current/); + await expect(page.locator("#walkthrough-step2")).not.toHaveClass(/wt-current/); + }); + + test("a rejected key entered on onboarding returns to the same step with the failure surfaced", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page, { + ...signedInContext(), + // The key the user types is genuinely rejected by the real endpoint. + [ENDPOINTS.flutterFlowListProjects]: err(401, { error: "unauthorized" }), + [ENDPOINTS.flutterFlowLegacyListProjects]: err(401, { error: "unauthorized" }), + }); + await page.goto("/"); + await reopenWalkthrough(page); + await expect(page.locator("#walkthrough-step1")).toHaveClass(/wt-current/); + + await clickInsideWalkthrough(page, "#walkthrough-step1 .wt-step-link"); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + + // Type a key and blur so the real (routed-401) endpoint evaluates it, then + // Save & Close. This is the REAL failure path: bytes get stored, but the + // connection is rejected. + await page.locator("#flutterflow-api-key-input").fill(KEY); + await page.locator("#flutterflow-api-key-input").blur(); + await expect(page.locator("#flutterflow-projects-error")).toBeVisible(); + await page.locator("#api-keys-modal .bg-blue-500").click(); + + // saveApiKeys stored bytes, but the connection genuinely failed, so the + // walkthrough must return to the SAME step — never advance on a rejected + // key. + await expect(page.locator("#api-keys-modal")).toBeHidden(); + await expect(page.locator(WALKTHROUGH)).toBeVisible(); + await expect(page.locator("#walkthrough-step1")).toHaveClass(/wt-current/); + await expect(page.locator("#walkthrough-step2")).not.toHaveClass(/wt-current/); + + // The failure is surfaced truthfully on the account connection card, which + // STU-384 renders from the real fetch outcome, not from stored bytes. + await clickInsideWalkthrough(page, ".wt-gotit-btn"); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + await page.locator('a.nav-link[data-view="account"]').click(); + await expect(page.locator("#account-view")).toBeVisible(); + await expect(page.locator("#acct-ff-status")).toContainText("API key rejected"); + }); + + test("closing settings opened outside onboarding does not launch or advance the walkthrough", async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + localStorage.setItem( + "ccc_auth_session", + JSON.stringify({ email: "metered@example.com", sessionToken: "session-token" }), + ); + }); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + + // Open the settings editor from the account connection card — NOT onboarding. + await page.locator('a.nav-link[data-view="account"]').click(); + await expect(page.locator("#account-view")).toBeVisible(); + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .scrollIntoViewIfNeeded(); + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + + // Closing it must not launch the walkthrough at all. + await page.evaluate(() => window.closeApiKeysModal()); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + + // Nor later (the old buggy path reopened it after the save window). Poll + // the negative over the window instead of a single fixed sleep, so a + // delayed reopen is caught mid-window rather than slipping past a one-shot + // wall-clock gap. + const settleDeadline = Date.now() + 1500; + while (Date.now() < settleDeadline) { + await expect(page.locator(WALKTHROUGH)).toBeHidden(); + await page.waitForTimeout(100); + } + }); +}); diff --git a/index.html b/index.html index 185aa57..0584677 100644 --- a/index.html +++ b/index.html @@ -2128,345 +2128,171 @@ color: var(--accent); } - /* ── Walkthrough Modal Redesign ─────────────────────── */ - .wt-hero { - display: flex; - align-items: center; - gap: 28px; - padding: 32px 32px 24px; - background: linear-gradient(168deg, #f0f4ff 0%, #f8f9ff 40%, #fff 100%); - position: relative; - overflow: hidden; - } - - .wt-hero::before { - content: ''; - position: absolute; - top: -40px; - right: -40px; - width: 180px; - height: 180px; - background: radial-gradient(circle, rgba(139, 92, 246, 0.06) 0%, transparent 70%); - border-radius: 50%; - pointer-events: none; - } - - .wt-illustration { - position: relative; - width: 200px; - height: 200px; - flex-shrink: 0; - } - - .wt-orbit { - position: absolute; - inset: 0; - border-radius: 50%; - border: 2px solid transparent; - animation: wt-orbit-spin 20s linear infinite; - } - - .wt-orbit-1 { - border-image: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(168, 85, 247, 0.15), transparent 60%) 1; - border-radius: 50%; - border: 2px solid rgba(99, 102, 241, 0.15); - } - - .wt-orbit-2 { - inset: 16px; - border: 2px dashed rgba(139, 92, 246, 0.12); - animation-direction: reverse; - animation-duration: 28s; - } - - .wt-orbit-3 { - inset: 32px; - border: 1.5px solid rgba(59, 130, 246, 0.1); - animation-duration: 15s; - } - - @keyframes wt-orbit-spin { - to { transform: rotate(360deg); } - } - - .wt-center-gem { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 72px; - height: 72px; - } - - .wt-gem-shape { - width: 100%; - height: 100%; - filter: drop-shadow(0 4px 12px rgba(99, 102, 241, 0.3)); - } - - .wt-code-tag { - position: absolute; - font-family: 'DM Mono', monospace; - font-size: 13px; - font-weight: 500; - color: #8b5cf6; - background: rgba(255, 255, 255, 0.9); - backdrop-filter: blur(4px); - padding: 4px 8px; - border-radius: 6px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - line-height: 1; - } - - .wt-code-tag-1 { bottom: 28px; left: 4px; color: #6366f1; } - .wt-code-tag-2 { bottom: 32px; right: 0px; color: #7c3aed; } - - .wt-gear { - position: absolute; - color: #94a3b8; - opacity: 0.5; - animation: wt-gear-spin 8s linear infinite; - } - .wt-gear-1 { top: 12px; right: 28px; width: 22px; height: 22px; } - .wt-gear-2 { top: 36px; right: 8px; width: 16px; height: 16px; animation-direction: reverse; animation-duration: 12s; } - - @keyframes wt-gear-spin { - to { transform: rotate(360deg); } - } - - .wt-sparkle { - position: absolute; - width: 8px; - height: 8px; - animation: wt-sparkle-pulse 2s ease-in-out infinite; - } - .wt-sparkle::before, - .wt-sparkle::after { - content: ''; - position: absolute; - background: currentColor; - border-radius: 1px; - } - .wt-sparkle::before { - width: 2px; - height: 100%; - left: 50%; - transform: translateX(-50%); - } - .wt-sparkle::after { - width: 100%; - height: 2px; - top: 50%; - transform: translateY(-50%); - } - - .wt-sparkle-1 { top: 20px; left: 24px; color: #818cf8; animation-delay: 0s; } - .wt-sparkle-2 { top: 8px; left: 80px; color: #a78bfa; animation-delay: 0.6s; } - .wt-sparkle-3 { top: 44px; left: 56px; color: #6366f1; animation-delay: 1.2s; font-size: 6px; } - .wt-sparkle-4 { bottom: 16px; right: 44px; color: #818cf8; animation-delay: 0.3s; } + /* ── Walkthrough / Tutorial Modal (new visual system) ─── */ + .wt-modal { max-width: 640px; overflow: hidden; background: var(--surface); } - @keyframes wt-sparkle-pulse { - 0%, 100% { opacity: 0.3; transform: scale(0.8); } - 50% { opacity: 1; transform: scale(1.2); } + .wt-topbar { + display: flex; align-items: center; justify-content: space-between; + gap: 16px; padding: 18px 24px; + border-bottom: 1px solid var(--border); } - - .wt-free-badge { - position: absolute; - top: 8px; - left: 50%; - transform: translateX(-50%); - background: linear-gradient(135deg, #22c55e, #16a34a); - color: #fff; - font-family: 'Delight', 'Outfit', sans-serif; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - padding: 3px 12px; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); - z-index: 2; + .wt-title { + font-family: var(--font-display); + font-size: var(--fs-h4); + font-weight: var(--fw-bold); + color: var(--fg); + letter-spacing: var(--ls-heading); } - .wt-hero-text { - flex: 1; - min-width: 0; + .wt-hero { padding: 22px 24px 0; } + .wt-hero-kicker { + font-family: var(--font-mono); + font-size: var(--fs-micro); + font-weight: 600; + letter-spacing: var(--ls-mono); + text-transform: uppercase; + color: var(--accent); + margin-bottom: 8px; } - .wt-hero-heading { - font-family: 'Delight', 'Outfit', sans-serif; - font-size: 26px; - font-weight: 700; + font-family: var(--font-display); + font-size: var(--fs-h3); + font-weight: var(--fw-black); color: var(--fg); - letter-spacing: -0.03em; - line-height: 1.2; - } - - .wt-info-card { - margin: 0 24px; - padding: 20px 24px; - background: linear-gradient(135deg, #eef2ff 0%, #f0f4ff 50%, #ede9fe 100%); - border-radius: 14px; - text-align: center; - position: relative; - } - - .wt-info-text { - font-family: 'Delight', 'DM Sans', sans-serif; - font-size: 14px; - color: #334155; - line-height: 1.6; - margin-bottom: 16px; - } - - .wt-info-text strong { - font-weight: 700; - color: #1e293b; + letter-spacing: var(--ls-heading); + line-height: 1.15; } - - .wt-upgrade-btn { - display: block; - width: 100%; - padding: 14px 24px; - background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 50%, #4f46e5 100%); - color: #fff; - font-family: 'Delight', 'DM Sans', sans-serif; - font-size: 14px; - font-weight: 600; - border: none; - border-radius: 10px; - cursor: pointer; - letter-spacing: -0.01em; - transition: all 0.2s ease; - box-shadow: 0 4px 14px -3px rgba(99, 102, 241, 0.4); + .wt-hero-sub { + margin: 8px 0 16px; + font-size: var(--fs-small); + color: var(--muted); + line-height: var(--lh-body); + max-width: 58ch; } - - .wt-upgrade-btn:hover { - transform: translateY(-1px); - box-shadow: 0 6px 20px -3px rgba(99, 102, 241, 0.5); - background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 50%, #4338ca 100%); + .wt-video { + border-radius: var(--radius-md); + overflow: hidden; + border: 1px solid var(--border-strong); + background: oklch(16% 0.02 265); + aspect-ratio: 16 / 9; } + .wt-video video { display: block; width: 100%; height: 100%; object-fit: cover; } .wt-steps { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 14px; - padding: 20px 24px 0; + list-style: none; + margin: 20px 0 0; + padding: 0 24px; + display: grid; gap: 10px; } - .wt-step-card { - background: #fff; - border: 1px solid #e8ebf0; - border-radius: 12px; - padding: 18px; - transition: border-color 0.2s, box-shadow 0.2s; + display: flex; align-items: flex-start; gap: 14px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + transition: border-color var(--t-fast) var(--ease), + box-shadow var(--t-fast) var(--ease), + opacity var(--t-fast) var(--ease); } - - .wt-step-card:hover { - border-color: #c7d2fe; - box-shadow: 0 2px 8px rgba(99, 102, 241, 0.08); + .wt-step-card.wt-current { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); } + .wt-step-card.wt-pending { opacity: 0.72; } .wt-step-num { - width: 28px; - height: 28px; - background: #3b82f6; - color: #fff; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 13px; - font-weight: 700; - font-family: 'Delight', 'DM Sans', sans-serif; - margin-bottom: 10px; + flex: none; width: 28px; height: 28px; border-radius: 50%; + display: grid; place-items: center; + font-family: var(--font-mono); + font-size: var(--fs-caption); + font-weight: 600; + color: var(--fg); + border: 1px solid var(--border-strong); + background: var(--surface-sunken); } + .wt-current .wt-step-num { background: var(--accent); border-color: var(--accent); color: var(--on-dark); } + .wt-done .wt-step-num { background: var(--ok); border-color: var(--ok); color: var(--on-dark); } + .wt-step-body { flex: 1; min-width: 0; } .wt-step-title { - font-family: 'Delight', 'Outfit', sans-serif; - font-size: 14px; - font-weight: 600; - color: #1e293b; - margin-bottom: 4px; + display: block; + font-family: var(--font-body); + font-size: var(--fs-body); + font-weight: var(--fw-bold); + color: var(--fg); + letter-spacing: -0.01em; } - .wt-step-desc { - font-size: 12.5px; - color: #64748b; + display: block; margin-top: 3px; + font-size: var(--fs-caption); + color: var(--muted); line-height: 1.5; - margin-bottom: 10px; } - .wt-step-link { - font-size: 12.5px; - font-weight: 600; - color: #3b82f6; + margin-top: 9px; padding: 0; + background: none; border: none; cursor: pointer; + font-family: var(--font-body); + font-size: var(--fs-small); + font-weight: var(--fw-bold); + color: var(--accent); text-decoration: none; - cursor: pointer; - background: none; - border: none; - padding: 0; - font-family: 'Delight', 'DM Sans', sans-serif; - transition: color 0.15s; } - .wt-step-link:hover { color: #2563eb; } + .wt-step-link:hover { text-decoration: underline; } - .wt-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 24px 20px; + .wt-upgrade { + margin: 16px 24px 0; padding: 14px 18px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface-sunken); + display: flex; flex-wrap: wrap; align-items: center; + justify-content: space-between; gap: 12px; } - - .wt-dont-show { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; + .wt-info-text { + flex: 1 1 250px; + font-size: var(--fs-small); color: var(--muted); + line-height: var(--lh-body); } - - .wt-dont-show input[type="checkbox"] { - width: 16px; - height: 16px; - border-radius: 4px; - border: 1.5px solid #cbd5e1; - accent-color: #3b82f6; + .wt-info-text strong { font-weight: var(--fw-bold); color: var(--fg); } + .wt-upgrade-btn { + padding: 10px 16px; + background: var(--accent); color: var(--on-dark); + font-family: var(--font-body); + font-size: var(--fs-small); font-weight: var(--fw-bold); + border: none; border-radius: var(--radius-control); cursor: pointer; + transition: box-shadow var(--t-fast) var(--ease), transform var(--t-fast) var(--ease); } + .wt-upgrade-btn:hover { box-shadow: var(--shadow-control); } - .wt-dont-show span { - font-size: 12px; - color: #94a3b8; - font-family: 'Delight', 'DM Sans', sans-serif; + .wt-footer { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 16px 24px 20px; } - + .wt-dont-show { display: flex; align-items: center; gap: 8px; cursor: pointer; } + .wt-dont-show input[type="checkbox"] { + width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; + } + .wt-dont-show span { font-size: var(--fs-caption); color: var(--muted-2); } .wt-gotit-btn { - padding: 10px 22px; - background: #3b82f6; - color: #fff; - font-family: 'Delight', 'DM Sans', sans-serif; - font-size: 14px; - font-weight: 600; - border: none; - border-radius: 10px; + min-height: var(--control-h); + padding: 10px 20px; + background: var(--fg); color: var(--surface); + font-family: var(--font-body); + font-size: var(--fs-body); font-weight: var(--fw-bold); + border: none; border-radius: var(--radius-control); cursor: pointer; - transition: background 0.15s; - display: flex; - align-items: center; - gap: 6px; - } - .wt-gotit-btn:hover { background: #2563eb; } - - .wt-gotit-btn svg { - width: 16px; - height: 16px; + display: flex; align-items: center; gap: 6px; + transition: background-color var(--t-fast) var(--ease); } + .wt-gotit-btn:hover { background: oklch(from var(--fg) calc(l + 0.12) c h); } + .wt-gotit-btn svg { width: 15px; height: 15px; } @media (max-width: 640px) { - .wt-hero { flex-direction: column; text-align: center; gap: 16px; padding: 24px; } - .wt-illustration { width: 160px; height: 160px; } - .wt-hero-heading { font-size: 22px; } - .wt-steps { grid-template-columns: 1fr; } - .wt-info-card { margin: 0 16px; } + .wt-hero { padding: 18px 16px 0; } + .wt-steps { padding: 0 16px; } + .wt-upgrade { margin-left: 16px; margin-right: 16px; flex-direction: column; align-items: stretch; } + .wt-upgrade-btn { text-align: center; } + .wt-footer { padding-left: 16px; padding-right: 16px; } } /* ── Pricing Modal Redesign ─────────────────────── */ @@ -3462,6 +3288,7 @@ Home Account Plans + Tutorial
- +