Flashcard flip: paint one face explicitly, never rely on backface-visibility

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.
This commit is contained in:
Martin Dvořák 2026-10-12 07:34:38 +00:00
commit 396da6528a
5 changed files with 82 additions and 6 deletions

View file

@ -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 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). 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 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 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 card leaves study, review, mistakes and exam sessions, and Undo in the toast brings it back

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { useState } from "react"; import { useState } from "react";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@ -20,8 +20,21 @@ type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) { export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) {
const { t } = useI18n(); const { t } = useI18n();
const reduceMotion = useReducedMotion();
const [flipped, setFlipped] = useState(false); const [flipped, setFlipped] = useState(false);
const [result, setResult] = useState<boolean | null>(null); const [result, setResult] = useState<boolean | null>(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) => { const settle = (correct: boolean) => {
if (result !== null) return; if (result !== null) return;
@ -42,10 +55,15 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A
> >
<motion.div <motion.div
animate={{ rotateX: flipped ? 180 : 0 }} animate={{ rotateX: flipped ? 180 : 0 }}
transition={{ type: "spring", stiffness: 220, damping: 24 }} transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 220, damping: 24 }}
onUpdate={(latest) => showFaceFor(Number(latest.rotateX ?? 0))}
onAnimationComplete={() => showFaceFor(flipped ? 180 : 0)}
className="relative min-h-56 w-full [transform-style:preserve-3d]" className="relative min-h-56 w-full [transform-style:preserve-3d]"
> >
<PromptCard className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300 [backface-visibility:hidden]"> <PromptCard
className="absolute inset-0 flex flex-col justify-center gap-3 group-hover:border-frost-300"
style={{ visibility: face === "front" ? "visible" : "hidden" }}
>
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200"> <span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
{t("flashcard.question")} {t("flashcard.question")}
</span> </span>
@ -55,7 +73,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: A
</span> </span>
</PromptCard> </PromptCard>
<div className="[transform:rotateX(180deg)] [backface-visibility:hidden]"> <div className="[transform:rotateX(180deg)]" style={{ visibility: face === "back" ? "visible" : "hidden" }}>
<div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6"> <div className="card flex min-h-56 flex-col justify-center gap-3 px-5 py-6 sm:px-6">
<span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200"> <span className="text-xs font-bold uppercase tracking-widest text-frost-600 dark:text-frost-200">
{t("flashcard.answer")} {t("flashcard.answer")}

View file

@ -70,12 +70,19 @@ export function ActivityHeader({
export function PromptCard({ export function PromptCard({
children, children,
className, className,
style,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
style?: React.CSSProperties;
}) { }) {
return ( return (
<div className={cn("glass-panel glass-solid rounded-[1.5rem] px-5 py-6 text-balance sm:px-6", className)}>{children}</div> <div
className={cn("glass-panel glass-solid rounded-[1.5rem] px-5 py-6 text-balance sm:px-6", className)}
style={style}
>
{children}
</div>
); );
} }

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -175,6 +175,48 @@ const browser = await launchBrowser();
await context.close(); 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 -------------------------- */ /* 2. SRS due → review session; stats screen -------------------------- */
{ {
const now = Date.now(); const now = Date.now();