Desktop library pass: alignment, card weight, sidebar and theme

Layout (desktop / lg+ only):
- 40px top padding above the page title.
- Deck grid uses minmax(320px, 1fr), so two cards fill the content width
  and share a right edge with the header (same max-width container).
- Group headings only render once real folders exist; with none, a single
  quiet "All decks" label replaces "Unorganized".
- Import JSON becomes a plain text button (mobile keeps the pill).

Deck cards (desktop):
- The whole card is a link; Study is a compact secondary button instead
  of a full-width solid pill.
- The ... control is a 32px ghost button with a real ellipsis icon,
  aligned to the title's centre.
- Meta line always shows the same fields (activities · runs / Not played
  yet); the score is a separate quiet "Best 53%" stat.
- 12px rhythm between title/meta/tags/summary, 16px before the actions.
- Hover: lighter surface, stronger border, soft shadow and a 2px lift in
  light mode (pointer devices only, so touch has no sticky hover).

Sidebar (desktop rail):
- All decks + Unorganized are views above the FOLDERS label; only
  user-created folders sit under it.
- Count only on All decks. The mobile drawer is unchanged.

Theme:
- Dark: sidebar is a step darker (#181818 to #141414 vs #212121 page).
- Light: cards get a slightly stronger border (#e2e2e2) and a soft
  shadow; the sidebar divider and edge shadow are stronger too.
- Medium difficulty darkened to #7a5300 (6.2:1 on the light chip).

Mobile is untouched: layout changes are behind lg: (or the desktop prop);
theme tweaks are shared tokens.

Verified: key-gate 49/49, install sheet 64/64, retry 14/14, zoom 6/6,
deck chrome 15/15, deck fixes 15/15, final ux 12/12, contrast 20/20,
security 8/8, pwa 16/16.
This commit is contained in:
Martin Dvořák 2026-10-07 22:37:29 +00:00
commit 3aae6f3199
5 changed files with 196 additions and 67 deletions

View file

@ -9,7 +9,7 @@ import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folde
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, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import { DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate";
@ -109,11 +109,19 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
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.125rem] lg:block"
/>
{/* header ------------------------------------------------------- */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-2">
<div className="relative z-10 flex items-center gap-2">
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
Deck title
</label>
@ -144,16 +152,28 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{truncate(deck.title, 90)}
</h3>
)}
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)]">
{formatDate(deck.updatedAt)}
<span aria-hidden> · </span>
<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
{deck.attempts > 0 ? (
<>
<span aria-hidden> · </span>
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
</>
) : null}
<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>
@ -163,11 +183,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
aria-expanded={menuOpen}
onClick={() => setMenuOpen((value) => !value)}
className={cn(
"grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]",
"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>
@ -184,9 +205,14 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
</Tag>
) : null}
{deck.bestScore !== undefined ? (
<Tag>
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
</Tag>
<>
<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>
@ -219,18 +245,24 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
) : null}
{/* single action ------------------------------------------------ */}
<div className="mt-5">
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm">
<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 mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
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)]">
@ -398,9 +430,14 @@ function difficultyClass(difficulty: Deck["difficulty"]): string {
}
/** Neutral pill; colour is only ever used on the text inside it. */
function Tag({ children }: { children: React.ReactNode }) {
function Tag({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span className="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)]">
<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>
);