feat(cards): CardFace v2 — 3D-Flip + tap-anywhere reveal

- CardFace now renders one card surface that flips on Y-axis when
  the user taps it. Both faces share a CSS-grid cell so the parent
  height is the max of front/back, no jumpy reflow on flip.
- Tap-anywhere on the surface reveals (only while showBack is
  false). The /learn page keeps the keyboard space/enter shortcut
  via the existing handler; the standalone "Aufdecken" button is
  now type-in-only (where the input field on the front breaks the
  flip-mental-model).
- prefers-reduced-motion: reduce collapses the rotateY into an
  instant cross-fade — same affordance, no vestibular trigger.
- Added a subtle "Tippe auf die Karte oder drücke Leertaste" hint
  on the front face so the new affordance is discoverable.
- Fixed the last Phase-A leftover: focus:border-indigo-400 in the
  type-in input → focus:border-app-accent.
This commit is contained in:
Till JS 2026-05-08 02:48:40 +02:00
parent ad3b99fe6d
commit 39e508075a
2 changed files with 130 additions and 22 deletions

View file

@ -3,6 +3,19 @@
* CardFace — renders one learnable unit (a single subIndex of a card) * CardFace — renders one learnable unit (a single subIndex of a card)
* for any Phase-1 card type. Stateless: the parent owns `showBack`, * for any Phase-1 card type. Stateless: the parent owns `showBack`,
* `typedAnswer`, and any timing. * `typedAnswer`, and any timing.
*
* Card-feel design (Phase A polish):
* - Single surface that physically flips on Y axis when revealed.
* Both faces share a CSS-grid cell so the parent height is the
* max of front/back, no jumpy reflow on flip.
* - Tap anywhere on the surface reveals (only while `showBack` is
* false). The /learn page keeps the keyboard space/enter shortcut.
* - `prefers-reduced-motion: reduce` collapses the rotateY into an
* instant cross-fade — same affordance, no vestibular trigger.
*
* Type-in cards skip the flip: the input field doesn't make sense on
* a flippable face, so we keep the historical "input + answer below"
* layout for that single card type.
*/ */
import { renderCloze, renderMarkdown, type Card } from '@mana/cards-core'; import { renderCloze, renderMarkdown, type Card } from '@mana/cards-core';
@ -13,9 +26,10 @@
showBack: boolean; showBack: boolean;
typedAnswer?: string; typedAnswer?: string;
onTypedAnswer?: (value: string) => void; onTypedAnswer?: (value: string) => void;
onReveal?: () => void;
} }
let { card, subIndex, showBack, typedAnswer = '', onTypedAnswer }: Props = $props(); let { card, subIndex, showBack, typedAnswer = '', onTypedAnswer, onReveal }: Props = $props();
const view = $derived.by(() => { const view = $derived.by(() => {
switch (card.type) { switch (card.type) {
@ -54,34 +68,127 @@
const matched = $derived( const matched = $derived(
isTypeIn && typedAnswer.trim().toLowerCase() === view.expected.trim().toLowerCase() isTypeIn && typedAnswer.trim().toLowerCase() === view.expected.trim().toLowerCase()
); );
function tryReveal() {
if (!showBack && !isTypeIn) onReveal?.();
}
</script> </script>
<article class="space-y-4"> {#if isTypeIn}
<div class="card-content rounded-xl border border-border bg-card p-6 text-lg leading-relaxed"> <!-- Type-in keeps the classic two-block layout: the input is part of the
{@html view.prompt} question affordance, so flipping the whole thing would hide it. -->
</div> <article class="space-y-4">
<div
class="card-content rounded-2xl border border-border bg-card p-6 text-lg leading-relaxed shadow-md"
>
{@html view.prompt}
</div>
{#if isTypeIn}
<input <input
class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-base outline-none focus:border-indigo-400" class="w-full rounded-lg border border-border-strong bg-background px-3 py-2 text-base outline-none focus:border-app-accent"
type="text" type="text"
placeholder="Antwort eingeben…" placeholder="Antwort eingeben…"
value={typedAnswer} value={typedAnswer}
oninput={(e) => onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)} oninput={(e) => onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)}
disabled={showBack} disabled={showBack}
/> />
{/if}
{#if showBack} {#if showBack}
<div <div
class="card-content rounded-xl border-2 p-6 text-lg leading-relaxed class="card-content rounded-2xl border-2 p-6 text-lg leading-relaxed
{isTypeIn {matched ? 'border-success bg-success/5' : 'border-error bg-error/5'}"
? matched >
? 'border-green-500 bg-green-500/5' {@html view.answer}
: 'border-red-500 bg-error/5' </div>
: 'border-indigo-500 bg-app-accent/5'}" {/if}
</article>
{:else}
<article class="card-stage">
<button
type="button"
class="card-flip"
class:flipped={showBack}
onclick={tryReveal}
aria-label={showBack ? 'Karte aufgedeckt' : 'Karte aufdecken'}
> >
{@html view.answer} <div
</div> class="card-face card-content card-front rounded-2xl border border-border bg-card p-6 text-lg leading-relaxed shadow-md"
{/if} >
</article> {@html view.prompt}
{#if !showBack}
<p class="card-hint mt-4 text-xs text-muted-foreground/70">
Tippe auf die Karte oder drücke Leertaste
</p>
{/if}
</div>
<div
class="card-face card-content card-back rounded-2xl border-2 border-app-accent bg-app-accent/5 p-6 text-lg leading-relaxed shadow-md"
>
{@html view.answer}
</div>
</button>
</article>
{/if}
<style>
.card-stage {
perspective: 1500px;
}
.card-flip {
position: relative;
display: grid;
width: 100%;
min-height: 280px;
padding: 0;
border: 0;
background: transparent;
text-align: left;
font: inherit;
color: inherit;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-flip.flipped {
transform: rotateY(180deg);
cursor: default;
}
.card-flip:focus-visible {
outline: 2px solid hsl(var(--color-app-accent));
outline-offset: 4px;
border-radius: 1rem;
}
.card-face {
grid-area: 1 / 1;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.card-back {
transform: rotateY(180deg);
}
@media (prefers-reduced-motion: reduce) {
.card-flip,
.card-flip.flipped {
transition: none;
transform: none;
}
.card-back {
transform: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.card-flip.flipped .card-back {
opacity: 1;
}
.card-flip.flipped .card-front {
opacity: 0;
}
}
</style>

View file

@ -146,6 +146,7 @@
{showBack} {showBack}
{typedAnswer} {typedAnswer}
onTypedAnswer={(v) => (typedAnswer = v)} onTypedAnswer={(v) => (typedAnswer = v)}
onReveal={reveal}
/> />
{#if canSuggest} {#if canSuggest}
@ -171,14 +172,14 @@
{/if} {/if}
{/if} {/if}
{#if !showBack} {#if !showBack && current.card.type === 'type-in'}
<button <button
class="mt-6 w-full rounded-lg bg-app-accent py-3 text-base text-white hover:bg-app-accent/90" class="mt-6 w-full rounded-lg bg-app-accent py-3 text-base text-white hover:bg-app-accent/90"
onclick={reveal} onclick={reveal}
> >
Aufdecken <span class="ml-2 text-xs opacity-70">(Leertaste)</span> Aufdecken <span class="ml-2 text-xs opacity-70">(Leertaste)</span>
</button> </button>
{:else} {:else if showBack}
<div class="mt-6 grid grid-cols-4 gap-2"> <div class="mt-6 grid grid-cols-4 gap-2">
<button <button
class="rounded-lg bg-error py-3 text-sm text-white hover:bg-error/90" class="rounded-lg bg-error py-3 text-sm text-white hover:bg-error/90"