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; --sidebar: #f9f9f9;
--surface: #ffffff; --surface: #ffffff;
--surface-raised: #ffffff; --surface-raised: #ffffff;
--card-hover-bg: #fafafa;
--surface-sunken: rgb(13 13 13 / 0.05); --surface-sunken: rgb(13 13 13 / 0.05);
--hairline: #e5e5e5; --hairline: #e5e5e5;
--card-border: #e2e2e2;
--sidebar-divider: #e2e2e2;
--hairline-soft: rgb(13 13 13 / 0.07); --hairline-soft: rgb(13 13 13 / 0.07);
--glass-tint: rgb(255 255 255 / 0.6); --glass-tint: rgb(255 255 255 / 0.6);
--text: #0d0d0d; /* 17.6:1 on #ffffff */ --text: #0d0d0d; /* 17.6:1 on #ffffff */
@ -95,7 +98,7 @@
--hover: rgb(13 13 13 / 0.05); --hover: rgb(13 13 13 / 0.05);
--sidebar-top: #fbfbfc; --sidebar-top: #fbfbfc;
--sidebar-bottom: #f6f6f7; --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. */ /* API-key gate: near-white stage with soft blank glows, tinted input. */
--gate-base: #f6f6f8; --gate-base: #f6f6f8;
--gate-glow-a: rgb(84 102 130 / 0.22); --gate-glow-a: rgb(84 102 130 / 0.22);
@ -113,7 +116,8 @@
--install-chip: #f2f2f2; --install-chip: #f2f2f2;
--install-border: #e0e0e0; --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: 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 { .dark {
@ -122,16 +126,19 @@
--sidebar: #171717; --sidebar: #171717;
--surface: #2f2f2f; --surface: #2f2f2f;
--surface-raised: #383838; --surface-raised: #383838;
--card-hover-bg: #383838;
--surface-sunken: rgb(255 255 255 / 0.08); --surface-sunken: rgb(255 255 255 / 0.08);
--hairline: rgb(255 255 255 / 0.14); --hairline: rgb(255 255 255 / 0.14);
--card-border: var(--hairline);
--sidebar-divider: var(--hairline);
--hairline-soft: rgb(255 255 255 / 0.08); --hairline-soft: rgb(255 255 255 / 0.08);
--glass-tint: rgb(255 255 255 / 0.08); --glass-tint: rgb(255 255 255 / 0.08);
--text: #ececec; /* 12.9:1 on #212121 */ --text: #ececec; /* 12.9:1 on #212121 */
--text-muted: #b4b4b4; /* 7.6:1 */ --text-muted: #b4b4b4; /* 7.6:1 */
--text-subtle: #a0a0a0; /* 6.0:1 */ --text-subtle: #a0a0a0; /* 6.0:1 */
--hover: rgb(255 255 255 / 0.07); --hover: rgb(255 255 255 / 0.07);
--sidebar-top: #1a1a1a; --sidebar-top: #181818;
--sidebar-bottom: #151515; --sidebar-bottom: #141414;
--sidebar-edge: 0 1px 0 var(--hairline), 6px 0 16px -14px rgb(0 0 0 / 0.75); --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. */ /* API-key gate: near-black stage, monochrome glows, raised input. */
--gate-base: #0f0f0f; --gate-base: #0f0f0f;
@ -150,6 +157,7 @@
--install-chip: #3a3a3c; --install-chip: #3a3a3c;
--install-border: rgb(255 255 255 / 0.08); --install-border: rgb(255 255 255 / 0.08);
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.25); --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); --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 { .card {
border-radius: 1.125rem; /* 18px */ border-radius: 1.125rem; /* 18px */
background: var(--surface); background: var(--surface);
border: 1px solid var(--hairline); border: 1px solid var(--card-border);
box-shadow: var(--shadow-card); 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 { .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 { @media (hover: hover) and (pointer: fine) {
background: var(--surface-raised); .card-hover:hover {
border-color: color-mix(in oklab, var(--hairline) 55%, var(--text) 18%); background: var(--card-hover-bg);
box-shadow: var(--shadow-card-hover); 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 { .card-hover:active {
transform: translateY(0); transform: translateY(0);
@ -409,7 +423,7 @@ body {
/* Sidebar surface: a touch darker than the page, with a real edge. */ /* Sidebar surface: a touch darker than the page, with a real edge. */
.sidebar-surface { .sidebar-surface {
background: linear-gradient(180deg, var(--sidebar-top), var(--sidebar-bottom)); 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); box-shadow: var(--sidebar-edge);
} }
@ -488,7 +502,7 @@ body {
/* Difficulty: neutral pill, colour lives in the text only. */ /* Difficulty: neutral pill, colour lives in the text only. */
.diff-easy { color: #16714f; } .diff-easy { color: #16714f; }
.diff-medium { color: #8a5e00; } .diff-medium { color: #7a5300; }
.diff-hard { color: #b4342f; } .diff-hard { color: #b4342f; }
.dark .diff-easy { color: #6fd3a8; } .dark .diff-easy { color: #6fd3a8; }
.dark .diff-medium { color: #e3b341; } .dark .diff-medium { color: #e3b341; }

View file

@ -13,7 +13,7 @@ export default function HomePage() {
<MobileSidebar /> <MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <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 /> <LibraryScreen />
</div> </div>
<TabBar /> <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 { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { cn, formatDate, truncate } from "@/lib/utils"; 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"; import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate"; type Busy = null | "more" | "regenerate";
@ -109,11 +109,19 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
return ( return (
<li className="card card-hover group relative flex flex-col p-5"> <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 ------------------------------------------------------- */} {/* header ------------------------------------------------------- */}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{renaming ? ( {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}`}> <label className="sr-only" htmlFor={`rename-${deck.id}`}>
Deck title Deck title
</label> </label>
@ -144,16 +152,28 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
{truncate(deck.title, 90)} {truncate(deck.title, 90)}
</h3> </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">
{formatDate(deck.updatedAt)} {/* Phones keep the original date · activities · runs line. */}
<span aria-hidden> · </span> <span className="lg:hidden">
{formatDate(deck.updatedAt)}
<span aria-hidden> · </span>
</span>
{deck.activities.length} activities {deck.activities.length} activities
{deck.attempts > 0 ? ( <span className="lg:hidden">
<> {deck.attempts > 0 ? (
<span aria-hidden> · </span> <>
{deck.attempts} {deck.attempts === 1 ? "run" : "runs"} <span aria-hidden> · </span>
</> {deck.attempts} {deck.attempts === 1 ? "run" : "runs"}
) : null} </>
) : 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> </p>
</div> </div>
@ -163,11 +183,12 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
aria-expanded={menuOpen} aria-expanded={menuOpen}
onClick={() => setMenuOpen((value) => !value)} onClick={() => setMenuOpen((value) => !value)}
className={cn( 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)]", menuOpen && "bg-[var(--hover)] text-[var(--text)]",
)} )}
> >
⋯ <span aria-hidden className="lg:hidden">⋯</span>
<EllipsisIcon className="hidden size-4 lg:block" />
</button> </button>
</div> </div>
@ -184,9 +205,14 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
</Tag> </Tag>
) : null} ) : null}
{deck.bestScore !== undefined ? ( {deck.bestScore !== undefined ? (
<Tag> <>
Best <span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span> <Tag className="lg:hidden">
</Tag> 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} ) : null}
</div> </div>
@ -219,18 +245,24 @@ export function DeckCard({ deck, onMove }: { deck: Deck; onMove?: (folderId: str
) : null} ) : null}
{/* single action ------------------------------------------------ */} {/* single action ------------------------------------------------ */}
<div className="mt-5"> <div className="mt-5 lg:mt-4">
<Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm"> <Link href={`/deck/${deck.id}`} className="btn-primary h-10 w-full px-4 text-sm lg:hidden">
<PlayIcon className="size-4" /> <PlayIcon className="size-4" />
Study Study
</Link> </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> </div>
{/* actions panel ------------------------------------------------ */} {/* actions panel ------------------------------------------------ */}
{menuOpen ? ( {menuOpen ? (
<div <div
data-testid="deck-actions" 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)}> <button type="button" className={menuRow} onClick={() => setRenaming(true)} disabled={Boolean(busy)}>
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]"> <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. */ /** 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 ( 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} {children}
</span> </span>
); );

View file

@ -98,6 +98,9 @@ export function LibraryScreen() {
}; };
const isEmpty = sorted.length === 0 && folderList.length === 0; 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 ( return (
<div className="relative"> <div className="relative">
@ -114,10 +117,23 @@ export function LibraryScreen() {
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <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" /> <UploadIcon className="size-4" />
Import JSON Import JSON
</Button> </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 <input
ref={importRef} ref={importRef}
type="file" type="file"
@ -211,7 +227,7 @@ export function LibraryScreen() {
return ( return (
<section key={group.id} aria-label={group.name}> <section key={group.id} aria-label={group.name}>
{/* group header -------------------------------------- */} {/* 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)]" /> <FolderIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
{isEditing && editingFolder ? ( {isEditing && editingFolder ? (
@ -288,6 +304,13 @@ export function LibraryScreen() {
) : null} ) : null}
</div> </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 ? ( {confirmDelete === group.id ? (
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]"> <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 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."} : "Empty folder — freeze a deck into it or move one over from Unorganized."}
</div> </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) => ( {group.decks.map((deck) => (
<DeckCard <DeckCard
key={deck.id} key={deck.id}

View file

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