fix(mood): keep the Dark/Light grade applied through altitude morphs

The mood grade was dropped while a morph played (busy): the WebGL canvas set
filter 'none' and the fallback set #vid's filter 'none' on morph load, so the
transition showed ungraded and the grade snapped back only on landing. Now the
grade rides through the morph on both paths (the painterly DREAM stays off mid-
morph, as before, to keep it crisp). +1 e2e (mood-grade.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-07-02 05:38:19 -07:00
parent f6b45b6cd7
commit 2f8641cb28
2 changed files with 54 additions and 3 deletions
+46
View File
@@ -0,0 +1,46 @@
import { test, expect, Page } from "@playwright/test";
// Bug: the Dark/Light Mood grade was dropped DURING an altitude morph — the morph
// showed ungraded ("normal") and the grade snapped back only on landing. Both render
// paths were at fault: the WebGL canvas set filter "none" while `busy`, and the
// fallback set #vid's filter "none" when a morph loaded. The grade must ride through.
async function boot(page: Page) {
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
await expect(page.locator("#scale-name")).toBeVisible();
}
test("Dark Mood grade stays applied to the morph while scrubbing", async ({ page }) => {
await boot(page);
// Drive Mood hard dark; wait for the debounced render to build the grade.
await page.evaluate(() => {
const m = document.querySelector("#mood") as HTMLInputElement;
m.value = "-4";
m.dispatchEvent(new Event("input", { bubbles: true }));
});
await page.waitForTimeout(300);
// Begin a drag → a morph plays (busy = true). Sample the VISIBLE morph surface:
// the paint canvas (WebGL path) or #vid (fallback). #vid-loop is hidden mid-morph,
// so we deliberately exclude it.
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 18, cy - 24, { steps: 8 });
await page.waitForFunction(() => (document.querySelector("#vid") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
const filters = await page.evaluate(() => [
(document.querySelector("#paint") as HTMLElement).style.filter,
(document.querySelector("#vid") as HTMLElement).style.filter,
]);
await page.mouse.up();
// Dark grade is brightness(<1)… — at least one visible surface must carry it.
// Before the fix both were "none" here.
const graded = filters.some((f) => /brightness\(/.test(f) && !/brightness\(1(\.0+)?\)/.test(f));
expect(graded, `visible morph surface must carry the dark grade; saw ${JSON.stringify(filters)}`).toBe(true);
});
+8 -3
View File
@@ -451,11 +451,13 @@ function paintLoop() {
if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; }
gl.viewport(0, 0, w, h);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {}
// No painterly restyle during a ring transition (busy) — show it raw.
// No painterly DREAM during a ring transition (busy) — show the morph crisp.
// The Dark/Light mood GRADE, however, must ride through the transition too
// (else the morph shows ungraded and the grade snaps back only on landing).
gl.uniform1f(uAmt, busy ? 0 : dreamIntensity);
gl.uniform2f(uTexel, 1 / w, 1 / h);
gl.drawArrays(gl.TRIANGLES, 0, 3);
paint.style.filter = busy ? "none" : gradeFilter;
paint.style.filter = gradeFilter;
}
// Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed
// labels (a label enters/leaves frame as the loop plays) against playback time.
@@ -983,7 +985,10 @@ function rebuildSegment(lo, enteredFrom) {
overlay.style.opacity = "0";
affectLayer.style.opacity = "0";
tint.style.opacity = "0";
vid.style.filter = "none";
// WebGL path: the graded canvas sits on top, so #vid stays raw (no double-grade).
// Fallback path: #vid IS the visible surface, so it must carry the mood grade
// through the morph (setting "none" here was the "morph ignores Mood" bug).
vid.style.filter = paintOK ? "none" : gradeFilter;
vid.loop = false;
if (vid.dataset.morph !== file) {
vid.dataset.morph = file;