freezmo/components/DeckCard.tsx
Martin Dvorak 52cce42cde Frosted ice glass restyle
Rebuilds the look around the spec: an ice scene, one shared glass
recipe, icy accents and glass on every large surface. Visual only —
layout, behaviour, copy and functionality are untouched.

Background
- .ice-scene: fixed, z-index -1, pointer-events none. Navy base
  (#070b16 to #0c1426) in dark, pale (#f4f9ff to #e6f1fb) in light,
  four drifting glacier blobs (#38bdf8/#60a5fa/#818cf8, 30-60s
  transform-only loops), five glossy ice orbs (40-220px radial
  gradients with a highlight at 30% 25% and a blue shadow) placed so the
  glass blurs them, and an SVG feTurbulence grain at 4-5%.
- prefers-reduced-motion stops the drift; phones get two blobs and two
  orbs fewer.

Glass
- One shared recipe (.glass-panel, .card, .sidebar-surface,
  .study-chrome, .tabbar, .glass, .gate-card): rgba(255,255,255,.06)
  dark / .55 light, blur(24px) saturate(140%) (16px on phones), a bright
  1px rim, an inset top highlight and a blue-tinted outer shadow.
- Nested glass (chips, inputs, secondary buttons) is more opaque and
  never adds a second blur layer. @supports fallback for no
  backdrop-filter. Radii: 24-28px cards/sheets, 14px inputs/buttons,
  pills for chips. Cards hover brighter + cyan rim + 2px lift, 180ms.

Colour and type
- Accent is glacier cyan (#38bdf8) to ice blue (#0ea5e9); progress bars
  and selected states use the gradient; the active sidebar item is a
  frosted highlight with a cyan rim.
- Primary CTA: icy gradient — white to #bfe3ff with ink text in dark,
  deep blue in light (biased so the label sits on the darker half and
  keeps 5.6:1).
- Leftover slate/black greys replaced with navy-tinted frost tokens.
- Headings are semibold with tighter tracking.

Screens
- Dashboard: the sidebar is one tall inset rounded glass panel; deck
  cards are glass.
- Gate: one centred glass card over the scene, orbs visible behind it.
- Sheets/modals: glass over the dimmed, lightly blurred backdrop.
- Study view: question card more opaque (.glass-solid); top bar and
  pager are slim glass pills.

Perf: measured on a phone viewport with 4x CPU throttling. The animated
blobs' filter: blur() was the whole cost (150ms vs 33ms per frame), so
they are soft radial gradients with no filter now; idle 60fps, ~17ms
median while scrolling.

Tests updated for the new tokens: contrast 18/18 AA, deck chrome 15/15,
key gate 49/49, install sheet 64/64, deck fixes 15/15, retry 14/14,
zoom 6/6, final ux 12/12, security 8/8, pwa 16/16.
2026-10-08 10:11:55 +00:00

444 lines
17 KiB
TypeScript

"use client";
import Link from "next/link";
import { useState } from "react";
import { ApiError, generateDeck } from "@/lib/client-api";
import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { cn, formatDate, truncate } from "@/lib/utils";
import { DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate";
const ADD_OPTIONS = [3, 5, 8];
/**
* One deck in the dashboard grid: title, muted metadata, tags, summary and a
* prominent Study CTA with quiet secondary actions.
*/
export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) {
const folders = listFolders();
const [busy, setBusy] = useState<Busy>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [title, setTitle] = useState(deck.title);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [addCount, setAddCount] = useState(5);
const [newFolder, setNewFolder] = useState<string | null>(null);
const hasSource = deck.sourceText.trim().length > 20;
const saveTitle = () => {
const trimmed = title.trim();
if (trimmed && trimmed !== deck.title) renameDeck(deck.id, trimmed);
else setTitle(deck.title);
setRenaming(false);
setMenuOpen(false);
};
const addMore = async () => {
if (!hasSource) {
setError("This deck has no stored source notes, so it can't be extended.");
return;
}
setBusy("more");
setError(null);
try {
const { deck: generated } = await generateDeck({
text: deck.sourceText,
difficulty: deck.difficulty,
language: deck.language,
count: addCount,
mode: "more",
existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length },
});
appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty));
setNotice(`Added ${generated.activities.length} activities`);
setMenuOpen(false);
window.setTimeout(() => setNotice(null), 3000);
} catch (addError) {
setError(addError instanceof ApiError ? addError.message : "Could not add more activities.");
} finally {
setBusy(null);
}
};
const regenerate = async () => {
if (!hasSource) {
setError("This deck has no stored source notes, so it can't be regenerated.");
return;
}
setBusy("regenerate");
setError(null);
try {
const { deck: generated } = await generateDeck({
text: deck.sourceText,
difficulty: deck.difficulty,
language: deck.language,
count: Math.max(8, deck.activities.length),
});
upsertDeck({
...deck,
title: generated.title || deck.title,
subject: generated.subject ?? deck.subject,
summary: generated.summary ?? deck.summary,
language: generated.language ?? deck.language,
activities: materialiseActivities(generated.activities, deck.difficulty),
attempts: 0,
bestScore: undefined,
updatedAt: Date.now(),
});
setNotice("Deck regenerated");
setMenuOpen(false);
window.setTimeout(() => setNotice(null), 3000);
} catch (regenerateError) {
setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck.");
} finally {
setBusy(null);
}
};
const menuRow =
"flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[13px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]";
return (
<li className="card card-hover group relative flex flex-col p-5">
{/* Desktop: the whole card is a link; interactive children sit above it. */}
<Link
href={`/deck/${deck.id}`}
aria-hidden="true"
tabIndex={-1}
className="absolute inset-0 hidden rounded-[1.5rem] lg:block"
/>
{/* header ------------------------------------------------------- */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{renaming ? (
<div className="relative z-10 flex items-center gap-2">
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
Deck title
</label>
<input
id={`rename-${deck.id}`}
value={title}
autoFocus
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") saveTitle();
if (event.key === "Escape") {
setTitle(deck.title);
setRenaming(false);
}
}}
className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
/>
<Button size="sm" onClick={saveTitle}>
Save
</Button>
</div>
) : (
<h3
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
title={deck.title}
>
{truncate(deck.title, 90)}
</h3>
)}
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] lg:mt-3">
{/* Phones keep the original date · activities · runs line. */}
<span className="lg:hidden">
{formatDate(deck.updatedAt)}
<span aria-hidden> · </span>
</span>
{deck.activities.length} activities
<span className="lg:hidden">
{deck.attempts > 0 ? (
<>
<span aria-hidden> · </span>
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
</>
) : null}
</span>
{/* Desktop always shows the same two fields. */}
<span className="hidden lg:inline">
<span aria-hidden> · </span>
{deck.attempts > 0
? `${deck.attempts} ${deck.attempts === 1 ? "run" : "runs"}`
: "Not played yet"}
</span>
</p>
</div>
<button
type="button"
aria-label="Deck actions"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((value) => !value)}
className={cn(
"relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:-mt-1.5",
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
)}
>
<span aria-hidden className="lg:hidden">⋯</span>
<EllipsisIcon className="hidden size-4 lg:block" />
</button>
</div>
{/* tags --------------------------------------------------------- */}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
<Tag>
<span className={difficultyClass(deck.difficulty)}>{deck.difficulty}</span>
</Tag>
{deck.folderId ? (
<Tag>
<FolderIcon className="size-3.5 opacity-70" />
{folderName(deck.folderId)}
</Tag>
) : null}
{deck.bestScore !== undefined ? (
<>
<Tag className="lg:hidden">
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
</Tag>
<span className="hidden text-[12px] font-medium tabular-nums text-[var(--text-muted)] lg:ml-1 lg:inline">
Best {deck.bestScore}%
</span>
</>
) : null}
</div>
{/* summary ------------------------------------------------------ */}
{deck.summary ? (
<p className="mt-3 line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)]">
{truncate(deck.summary, 160)}
</p>
) : null}
{/* status ------------------------------------------------------- */}
{busy ? (
<div className="mt-4 space-y-2">
<ProgressBar value={1} max={2} label={busy === "more" ? "Adding activities" : "Regenerating deck"} />
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
{busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}
</p>
</div>
) : null}
{notice ? (
<p className="mt-3 rounded-xl bg-emerald-500/10 px-3 py-2 text-[12.5px] font-medium text-emerald-700 dark:text-emerald-300">
{notice}
</p>
) : null}
{error ? (
<p className="mt-3 rounded-xl bg-rose-500/10 px-3 py-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
{error}
</p>
) : null}
{/* single action ------------------------------------------------ */}
<div className="mt-5 lg:mt-4">
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
<PlayIcon className="size-4" />
Study
</Link>
<div className="relative z-10 hidden lg:flex lg:justify-end">
<Link href={`/deck/${deck.id}`} className="btn-secondary h-9 px-4 text-[13px]">
<PlayIcon className="size-4" />
Study
</Link>
</div>
</div>
{/* actions panel ------------------------------------------------ */}
{menuOpen ? (
<div
data-testid="deck-actions"
className="animate-rise relative z-10 mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
>
<button type="button" className={menuRow} onClick={() => setRenaming(true)} disabled={Boolean(busy)}>
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
✎
</span>
Rename
</button>
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
Export JSON
</button>
<button
type="button"
className={menuRow}
onClick={() => void regenerate()}
disabled={Boolean(busy) || !hasSource}
>
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
Regenerate deck
</button>
{confirmDelete ? (
<div className="flex items-center gap-2 px-3 py-2">
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
Really delete
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
) : (
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}>
<span aria-hidden className="w-4 text-center">
⌫
</span>
Delete deck
</button>
)}
{/* folder ---------------------------------------------------- */}
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Folder
</p>
<div className="flex flex-wrap items-center gap-1.5">
{[{ id: UNORGANIZED_ID, name: "Unorganized" }, ...folders].map((folder) => {
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
return (
<button
key={folder.id}
type="button"
aria-pressed={current}
onClick={() => {
onMove?.(folder.id === UNORGANIZED_ID ? undefined : folder.id);
setMenuOpen(false);
}}
className={cn(
"rounded-full px-2.5 py-1 text-[12px] font-medium transition",
current
? "grad-ice text-[#06111f]"
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
)}
>
{folder.name}
</button>
);
})}
{newFolder === null ? (
<button
type="button"
onClick={() => setNewFolder("")}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
+ New folder
</button>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
<input
autoFocus
value={newFolder}
onChange={(event) => setNewFolder(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Escape") setNewFolder(null);
if (event.key === "Enter" && newFolder.trim()) {
const created = makeFolder(newFolder);
if ("folder" in created) {
onMove?.(created.folder.id);
setNewFolder(null);
setMenuOpen(false);
} else {
setError(created.error);
}
}
}}
aria-label="New folder name"
placeholder="Folder name"
maxLength={60}
className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]"
/>
<button
type="button"
className="btn-primary h-6 px-2.5 text-[11px]"
onClick={() => {
const created = makeFolder(newFolder);
if ("folder" in created) {
onMove?.(created.folder.id);
setNewFolder(null);
setMenuOpen(false);
} else {
setError(created.error);
}
}}
>
Add
</button>
</span>
)}
</div>
</div>
{/* add more -------------------------------------------------- */}
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
Add more activities
</p>
<div className="flex flex-wrap items-center gap-1.5">
{ADD_OPTIONS.map((option) => (
<button
key={option}
type="button"
onClick={() => setAddCount(option)}
aria-pressed={addCount === option}
className={cn(
"min-w-9 rounded-full px-2.5 py-1 text-[12px] font-medium transition",
addCount === option
? "grad-ice text-[#06111f]"
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
)}
>
+{option}
</button>
))}
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
Generate
</Button>
</div>
{!hasSource ? (
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
No stored source text for this deck (imported decks don&apos;t carry it).
</p>
) : null}
</div>
</div>
) : null}
</li>
);
}
function difficultyClass(difficulty: Deck["difficulty"]): string {
if (difficulty === "easy") return "diff-easy";
if (difficulty === "hard") return "diff-hard";
return "diff-medium";
}
/** Neutral pill; colour is only ever used on the text inside it. */
function Tag({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-[12px] font-medium leading-none text-[var(--text-muted)]",
className,
)}
>
{children}
</span>
);
}