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" ? (
)}
- +
)}