From 3aae6f3199218f2cdf8b0e36c01cfa590d18c152 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 7 Oct 2026 22:37:29 +0000 Subject: [PATCH] Desktop library pass: alignment, card weight, sidebar and theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/globals.css | 42 ++++++++----- app/page.tsx | 2 +- components/DeckCard.tsx | 79 ++++++++++++++++++------- components/LibraryScreen.tsx | 29 ++++++++- components/Sidebar.tsx | 111 ++++++++++++++++++++++++++--------- 5 files changed, 196 insertions(+), 67 deletions(-) diff --git a/app/globals.css b/app/globals.css index 278bf01..2ff3899 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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,18 +324,24 @@ 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; } -.card-hover:hover { - background: var(--surface-raised); - border-color: color-mix(in oklab, var(--hairline) 55%, var(--text) 18%); - box-shadow: var(--shadow-card-hover); +@media (hover: hover) and (pointer: fine) { + .card-hover:hover { + 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; } diff --git a/app/page.tsx b/app/page.tsx index f65dbb3..47eae75 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,7 +13,7 @@ export default function HomePage() {
-
+
diff --git a/components/DeckCard.tsx b/components/DeckCard.tsx index f00e7bc..ca9f85e 100644 --- a/components/DeckCard.tsx +++ b/components/DeckCard.tsx @@ -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 (
  • + {/* Desktop: the whole card is a link; interactive children sit above it. */} + + {/* header ------------------------------------------------------- */}
    {renaming ? ( -
    +
    @@ -144,16 +152,28 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str {truncate(deck.title, 90)} )} -

    - {formatDate(deck.updatedAt)} - · +

    + {/* Phones keep the original date · activities · runs line. */} + + {formatDate(deck.updatedAt)} + · + {deck.activities.length} activities - {deck.attempts > 0 ? ( - <> - · - {deck.attempts} {deck.attempts === 1 ? "run" : "runs"} - - ) : null} + + {deck.attempts > 0 ? ( + <> + · + {deck.attempts} {deck.attempts === 1 ? "run" : "runs"} + + ) : null} + + {/* Desktop always shows the same two fields. */} + + · + {deck.attempts > 0 + ? `${deck.attempts} ${deck.attempts === 1 ? "run" : "runs"}` + : "Not played yet"} +

    @@ -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)]", )} > - ⋯ + ⋯ +
    @@ -184,9 +205,14 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str ) : null} {deck.bestScore !== undefined ? ( - - Best {deck.bestScore}% - + <> + + Best {deck.bestScore}% + + + Best {deck.bestScore}% + + ) : null}
    @@ -219,18 +245,24 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str ) : null} {/* single action ------------------------------------------------ */} -
    - +
    + Study +
    + + + Study + +
    {/* actions panel ------------------------------------------------ */} {menuOpen ? (
    + {/* group header -------------------------------------- */} -
    +
    {isEditing && editingFolder ? ( @@ -288,6 +304,13 @@ export function LibraryScreen() { ) : null}
    + {/* One quiet label replaces the redundant single-group heading. */} + {!hasFolders ? ( +

    + All decks +

    + ) : null} + {confirmDelete === group.id ? (

    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."}

    ) : ( -
      +
        {group.decks.map((deck) => ( 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 ? {value} : null; + /** One shared view row (All decks / Unorganized) for both nav layouts. */ + const viewRow = (id: string, label: string, icon: ReactNode, trailing: ReactNode) => ( + + ); + return (
        {/* brand ---------------------------------------------------------- */} @@ -122,7 +145,8 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on {/* folders --------------------------------------------------------- */} - {!collapsed ? ( + {/* Mobile keeps the original layout: one Folders label above the list. */} + {!desktop && !collapsed ? (
        Folders @@ -139,7 +163,10 @@ function NavContent({ collapsed = false, onNavigate }: { collapsed?: boolean; on
        ) : null} -