mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +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)
|
* 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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue