Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
342 changes: 342 additions & 0 deletions e2e/stu390-logo-loop.spec.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
}
});
11 changes: 11 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
<link rel="stylesheet" href="/src/designTokens.css" />
<link rel="stylesheet" href="/src/sharedControls.css" />
<link rel="stylesheet" href="/src/heroMarkField.css" />
<link rel="stylesheet" href="/src/logoMotion.css" />
<style>
:root {
/* Layout constants preserved until the shell change that owns them. */
Expand Down Expand Up @@ -3464,6 +3465,16 @@ <h3 class="text-base font-semibold text-gray-800 mb-2">Free generations used</h3

<!-- Pipeline Progress View -->
<div id="pipeline-progress" class="pipeline-progress">
<!-- Decorative authored two-piece mark loop (STU-390); never
gates completion - progress depends only on backend events. -->
<div class="pipeline-glyph logo-loop" aria-hidden="true">
<span class="loop-shadow"></span>
<span class="loop-mark">
<span class="loop-halo"></span>
<span class="loop-piece loop-piece-a"><svg viewBox="0 0 44 44" fill="currentColor" focusable="false"><path d="M25 5H17C9.82 5 4 10.82 4 18S9.82 31 17 31H24V25H17A7 7 0 0 1 17 11H25Z"></path></svg></span>
<span class="loop-piece loop-piece-b"><svg viewBox="0 0 44 44" fill="currentColor" focusable="false"><path d="M25 5H17C9.82 5 4 10.82 4 18S9.82 31 17 31H24V25H17A7 7 0 0 1 17 11H25Z" transform="rotate(180 22 22)"></path></svg></span>
</span>
</div>
<div class="pipeline-prompt-recap">
<p class="pipeline-prompt-kicker">Your prompt</p>
<p class="pipeline-prompt-text" id="pipeline-submitted-prompt"></p>
Expand Down
Binary file added screenshots/stu-390/stu390-mark-orbit-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/stu-390/stu390-mark-orbit-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/stu-390/stu390-mark-rest-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/stu-390/stu390-mark-rest-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/stu-390/stu390-page-orbit-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/stu-390/stu390-page-rest-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading