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

@ -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),
)}
</>
)}
<button
type="button"
onClick={() => select(UNORGANIZED_ID)}
aria-pressed={selected === UNORGANIZED_ID}
title="Unorganized"
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">Unorganized</span> : null}
{count(counts[UNORGANIZED_ID] ?? 0)}
</button>
{/* 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={() => 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)]"
>
<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>
);
}