diff --git a/app.js b/app.js index f1563b3..febd0fc 100644 --- a/app.js +++ b/app.js @@ -62,6 +62,7 @@ import { } from "./src/deployOutcome.js"; import { buildFlutterFlowSyncMetadata } from "./src/flutterFlowSyncMetadata.js"; import { initHeroMarkField } from "./src/heroMarkField.js"; +import { createPipelineLogoLoop } from "./src/logoMotion.js"; import { applyDependencyOverrides, mergeDependenciesIntoYaml, @@ -5684,6 +5685,11 @@ document.addEventListener("DOMContentLoaded", async () => { // Decorative hero mark field (WebGL with 2D fallback); no dependency on generation state. window.__heroField = initHeroMarkField(); + // Decorative pipeline logo loop (120 s authored mark motion in the pipeline + // arena). Self-managed: pauses when hidden/offscreen/reduced-motion and is + // cleanup-safe on view transition; completion never depends on it. + window.__pipelineLogo = createPipelineLogoLoop(); + await initializeAuth(); handleCheckoutRedirect(); await fetchSubscription(); diff --git a/e2e/stu390-logo-loop.spec.js b/e2e/stu390-logo-loop.spec.js new file mode 100644 index 0000000..c5bf199 --- /dev/null +++ b/e2e/stu390-logo-loop.spec.js @@ -0,0 +1,342 @@ +import { test, expect } from "@playwright/test"; +import { + applyDefaultRoutes, + ENDPOINTS, + freeSubscription, + guestIdentity, + oneArtifact, + quotaExhausted, + reviewPassed, +} from "./fixtures/apiFixtures.js"; + +/** + * STU-390 — the authored logo animation in the pipeline arena. + * + * The mark is DECORATIVE: pipeline completion depends only on real backend + * events, never on this animation. These tests drive the real pipeline stage + * machine (hermetic fixtures, no timers gating a stage) alongside the loop, + * and separately measure the loop's rendering lifecycle across hidden-tab, + * CSS-hidden (display:none) and viewport-offscreen states. `offsetParent` + * alone is NOT proof of viewport visibility, so the offscreen case is checked + * through the arena's actual on-screen geometry, not just a hidden attribute. + */ + +const DESKTOP = { width: 1440, height: 900 }; + +const STAGE_RESPONSES = { + architect: oneArtifact, + generator: oneArtifact, + review: reviewPassed, +}; + +function deferred() { + let resolve; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +function stepOf(route) { + try { + return JSON.parse(route.request().postData() || "{}").step || ""; + } catch { + return ""; + } +} + +async function routePipelineStages(page, { hold = [], responses = {} } = {}) { + const gates = Object.fromEntries(hold.map((s) => [s, deferred()])); + await page.route(ENDPOINTS.pipeline, async (route) => { + const step = stepOf(route); + if (gates[step]) await gates[step].promise; + const entry = responses[step] ?? STAGE_RESPONSES[step]; + if (entry === "abort") { + await route.abort(); + return; + } + await route.fulfill(typeof entry === "function" ? entry() : entry); + }); + return { release: (s) => gates[s].resolve() }; +} + +async function openHome(page, overrides = {}) { + await page.addInitScript(() => { + localStorage.setItem("hasSeenWalkthrough", "true"); + }); + await applyDefaultRoutes(page, { + [ENDPOINTS.identity]: guestIdentity(), + [ENDPOINTS.getSubscription]: freeSubscription(), + ...overrides, + }); + await page.goto("/"); + await expect(page.locator("#pipeline-input")).toBeVisible(); +} + +const running = (page) => page.evaluate(() => window.__pipelineLogo?.isRunning() === true); + +// Counts only the logo loop's own painted frames over `ms`. This isolates the +// module from every other requestAnimationFrame consumer on the page (the hero +// mark field animates independently), so a "still turning over" loop cannot be +// mistaken for, or hidden by, unrelated page animation. +async function framesIn(page, ms) { + const before = await page.evaluate(() => window.__pipelineLogo?.frameCount() ?? 0); + await page.waitForTimeout(ms); + const after = await page.evaluate(() => window.__pipelineLogo?.frameCount() ?? 0); + return after - before; +} + +const currentTime = (page) => page.evaluate(() => window.__pipelineLogo?.currentTime() ?? -1); + +// Painted frames over `ms` measured from a steady state: a fresh loop is given +// a warm-up before the window opens, so two rate measurements are comparable. +// A leaked second loop would paint ~2x the frames in the same window. +async function steadyFramesIn(page, ms = 500) { + await page.waitForTimeout(250); + const before = await page.evaluate(() => window.__pipelineLogo?.frameCount() ?? 0); + await page.waitForTimeout(ms); + const after = await page.evaluate(() => window.__pipelineLogo?.frameCount() ?? 0); + return after - before; +} + +// 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 +// flips all three; we mirror that so the mark truly renders for the test. +async function revealPipeline(page) { + await page.evaluate(() => { + const stage = document.getElementById("generation-stage"); + stage.hidden = false; + stage.inert = false; + stage.classList.add("is-active"); + document.getElementById("main-stage-container").classList.add("visible"); + document.getElementById("pipeline-progress").classList.add("visible"); + }); + await expect.poll(() => running(page), { timeout: 3000 }).toBe(true); +} + +async function hidePipeline(page) { + await page.evaluate(() => { + document.getElementById("pipeline-progress").classList.remove("visible"); + }); + await expect.poll(() => running(page), { timeout: 3000 }).toBe(false); +} + +test.describe("Decorative mark lifecycle in the pipeline arena", () => { + test("mark exists in the pipeline arena, is decorative and animates when the pipeline shows", async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openHome(page); + + const arena = page.locator(".pipeline-glyph.logo-loop"); + await expect(arena).toHaveCount(1); + // Decorative: excluded from the accessibility tree (aria-hidden, not focusable). + await expect(arena).toHaveAttribute("aria-hidden", "true"); + const reachable = await page.evaluate(() => { + const mark = document.querySelector(".pipeline-glyph.logo-loop"); + const focusable = mark.querySelectorAll('a[href], button, [tabindex], input, select, textarea'); + return focusable.length; + }); + expect(reachable, "decorative mark must expose no focusable control").toBe(0); + + // Hidden by default (pipeline not running) — no loop. + await expect.poll(() => running(page), { timeout: 3000 }).toBe(false); + + await revealPipeline(page); + // While visible it actively renders: a genuine frame metric from the module. + const frames = await framesIn(page, 500); + expect(frames, "the mark should animate while its arena is on screen").toBeGreaterThan(5); + }); + + test("hidden browser tab stops rendering and returning resumes a single loop", async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openHome(page); + await revealPipeline(page); + + const framesOn = await steadyFramesIn(page); + expect(framesOn).toBeGreaterThan(5); + + // Simulate the tab being hidden (document.hidden + visibilitychange). + await page.evaluate(() => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => true }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await expect.poll(() => running(page), { timeout: 3000 }).toBe(false); + const framesHidden = await framesIn(page, 500); + expect(framesHidden, "no logo frames while the tab is hidden").toBe(0); + + // Restore visibility; the loop must resume, not accumulate a second loop. + await page.evaluate(() => { + Object.defineProperty(document, "hidden", { configurable: true, get: () => false }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await expect.poll(() => running(page), { timeout: 3000 }).toBe(true); + const framesReenter = await steadyFramesIn(page); + expect(framesReenter, "a restored loop should stay a single loop").toBeGreaterThan(5); + // If a second loop had leaked in, the painted-frame rate would ~double. + expect(framesReenter).toBeLessThan(framesOn * 1.6); + }); + + test("CSS-hidden pipeline (display:none) stops rendering with no raf leak", async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openHome(page); + await revealPipeline(page); + await expect.poll(() => running(page)).toBe(true); + const framesOn = await steadyFramesIn(page); + expect(framesOn).toBeGreaterThan(5); + + // display:none -> offsetParent null. This is the CSS-hidden case. + await hidePipeline(page); + const framesHidden = await framesIn(page, 500); + expect(framesHidden, "no logo frames once the pipeline is display:none").toBe(0); + + // Re-enter: still a single loop, never a doubled one. + await revealPipeline(page); + const framesBack = await steadyFramesIn(page); + expect(framesBack).toBeGreaterThan(5); + expect(framesBack).toBeLessThan(framesOn * 1.7); + }); + + test("viewport-offscreen arena stops rendering even though it is not display:none", async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openHome(page); + await revealPipeline(page); + await expect.poll(() => running(page)).toBe(true); + + // Move the arena out of the viewport while keeping it in layout (transform, + // not display:none), so offsetParent stays non-null. Only the Intersection- + // Observer viewport check can catch this. + await page.evaluate(() => { + document.getElementById("pipeline-progress").style.transform = "translateY(4000px)"; + }); + await expect.poll(() => running(page), { timeout: 3000 }).toBe(false); + const framesOff = await framesIn(page, 500); + expect(framesOff, "no logo frames once the arena is offscreen").toBe(0); + + // Bring it back on screen — one loop resumes. + await page.evaluate(() => { + document.getElementById("pipeline-progress").style.transform = "none"; + }); + await expect.poll(() => running(page), { timeout: 3000 }).toBe(true); + expect(await framesIn(page, 500)).toBeGreaterThan(5); + }); + + test("live reduced-motion toggle freezes to the static rest frame and stops the loop", async ({ page }) => { + 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); + const framesReduced = await framesIn(page, 500); + expect(framesReduced, "no logo frames under reduced motion").toBe(0); + + // The clock is frozen while reduced: it must not advance while we wait. + const before = await currentTime(page); + await page.waitForTimeout(300); + const after = await currentTime(page); + expect(after).toBe(before); + expect(after).toBeGreaterThanOrEqual(0); + + // Static rest frame is painted (browser serialises "1.000" back to "1"). + const pieces = await page.evaluate(() => ({ + a: parseFloat(document.querySelector(".loop-piece-a").style.opacity), + b: parseFloat(document.querySelector(".loop-piece-b").style.opacity), + })); + expect(pieces.a).toBe(1); + expect(pieces.b).toBe(1); + + // Live toggle back on resumes without accumulating loops. + 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); + }); +}); + +test.describe("Progress is independent of the animation position", () => { + test("a run that is mid-loop still completes when the backend releases every stage", async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openHome(page); + const pipeline = await routePipelineStages(page, { hold: ["architect", "generator", "review"] }); + + await page.locator("#pipeline-input").fill("A gauge widget"); + await page.locator("#hero-send").click(); + + // Arena is showing and animating while the first stage is held. + await expect.poll(() => running(page), { timeout: 3000 }).toBe(true); + + // Park the animation at a non-rest beat (t=90, the orbit). Completion must + // not depend on where the loop is. + await page.evaluate(() => window.__pipelineLogo.seek(90)); + + pipeline.release("architect"); + await expect(page.locator("#pdot-1")).toHaveAttribute("data-state", "done"); + pipeline.release("generator"); + await expect(page.locator("#pdot-2")).toHaveAttribute("data-state", "done"); + pipeline.release("review"); + await expect(page.locator("#results-view")).toHaveClass(/visible/); + + // With Results shown, the arena leaves the screen, so its loop must stop. + await expect.poll(() => running(page), { timeout: 3000 }).toBe(false); + const framesAfter = await framesIn(page, 500); + expect(framesAfter, "no logo frames after the pipeline leaves view").toBe(0); + }); + + test("a mid-loop run still fails honestly when the backend rejects a stage", async ({ page }) => { + await page.setViewportSize(DESKTOP); + await openHome(page); + const pipeline = await routePipelineStages(page, { hold: ["architect"], responses: { generator: quotaExhausted } }); + + await page.locator("#pipeline-input").fill("A gauge widget"); + await page.locator("#hero-send").click(); + + await expect.poll(() => running(page), { timeout: 3000 }).toBe(true); + await page.evaluate(() => window.__pipelineLogo.seek(47)); // peek-a-boo beat + + // Let Architect succeed; Generator then answers with a quota rejection. + pipeline.release("architect"); + + const failure = page.locator("#pipeline-failure"); + await expect(failure).toBeVisible(); + await expect(failure).toHaveAttribute("data-kind", "quota"); + await expect(page.locator("#results-view")).not.toHaveClass(/visible/); + await expect(page.locator("#pdot-1")).toHaveAttribute("data-state", "done"); + await expect(page.locator("#pdot-2")).toHaveAttribute("data-state", "failed"); + }); +}); + +test.describe("Visual review artefacts (human review, not pixel baselines)", () => { + for (const [name, width, height] of [ + ["mobile", 360, 800], + ["desktop", 1440, 900], + ]) { + test(`captures representative beats for ${name} review`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height }); + await openHome(page); + await revealPipeline(page); + // Freeze the loop so a seek paints a deterministic, stable beat. + await page.emulateMedia({ reducedMotion: "reduce" }); + + const beats = [ + [0, "rest"], + [47, "peek-a-boo"], + [90, "orbit"], + [120, "rest-boundary"], + ]; + for (const [beat, label] of beats) { + await page.evaluate((t) => window.__pipelineLogo.seek(t), beat); + await page.waitForTimeout(40); + const pageShot = testInfo.outputPath(`stu390-page-${label}-${name}.png`); + await page.screenshot({ path: pageShot }); + await testInfo.attach(`page @ ${label} (${name})`, { path: pageShot }); + // Close-up of the two-piece mark itself for brand-geometry review. + const markShot = testInfo.outputPath(`stu390-mark-${label}-${name}.png`); + await page.locator(".loop-mark").screenshot({ path: markShot }); + await testInfo.attach(`mark @ ${label} (${name})`, { path: markShot }); + } + }); + } +}); diff --git a/index.html b/index.html index 8b189ef..874793b 100644 --- a/index.html +++ b/index.html @@ -34,6 +34,7 @@ +