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

@ -85,8 +85,11 @@
--sidebar: #f9f9f9;
--surface: #ffffff;
--surface-raised: #ffffff;
--card-hover-bg: #fafafa;
--surface-sunken: rgb(13 13 13 / 0.05);
--hairline: #e5e5e5;
--card-border: #e2e2e2;
--sidebar-divider: #e2e2e2;
--hairline-soft: rgb(13 13 13 / 0.07);
--glass-tint: rgb(255 255 255 / 0.6);
--text: #0d0d0d; /* 17.6:1 on #ffffff */
@ -95,7 +98,7 @@
--hover: rgb(13 13 13 / 0.05);
--sidebar-top: #fbfbfc;
--sidebar-bottom: #f6f6f7;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(13 13 13 / 0.28);
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 18px -12px rgb(13 13 13 / 0.38);
/* API-key gate: near-white stage with soft blank glows, tinted input. */
--gate-base: #f6f6f8;
--gate-glow-a: rgb(84 102 130 / 0.22);
@ -113,7 +116,8 @@
--install-chip: #f2f2f2;
--install-border: #e0e0e0;
--shadow-card: 0 1px 2px rgb(13 13 13 / 0.06), 0 1px 3px rgb(13 13 13 / 0.04);
--shadow-card-hover: 0 10px 28px -14px rgb(13 13 13 / 0.22), 0 1px 2px rgb(13 13 13 / 0.06);
--card-shadow: 0 1px 2px rgb(13 13 13 / 0.05), 0 5px 14px -6px rgb(13 13 13 / 0.12);
--shadow-card-hover: 0 12px 30px -12px rgb(13 13 13 / 0.2), 0 2px 6px rgb(13 13 13 / 0.07);
}
.dark {
@ -122,16 +126,19 @@
--sidebar: #171717;
--surface: #2f2f2f;
--surface-raised: #383838;
--card-hover-bg: #383838;
--surface-sunken: rgb(255 255 255 / 0.08);
--hairline: rgb(255 255 255 / 0.14);
--card-border: var(--hairline);
--sidebar-divider: var(--hairline);
--hairline-soft: rgb(255 255 255 / 0.08);
--glass-tint: rgb(255 255 255 / 0.08);
--text: #ececec; /* 12.9:1 on #212121 */
--text-muted: #b4b4b4; /* 7.6:1 */
--text-subtle: #a0a0a0; /* 6.0:1 */
--hover: rgb(255 255 255 / 0.07);
--sidebar-top: #1a1a1a;
--sidebar-bottom: #151515;
--sidebar-top: #181818;
--sidebar-bottom: #141414;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75);
/* API-key gate: near-black stage, monochrome glows, raised input. */
--gate-base: #0f0f0f;
@ -150,6 +157,7 @@
--install-chip: #3a3a3c;
--install-border: rgb(255 255 255 / 0.08);
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25);
--card-shadow: var(--shadow-card);
--shadow-card-hover: 0 10px 28px -16px rgb(0 0 0 / 0.55), 0 1px 2px rgb(0 0 0 / 0.3);
}
@ -316,19 +324,25 @@ body {
.card {
border-radius: 1.125rem; /* 18px */
background: var(--surface);
border: 1px solid var(--hairline);
box-shadow: var(--shadow-card);
border: 1px solid var(--card-border);
box-shadow: var(--card-shadow);
}
/* Opt-in hover lift for interactive cards (deck cards) */
/* Opt-in hover lift for interactive cards (deck cards). Pointer devices only,
so touch screens never get sticky hover states. */
.card-hover {
transition: box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease;
transition: background-color 180ms ease, box-shadow 180ms ease, border-color 180ms ease, transform 180ms ease;
}
@media (hover: hover) and (pointer: fine) {
.card-hover:hover {
background: var(--surface-raised);
border-color: color-mix(in oklab, var(--hairline) 55%, var(--text) 18%);
background: var(--card-hover-bg);
border-color: color-mix(in oklab, var(--hairline) 45%, var(--text) 20%);
box-shadow: var(--shadow-card-hover);
}
:root:not(.dark) .card-hover:hover {
transform: translateY(-2px);
}
}
.card-hover:active {
transform: translateY(0);
}
@ -409,7 +423,7 @@ body {
/* Sidebar surface: a touch darker than the page, with a real edge. */
.sidebar-surface {
background: linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom));
border-right: 1px solid var(--hairline);
border-right: 1px solid var(--sidebar-divider);
box-shadow: var(--sidebar-edge);
}
@ -488,7 +502,7 @@ body {
/* Difficulty: neutral pill, colour lives in the text only. */
.diff-easy { color: #16714f; }
.diff-medium { color: #8a5e00; }
.diff-medium { color: #7a5300; }
.diff-hard { color: #b4342f; }
.dark .diff-easy { color: #6fd3a8; }
.dark .diff-medium { color: #e3b341; }

View file

@ -13,7 +13,7 @@ export default function HomePage() {
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16">
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<LibraryScreen />
</div>
<TabBar />

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)]">
<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>
@ -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>
<>
<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>
);

View file

@ -98,6 +98,9 @@ export function LibraryScreen() {
};
const isEmpty = sorted.length === 0 && folderList.length === 0;
// Group headings only earn their keep once real folders exist; with none,
// desktop shows a single quiet “All decks” label instead of “Unorganized”.
const hasFolders = folderList.length > 0;
return (
<div className="relative">
@ -114,10 +117,23 @@ export function LibraryScreen() {
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="md" onClick={() => importRef.current?.click()}>
<Button
variant="secondary"
size="md"
className="lg:hidden"
onClick={() => importRef.current?.click()}
>
<UploadIcon className="size-4" />
Import JSON
</Button>
<button
type="button"
onClick={() => importRef.current?.click()}
className="hidden items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline lg:inline-flex"
>
<UploadIcon className="size-4" />
Import JSON
</button>
<input
ref={importRef}
type="file"
@ -211,7 +227,7 @@ export function LibraryScreen() {
return (
<section key={group.id} aria-label={group.name}>
{/* group header -------------------------------------- */}
<div className="mb-4 flex items-center gap-2">
<div className={cn("mb-4 flex items-center gap-2", !hasFolders && "lg:hidden")}>
<FolderIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
{isEditing && editingFolder ? (
@ -288,6 +304,13 @@ export function LibraryScreen() {
) : null}
</div>
{/* One quiet label replaces the redundant single-group heading. */}
{!hasFolders ? (
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
All decks
</p>
) : null}
{confirmDelete === group.id ? (
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
The {group.decks.length} deck{group.decks.length === 1 ? "" : "s"} inside will move to
@ -302,7 +325,7 @@ export function LibraryScreen() {
: "Empty folder — freeze a deck into it or move one over from Unorganized."}
</div>
) : (
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
{group.decks.map((deck) => (
<DeckCard
key={deck.id}

View file

@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import { useMemo, useState, useSyncExternalStore } from "react";
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
@ -26,7 +26,15 @@ import { cn } from "@/lib/utils";
const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
/** Shared nav content for the desktop rail and the mobile drawer. */
function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; onNavigate?: () => void }) {
function NavContent({
collapsed = false,
onNavigate,
desktop = false,
}: {
collapsed?: boolean;
onNavigate?: () => void;
desktop?: boolean;
}) {
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore(
folderStore.subscribe,
@ -76,6 +84,21 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
const count = (value: number) =>
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null;
/** One shared view row (All decks / Unorganized) for both nav layouts. */
const viewRow = (id: string, label: string, icon: ReactNode, trailing: ReactNode) => (
<button
type="button"
onClick={() => select(id)}
aria-pressed={selected === id}
title={label}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
{icon}
{!collapsed ? <span className="flex-1 truncate text-left">{label}</span> : null}
{trailing}
</button>
);
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* brand ---------------------------------------------------------- */}
@ -122,7 +145,8 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
</Link>
{/* folders --------------------------------------------------------- */}
{!collapsed ? (
{/* Mobile keeps the original layout: one Folders label above the list. */}
{!desktop && !collapsed ? (
<div className="flex items-center justify-between px-2 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
Folders
@ -139,7 +163,10 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
</div>
) : null}
<nav aria-label="Folders" className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
<nav
aria-label={desktop ? "Library" : "Folders"}
className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto"
>
{collapsed ? (
<button
type="button"
@ -152,29 +179,57 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
</button>
) : null}
<button
type="button"
onClick={() => select(ALL_FOLDERS)}
aria-pressed={selected === ALL_FOLDERS}
title="All decks"
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<LayersIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">All decks</span> : null}
{count(decks.length)}
</button>
{/* Desktop: All decks + Unorganized are views, so they sit above the
Folders label; mobile keeps them inside the folder list. */}
{desktop ? (
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(
ALL_FOLDERS,
"All decks",
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
"Unorganized",
<FolderIcon className="size-4 shrink-0 opacity-70" />,
null,
)}
</div>
) : (
<>
{viewRow(
ALL_FOLDERS,
"All decks",
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
"Unorganized",
<FolderIcon className="size-4 shrink-0 opacity-70" />,
count(counts[UNORGANIZED_ID] ?? 0),
)}
</>
)}
{/* Desktop: the Folders label sits between the views and the folders. */}
{desktop && !collapsed ? (
<div className="flex items-center justify-between px-2 pb-1 pt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
Folders
</span>
<button
type="button"
onClick={() => select(UNORGANIZED_ID)}
aria-pressed={selected === UNORGANIZED_ID}
title="Unorganized"
className={cn("nav-row", collapsed && "justify-center px-2")}
onClick={() => setCreating(true)}
aria-label="New folder"
title="New folder"
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">Unorganized</span> : null}
{count(counts[UNORGANIZED_ID] ?? 0)}
<PlusIcon className="size-3.5" />
</button>
</div>
) : null}
{sortedFolders.map((folder) => (
<button
@ -187,7 +242,7 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
{count(counts[folder.id] ?? 0)}
{desktop ? null : count(counts[folder.id] ?? 0)}
</button>
))}
@ -311,7 +366,7 @@ export function Sidebar() {
isCollapsed ? "w-[68px]" : "w-[260px]",
)}
>
<NavContent collapsed={isCollapsed} />
<NavContent collapsed={isCollapsed} desktop />
</aside>
);
}