From 396da6528a06464d98011f3296f8ba651a8e0d90 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Mon, 12 Oct 2026 07:34:38 +0000 Subject: [PATCH] Flashcard flip: paint one face explicitly, never rely on backface-visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a GPU-composited desktop the flipped card showed the question mirrored over the answer. Both faces are glass layers with backdrop-filter, and on some compositing paths the front's backface keeps rendering, so the pre-rotated back face and the mirrored front face both painted. - The faces are now painted by an explicit visibility swap driven by the animation's own rotation (onUpdate): the swap lands at the 90° edge-on moment, where it cannot be seen, so exactly one face is ever in the render tree — independent of backface-visibility and of the browser's 3D flattening rules. onAnimationComplete lands the swap for instant flips (reduced motion now skips the spring, matching the app's motion rules). - PromptCard takes an optional style so the face can carry the visibility. - phase-2 suite: the painted-face invariant at rest, flipped and flipped back, plus the answer reading inside the card (56 checks). - README: decision 53. SW -> freezmo-v39. --- README.md | 9 +++++++ components/activities/Flashcard.tsx | 26 +++++++++++++++--- components/activities/shared.tsx | 9 ++++++- public/sw.js | 2 +- tests/phase2-test.mjs | 42 +++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 29d754d..6e96d15 100644 --- a/README.md +++ b/README.md @@ -1355,6 +1355,15 @@ Things I chose while building, with the reasoning: phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a monthly-limit body, a two-device sync round-trip, and the generation guards below). +53. **The flashcard flip never relies on `backface-visibility`.** The card is two faces in a + `preserve-3d` container (the back pre-rotated 180°) — the classic CSS flip — but each face is a + glass layer with `backdrop-filter`, and on some GPU/compositing paths the front's backface keeps + rendering, so the flipped card showed the question mirrored over the answer. The faces are now + painted by an explicit `visibility` swap driven by the animation's own rotation (the swap lands + at the 90° edge-on moment, where it cannot be seen), so exactly one face is ever in the render + tree. Reduced motion skips the spring and the swap still lands via `onAnimationComplete`. The + phase-2 suite asserts the painted-face invariant at rest, flipped and flipped back. + 52. **Reporting a question is local-first, with a server copy for the operator.** The red "!" in the feed chrome records the question in this browser — that local list is the skip state, so the card leaves study, review, mistakes and exam sessions, and Undo in the toast brings it back diff --git a/components/activities/Flashcard.tsx b/components/activities/Flashcard.tsx index c9fc8fe..1e7421c 100644 --- a/components/activities/Flashcard.tsx +++ b/components/activities/Flashcard.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion, useReducedMotion } from "framer-motion"; import { useState } from "react"; import { useI18n } from "@/lib/i18n"; @@ -20,8 +20,21 @@ type FlashcardActivity = Extract; export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps) { const { t } = useI18n(); + const reduceMotion = useReducedMotion(); const [flipped, setFlipped] = useState(false); const [result, setResult] = useState(null); + /** + * Which face is painted. The swap happens at the 90° edge-on moment instead + * of relying on backface-visibility: a backdrop-filter layer (the card + * glass) keeps rendering its backface on some GPU/compositing paths, which + * showed the question mirrored over the answer. + */ + const [face, setFace] = useState<"front" | "back">("front"); + + const showFaceFor = (angle: number) => { + const next = angle > 90 && angle < 270 ? "back" : "front"; + setFace((current) => (current === next ? current : next)); + }; const settle = (correct: boolean) => { if (result !== null) return; @@ -42,10 +55,15 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A > showFaceFor(Number(latest.rotateX ?? 0))} + onAnimationComplete={() => showFaceFor(flipped ? 180 : 0)} className="relative min-h-56 w-full [transform-style:preserve-3d]" > - + {t("flashcard.question")} @@ -55,7 +73,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A -
+
{t("flashcard.answer")} diff --git a/components/activities/shared.tsx b/components/activities/shared.tsx index be14f52..4185cd9 100644 --- a/components/activities/shared.tsx +++ b/components/activities/shared.tsx @@ -70,12 +70,19 @@ export function ActivityHeader({ export function PromptCard({ children, className, + style, }: { children: React.ReactNode; className?: string; + style?: React.CSSProperties; }) { return ( -
{children}
+
+ {children} +
); } diff --git a/public/sw.js b/public/sw.js index 1e29876..9cb3f33 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v38"; +const CACHE_VERSION = "freezmo-v39"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/phase2-test.mjs b/tests/phase2-test.mjs index d048c87..e867858 100644 --- a/tests/phase2-test.mjs +++ b/tests/phase2-test.mjs @@ -175,6 +175,48 @@ const browser = await launchBrowser(); await context.close(); } +/* 1d. flashcard flip paints exactly one face ------------------------- */ +{ + const { context, page } = await makePage(browser); + const flashDeck = { + ...DECK, + id: "seed_flash", + title: "Flashcards", + activities: [ + { type: "flashcard", id: "f1", xp: 10, topic: "Sky", front: "Why is the sky blue?", back: "Rayleigh scattering.", explanation: "e" }, + { type: "flashcard", id: "f2", xp: 10, topic: "Sky", front: "Second question", back: "Second answer", explanation: "e" }, + ], + }; + await seed(page, { decks: [flashDeck] }); + await page.goto(`${BASE}/deck/seed_flash`, { waitUntil: "networkidle" }); + await page.waitForTimeout(1000); + + const faces = () => + page.evaluate(() => { + const motion = document.querySelector("button.group > div"); + const [front, back] = motion ? [...motion.children] : []; + const visibility = (element) => (element ? getComputedStyle(element).visibility : null); + return { front: visibility(front), back: visibility(back) }; + }); + + const atRest = await faces(); + check("flashcard: front face painted at rest", atRest.front === "visible" && atRest.back === "hidden", JSON.stringify(atRest)); + await page.locator("button.group").first().click(); + await page.waitForTimeout(900); + const flipped = await faces(); + check( + "flashcard: flip paints the back face only (never mirrored)", + flipped.front === "hidden" && flipped.back === "visible", + JSON.stringify(flipped), + ); + check("flashcard: the answer reads inside the card", await page.locator("button.group").getByText("Rayleigh scattering.").isVisible()); + await page.locator("button.group").first().click(); + await page.waitForTimeout(1000); + const back = await faces(); + check("flashcard: flipping back restores the question face", back.front === "visible" && back.back === "hidden", JSON.stringify(back)); + await context.close(); +} + /* 2. SRS due → review session; stats screen -------------------------- */ { const now = Date.now();