mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
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:
parent
ad3b99fe6d
commit
39e508075a
2 changed files with 130 additions and 22 deletions
|
|
@ -3,6 +3,19 @@
|
|||
* CardFace — renders one learnable unit (a single subIndex of a card)
|
||||
* for any Phase-1 card type. Stateless: the parent owns `showBack`,
|
||||
* `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';
|
||||
|
|
@ -13,9 +26,10 @@
|
|||
showBack: boolean;
|
||||
typedAnswer?: string;
|
||||
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(() => {
|
||||
switch (card.type) {
|
||||
|
|
@ -54,34 +68,127 @@
|
|||
const matched = $derived(
|
||||
isTypeIn && typedAnswer.trim().toLowerCase() === view.expected.trim().toLowerCase()
|
||||
);
|
||||
|
||||
function tryReveal() {
|
||||
if (!showBack && !isTypeIn) onReveal?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="space-y-4">
|
||||
<div class="card-content rounded-xl border border-border bg-card p-6 text-lg leading-relaxed">
|
||||
{@html view.prompt}
|
||||
</div>
|
||||
{#if isTypeIn}
|
||||
<!-- Type-in keeps the classic two-block layout: the input is part of the
|
||||
question affordance, so flipping the whole thing would hide it. -->
|
||||
<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
|
||||
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"
|
||||
placeholder="Antwort eingeben…"
|
||||
value={typedAnswer}
|
||||
oninput={(e) => onTypedAnswer?.((e.currentTarget as HTMLInputElement).value)}
|
||||
disabled={showBack}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showBack}
|
||||
<div
|
||||
class="card-content rounded-xl border-2 p-6 text-lg leading-relaxed
|
||||
{isTypeIn
|
||||
? matched
|
||||
? 'border-green-500 bg-green-500/5'
|
||||
: 'border-red-500 bg-error/5'
|
||||
: 'border-indigo-500 bg-app-accent/5'}"
|
||||
{#if showBack}
|
||||
<div
|
||||
class="card-content rounded-2xl border-2 p-6 text-lg leading-relaxed
|
||||
{matched ? 'border-success bg-success/5' : 'border-error bg-error/5'}"
|
||||
>
|
||||
{@html view.answer}
|
||||
</div>
|
||||
{/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>
|
||||
{/if}
|
||||
</article>
|
||||
<div
|
||||
class="card-face card-content card-front rounded-2xl border border-border bg-card p-6 text-lg leading-relaxed shadow-md"
|
||||
>
|
||||
{@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>
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@
|
|||
{showBack}
|
||||
{typedAnswer}
|
||||
onTypedAnswer={(v) => (typedAnswer = v)}
|
||||
onReveal={reveal}
|
||||
/>
|
||||
|
||||
{#if canSuggest}
|
||||
|
|
@ -171,14 +172,14 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !showBack}
|
||||
{#if !showBack && current.card.type === 'type-in'}
|
||||
<button
|
||||
class="mt-6 w-full rounded-lg bg-app-accent py-3 text-base text-white hover:bg-app-accent/90"
|
||||
onclick={reveal}
|
||||
>
|
||||
Aufdecken <span class="ml-2 text-xs opacity-70">(Leertaste)</span>
|
||||
</button>
|
||||
{:else}
|
||||
{:else if showBack}
|
||||
<div class="mt-6 grid grid-cols-4 gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-error py-3 text-sm text-white hover:bg-error/90"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue