cards/apps/web/src/lib/components/CardSurface.svelte
Till JS 870e2aea85 feat(decks): card-stack visualization + direct-launch study mode
Decks fühlen sich jetzt wie echte Karten an, statt flacher Boxen.
Eine zentrale CardSurface-Komponente trägt die Karten-Optik
(Border, Radius 0.875rem, Shadow, Color-Stripe links) — drei
Sizes (md/lg/hero), drei Konsumenten, ein visuelles Familien-Set.

Komponenten:
- CardSurface.svelte   Foundation für jede Card-Erscheinung in Cards
- DeckStack.svelte     5:7-Stapel mit 3 deterministisch tilted
                       Hintergrund-Layern (cyrb53-Hash der Deck-ID,
                       reproduzierbar pro Mount), Color-Stripe-Akzent,
                       Title/Description/Card-Count/Due-Badge
- DeckGrid.svelte      Auto-fill responsive Grid; selectedId-Prop
                       triggert Stufe-1-Animation (others fade-out,
                       selected scale-up)
- DeckFan.svelte       Auffächer-Detail-View (Hand of Cards) mit
                       Top-7-Karten als Spread, Mount-Animation
                       gestapelt → fanned via doppeltem rAF +
                       cubic-bezier-Transition
- utils/deck-tilt.ts   cyrb53 + stackLayers für Pseudo-Random-Tilts

Routing-Wechsel: Klick auf Deck-Stack → 220ms Stufe-1-Fade →
goto('/study/<id>') direkt in den Lernmodus. Detail-View
(/decks/<id>) bleibt erreichbar über "Karten verwalten →"-Link
in der Study-Sidebar.

Lernmodus visuell als Karte:
- Globale Header (Logo + Nav + Sprach-Switcher) im Lernmodus
  ausgeblendet (routes/+layout.svelte detektiert /study/<deckId>
  per Regex), volle Konzentration auf die Karte
- Lern-Karte ist CardSurface size="hero" mit aspect-ratio 5:7
  (Portrait, gleiches Verhältnis wie Deck-Stacks und Fan-Karten)
- Color-Stripe links zeigt die Deck-Farbe — visuelle Bindung an
  Herkunft
- Sidebar oben links absolute-positioniert: ← Decks, Deck-Name,
  Fortschritt, Karten verwalten → — kompakter UI-Block, der die
  Karten-Zentrierung NICHT beeinflusst (Karte bleibt geometrisch
  Bildschirm-Mitte)
- Reveal-Button + 4er-Grade-Grid (Wieder/Schwer/Gut/Leicht) als
  Aktions-Leiste UNTER der Karte — keine weiteren Karten, ein
  Review = eine Karte

Mobile (≤720px): Sidebar wird zur horizontalen Zeile oben, Karte
rückt darunter durch padding-top: 6.5rem.

Reduced-motion durchgängig respektiert (keine Tilts, keine Hover-
Lifts, keine Fan-Spread-Animation, kein Card-Transition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:02:04 +02:00

187 lines
4.1 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
/**
* Zentrale Karten-Optik der Cards-App.
*
* Eine Komponente — drei Erscheinungen, alle gleichen "Card-Feel":
* • size="md" → Deck-Stapel-Cover oder Hand-Karte (5:7-Format, kompakt)
* • size="lg" → Aufgefächerte Detail-Karte
* • size="hero" → Lern-Karte (groß, fokussiert, breiter)
*
* Alle drei tragen den gleichen Border-Radius, Border-Style, Shadow-
* Stil und Surface-Farbe. So fühlt sich jede Karten-Erscheinung wie
* eine Karte an — Deckblatt, Fan-Karte, Study-Karte sind visuell ein
* Familien-Set.
*
* 12-Token-Disziplin: nur `--color-surface`, `--color-border`,
* `--color-foreground`, `--color-primary*`. Kein Hex.
*/
type Size = 'md' | 'lg' | 'hero';
type As = 'div' | 'article' | 'a' | 'button';
interface Props {
size?: Size;
as?: As;
href?: string;
onclick?: (e: MouseEvent) => void;
colorAccent?: string | null;
ariaLabel?: string;
title?: string;
raised?: boolean;
flat?: boolean;
class?: string;
children?: Snippet;
}
let {
size = 'md',
as = 'div',
href,
onclick,
colorAccent = null,
ariaLabel,
title,
raised = false,
flat = false,
class: className = '',
children,
}: Props = $props();
</script>
{#if as === 'a' || href}
<a
class="surface size-{size} {className}"
class:raised
class:flat
{href}
aria-label={ariaLabel}
{title}
{onclick}
>
{#if colorAccent}
<span class="accent" style="background:{colorAccent}" aria-hidden="true"></span>
{/if}
{#if children}{@render children()}{/if}
</a>
{:else if as === 'button'}
<button
type="button"
class="surface size-{size} {className}"
class:raised
class:flat
aria-label={ariaLabel}
{title}
{onclick}
>
{#if colorAccent}
<span class="accent" style="background:{colorAccent}" aria-hidden="true"></span>
{/if}
{#if children}{@render children()}{/if}
</button>
{:else}
<svelte:element
this={as}
class="surface size-{size} {className}"
class:raised
class:flat
aria-label={ariaLabel}
{title}
>
{#if colorAccent}
<span class="accent" style="background:{colorAccent}" aria-hidden="true"></span>
{/if}
{#if children}{@render children()}{/if}
</svelte:element>
{/if}
<style>
.surface {
position: relative;
display: block;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.875rem;
color: hsl(var(--color-foreground));
font-family: inherit;
text-align: left;
text-decoration: none;
overflow: hidden;
box-shadow:
0 4px 12px hsl(var(--color-foreground) / 0.08),
0 1px 3px hsl(var(--color-foreground) / 0.05);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.15s ease;
}
/* button-/a-Variante als Karte ohne Default-Padding/-Margin */
button.surface,
a.surface {
padding: 0;
margin: 0;
font: inherit;
cursor: pointer;
}
/* Sizes — definieren nur Maße, keine internen Inhalte */
.size-md {
width: 100%;
max-width: 18rem;
aspect-ratio: 5 / 7;
}
.size-lg {
width: 12rem;
height: 16.8rem;
}
.size-hero {
width: 100%;
max-width: 24rem;
aspect-ratio: 5 / 7;
}
/* Raised → echte Top-Karte mit stärkerem Schatten (Hand-of-Cards-Hover, Study-Hero) */
.surface.raised {
box-shadow:
0 14px 28px hsl(var(--color-foreground) / 0.16),
0 4px 10px hsl(var(--color-foreground) / 0.08);
}
/* Flat → reine Background-Layer (versteckte Hintergrund-Karten im Stapel) */
.surface.flat {
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.06);
}
/* Color-Akzent links als Identitäts-Streifen */
.accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.375rem;
}
.surface:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 4px;
}
button.surface:hover,
a.surface:hover {
transform: translateY(-2px);
box-shadow:
0 12px 28px hsl(var(--color-foreground) / 0.16),
0 4px 10px hsl(var(--color-foreground) / 0.08);
}
@media (prefers-reduced-motion: reduce) {
.surface {
transition: none;
}
button.surface:hover,
a.surface:hover {
transform: none;
}
}
</style>