From afc8909d3f6c8d09b756e864caf0187fff8f2320 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 11 Oct 2026 18:05:13 +0200 Subject: [PATCH] fix(studio): the furniture list was rendered at opacity 0 and never painted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Studio furniture pane was present in the DOM the whole time and still looked empty. Every row existed, had real dimensions, and its image loaded with a 200 — and none of it was visible. The pane was wrapped in a `motion/react-m` element declared with `initial={{ opacity: 0 }}` and `animate={{ opacity: 1 }}`. That minimal entry renders the element but never runs the animation, so the inline style stayed at opacity 0 and the content was painted transparently forever. Measured on the live release: the table sat at opacity 1 directly inside a wrapper pinned at `style="opacity: 0"`. It went unnoticed because playwright.ui.config.ts sets reducedMotion to "reduce", under which the animation is skipped and the element lands straight on its final value. The existing Studio specs therefore passed while the real browser showed nothing. That also means the perf win from the minimal entry was never actually delivering a working fade — it only hid the breakage. The decorative 150ms fade is now a CSS animation (.studio-list-fade-in). A CSS animation cannot strand content this way: if it never runs, the element is simply opaque. motion/react-m had exactly one usage in the app and is gone; the four files that use the full motion/react are untouched and unaffected. Adds e2e/ui/studio-visibility.spec.ts, which opts out of reduced motion and asserts no ancestor of a furniture row is faded below 0.9. Verified it fails on the old code with `Received: 0` and passes on the new, so this cannot regress silently again. --- e2e/ui/studio-visibility.spec.ts | 136 ++++++++++++++++++ src/app/globals.css | 20 +++ src/components/admin/studio/studio-client.tsx | 23 ++- 3 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 e2e/ui/studio-visibility.spec.ts diff --git a/e2e/ui/studio-visibility.spec.ts b/e2e/ui/studio-visibility.spec.ts new file mode 100644 index 00000000..405ae7fe --- /dev/null +++ b/e2e/ui/studio-visibility.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from "@playwright/test"; + +/** + * The furniture pane once declared `initial={{ opacity: 0 }}` / `animate={{ + * opacity: 1 }}` through motion's minimal `motion/react-m` entry. That entry + * renders the element but never runs the animation, so the inline style stayed + * at opacity: 0: every row was in the DOM, measurable, and its image loaded, + * yet the whole list was invisible. + * + * studio.spec.ts could not catch it because playwright.ui.config.ts sets + * `reducedMotion: "reduce"`, and under reduced motion the animation is skipped + * and the element lands straight on its final value. This file opts out of that + * so a future animation swap cannot quietly hide the list again. + */ + +const items = [ + { + id: 1, + classname: "fixture_chair", + name: "Fixture chair", + description: "Synthetic chair", + type: "flooritem", + revision: 1, + category: "other", + alreadyImported: true, + nitroExists: true, + iconUrl: "/fixture/cover.svg", + }, + { + id: 2, + classname: "fixture_table", + name: "Fixture table", + description: "Synthetic table", + type: "flooritem", + revision: 1, + category: "other", + alreadyImported: false, + nitroExists: false, + iconUrl: "/fixture/cover.svg", + }, +]; + +test("the furniture pane is painted, not merely present in the DOM", async ({ + browser, +}) => { + const context = await browser.newContext({ + reducedMotion: "no-preference", + viewport: { width: 1440, height: 900 }, + }); + const page = await context.newPage(); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + + if (url.pathname === "/api/admin/import/furni") { + return route.fulfill({ + json: + url.searchParams.get("action") === "stats" + ? { totalInDb: 2, inCatalog: 1, notInCatalog: 1, missingNitro: 0 } + : { + items, + meta: { currentPage: 1, lastPage: 1, total: 2, perPage: 20 }, + }, + }); + } + if (url.pathname === "/api/admin/import/clone") + return route.fulfill({ json: { sources: [] } }); + if (url.pathname === "/api/admin/studio/import-jobs") + return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } }); + if (url.pathname === "/api/admin/studio/nitro-quality") + return route.fulfill({ + json: { + report: { + scales: [32, 64].map((size) => ({ + size, + state: "missing", + assets: [], + animations: [], + directions: [], + issues: [], + })), + }, + }, + }); + return route.fulfill({ + status: 404, + json: { error: "Unknown fixture endpoint" }, + }); + }); + + await page.goto("/admin/studio-harness", { waitUntil: "domcontentloaded" }); + + const list = page.locator('[data-testid="studio-furniture-list"]'); + await expect(list).toBeVisible(); + + await expect + .poll(async () => list.locator("[data-index]").count(), { timeout: 20000 }) + .toBeGreaterThan(0); + // Let any entrance animation run before sampling computed styles. + await page.waitForTimeout(1000); + + // The rows exist. Now prove they are actually painted: no ancestor may be + // left faded out, which is precisely the failure this guards against. + const samples = await list.locator("[data-index]").evaluateAll((els) => + els.slice(0, 5).map((el) => { + const opacities: number[] = []; + let node: Element | null = el; + while (node && opacities.length < 12) { + opacities.push(Number(getComputedStyle(node).opacity)); + node = node.parentElement; + } + const rect = el.getBoundingClientRect(); + return { + minOpacity: Math.min(...opacities), + width: rect.width, + height: rect.height, + }; + }), + ); + + expect(samples.length).toBeGreaterThan(0); + for (const s of samples) { + expect(s.minOpacity, "an ancestor is faded out").toBeGreaterThan(0.9); + expect(s.width).toBeGreaterThan(0); + expect(s.height).toBeGreaterThan(0); + } + + // Playwright treats opacity 0 as not visible, so this is the end-to-end form. + await expect(list.locator("[data-index]").first()).toBeVisible(); + await expect( + page.getByRole("button", { name: "View Fixture chair", exact: true }), + ).toBeVisible(); + + await context.close(); +}); diff --git a/src/app/globals.css b/src/app/globals.css index 0a015639..d1ea9970 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -239,6 +239,26 @@ html { } /* ── Photo lightbox (was an AnimatePresence opacity pair) ───────────────── */ + +/* ── Studio furniture fade-in ───────────────────────────────────────────── + Replaces a `motion/react-m` wrapper that declared initial opacity 0 and + animate opacity 1. That minimal entry renders the element but never runs + the animation, so the style stayed at opacity: 0 and the entire furniture + list was present, loaded and measurable, yet invisible. A CSS animation + cannot strand content that way: if the animation never runs the element is + simply opaque. */ +@keyframes studio-list-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.studio-list-fade-in { + animation: studio-list-fade-in 150ms ease-out both; +} + @keyframes lightbox-fade-in { from { opacity: 0; diff --git a/src/components/admin/studio/studio-client.tsx b/src/components/admin/studio/studio-client.tsx index db293b74..f0d5ddb3 100644 --- a/src/components/admin/studio/studio-client.tsx +++ b/src/components/admin/studio/studio-client.tsx @@ -26,13 +26,13 @@ import { Trash2, X, } from "lucide-react"; -// motion/react-m is the minimal entry. The full motion/react pulls in -// framer-motion's entire component library (73 internal modules) for what this -// file needs: one fade-in on the result pane. The minimal entry ships only the -// element factories (2 modules) and exposes the same initial/animate/transition -// props, so the fade behaves identically. Only the element factory is -// imported, since that is the single one this file renders. -import { div as Mdiv } from "motion/react-m"; +// The fade-in on the furniture pane is a plain CSS animation +// (.studio-list-fade-in), not motion. It used to be a `motion/react-m` element +// declared with initial opacity 0 and animate opacity 1. That minimal entry +// renders the element but never runs the animation, so the inline style stayed +// at opacity: 0 and the whole furniture list was present, measurable and fully +// loaded — yet invisible. Every other animation in the app uses the full +// motion/react and is unaffected; this was the only motion/react-m usage. import { lazy, Suspense, @@ -2059,12 +2059,7 @@ export function StudioClient({

) : ( - +
{viewMode === "grid" ? (
)}
- +
)}