Keep the library header steady across tabs; simplify the empty states

Header & action bar
- Import JSON no longer disappears on the Homework tab: it sits directly
  under the "Your icebox" header/subtitle, above the tab switcher, on every
  tab — so switching pools no longer shifts the layout or hides actions. It
  is one secondary button at all sizes now, not a quiet link when empty.
- The decks-only "Homework Solver" header action is gone; the homework tab's
  empty-state CTA and the drawer cover starting a new solve.

Empty states
- Dropped the "Using your own Ollama models — check the connection in
  Settings." line (and its two dictionary keys) everywhere.
- Both tabs share one structure: ice icon -> heading -> short description ->
  primary CTA button, starting at the same height:
    Study Decks: "No decks yet" -> "Freeze a page of notes into flashcards
    and quizzes." -> "Create study deck"
    Homework: "No homework yet" -> "Solved assignments and step-by-step
    guides show up here." -> "Solve homework"
- CTA labels read as actions on the standardized nouns; removed the now
  unused actionStudyDeck/actionHomework keys.

Tabs
- Link segments are 44px tall at every breakpoint (min-height in the base
  .segmented > a rule).

Service worker: freezmo-v11.

Verified: library-consistency suite 49/49 (Import visible with a fixed
position on both tabs, no Ollama text, empty states at the same y, primary
ice CTA buttons >=44px, deep links, deletion, redirects, desktop, 360px, axe
en+cs light+dark), mobile 51/51, i18n 27/27, desktop regression 74/74.
Lint + build clean.
This commit is contained in:
Martin Dvořák 2026-10-08 21:50:18 +00:00
commit 9d6ef90f02
4 changed files with 55 additions and 139 deletions

View file

@ -499,6 +499,7 @@ body {
align-items: center;
justify-content: center;
gap: 0.375rem;
min-height: 2.75rem;
border-radius: 0.75rem;
padding: 0.42rem 0.5rem;
font-size: 0.875rem;
@ -727,8 +728,7 @@ body {
@media (max-width: 639px) {
.touch-target { min-height: 44px; min-width: 44px; }
.nav-row { min-height: 44px; }
.segmented > button,
.segmented > a { min-height: 44px; }
.segmented > button { min-height: 44px; }
.folder-pill { min-height: 44px; }
}

View file

@ -6,13 +6,12 @@ import { useMemo, useRef, useState, useSyncExternalStore } from "react";
import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { FolderIcon, SnowflakeIcon, SparklesIcon, UploadIcon } from "@/components/icons";
import { FolderIcon, UploadIcon } from "@/components/icons";
import { Button, EmptyState } from "@/components/ui";
import { folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { useI18n } from "@/lib/i18n";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { openSettings } from "@/lib/ui-events";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
import { cn } from "@/lib/utils";
@ -118,89 +117,47 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<div className="relative">
{/* page header ---------------------------------------------------- */}
<section className="mb-6 space-y-5">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
{t("library.title")}
</h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
{t("library.subtitle")}
</p>
</div>
<div>
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
{t("library.title")}
</h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
{t("library.subtitle")}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Context action: Import for decks (a quiet link while the pool
is empty), start-a-new-solve for homework. */}
{tab === "decks" ? (
isEmpty ? (
<button
type="button"
onClick={() => importRef.current?.click()}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] hover:underline"
>
<UploadIcon className="size-4" />
{t("library.import")}
</button>
) : (
<>
<Button
variant="secondary"
size="md"
className="touch-target lg:hidden"
onClick={() => importRef.current?.click()}
>
<UploadIcon className="size-4" />
{t("library.import")}
</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" />
{t("library.import")}
</button>
</>
)
) : homework.length > 0 ? (
<>
<Link
href="/homework?mode=homework"
className="btn-secondary touch-target h-10 px-4 text-sm lg:hidden"
>
<SparklesIcon className="size-4" />
{t("actionHomework")}
</Link>
<Link
href="/homework?mode=homework"
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"
>
<SparklesIcon className="size-4" />
{t("actionHomework")}
</Link>
</>
) : null}
<input
ref={importRef}
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(event) => {
void handleImport(event.target.files?.[0]);
event.target.value = "";
}}
/>
</div>
{/* Import sits above the tabs on every tab, so the header never
shifts when you switch pools. */}
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="md"
className="touch-target"
onClick={() => importRef.current?.click()}
>
<UploadIcon className="size-4" />
{t("library.import")}
</Button>
<input
ref={importRef}
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(event) => {
void handleImport(event.target.files?.[0]);
event.target.value = "";
}}
/>
</div>
{/* Two pools, never mixed: study decks and saved homework. */}
<div className="segmented w-full sm:max-w-sm">
<Link href="/" aria-current={tab === "decks" ? "page" : undefined}>
{t("tabDecks")}
</Link>
<Link href="/?tab=homework" aria-current={tab === "homework" ? "page" : undefined}>
{t("tabHomework")}
</Link>
</div>
{/* Two pools, never mixed: study decks and saved homework. */}
<div className="segmented w-full sm:max-w-sm">
<Link href="/" aria-current={tab === "decks" ? "page" : undefined}>
{t("tabDecks")}
</Link>
<Link href="/?tab=homework" aria-current={tab === "homework" ? "page" : undefined}>
{t("tabHomework")}
</Link>
</div>
{/* Secondary stats folded into the header as quiet text on desktop,
@ -263,34 +220,12 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<EmptyState
title={t("library.noDecks")}
action={
<Link
href="/create?mode=deck"
className="card card-hover touch-target flex w-[min(24rem,calc(100vw_-_3rem))] items-center gap-3 border-accent-400/60 p-3.5 text-left sm:p-4"
>
<span
aria-hidden
className="grad-ice grid size-11 shrink-0 place-items-center rounded-2xl text-[#06111f]"
>
<SnowflakeIcon className="size-5" />
</span>
<span className="min-w-0 text-[14px] font-semibold leading-snug text-[var(--text)]">
{t("emptyStateCreateDeck")}
</span>
<Link href="/create?mode=deck" className="btn-primary touch-target h-11 px-5 text-sm">
{t("emptyStateCreateDeck")}
</Link>
}
>
{t("library.emptyBody")}
<span className="mt-3 block text-[12.5px] text-[var(--text-subtle)]">
{t("library.emptyHint")}{" "}
<button
type="button"
onClick={openSettings}
className="font-medium text-[var(--text-muted)] underline underline-offset-2 hover:text-[var(--text)]"
>
{t("library.emptySettings")}
</button>
.
</span>
</EmptyState>
) : null}
@ -424,19 +359,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
<EmptyState
title={t("homework.emptyTitle")}
action={
<Link
href="/homework?mode=homework"
className="card card-hover touch-target flex w-[min(24rem,calc(100vw_-_3rem))] items-center gap-3 border-accent-400/60 p-3.5 text-left sm:p-4"
>
<span
aria-hidden
className="grad-ice grid size-11 shrink-0 place-items-center rounded-2xl text-[#06111f]"
>
<SparklesIcon className="size-5" />
</span>
<span className="min-w-0 text-[14px] font-semibold leading-snug text-[var(--text)]">
{t("emptyStateHomework")}
</span>
<Link href="/homework?mode=homework" className="btn-primary touch-target h-11 px-5 text-sm">
{t("emptyStateHomework")}
</Link>
}
>

View file

@ -223,7 +223,7 @@ const en: Record<string, string> = {
"homework.history.openTitle": "Open “{title}”",
"homework.history.deleteConfirm": "Delete this result?",
"homework.emptyTitle": "No homework yet",
"homework.emptyBody": "Solved assignments and step-by-step guides you save show up here.",
"homework.emptyBody": "Solved assignments and step-by-step guides show up here.",
"homework.open": "Open",
"homework.steps.one": "{n} step",
"homework.steps.few": "{n} steps",
@ -234,10 +234,8 @@ const en: Record<string, string> = {
"tabHomework": "Homework",
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Study Deck",
"emptyStateHomework": "Homework Solver",
"actionStudyDeck": "Study Deck",
"actionHomework": "Homework Solver",
"emptyStateCreateDeck": "Create study deck",
"emptyStateHomework": "Solve homework",
/* library --------------------------------------------------------- */
"library.title": "Your icebox",
@ -250,9 +248,7 @@ const en: Record<string, string> = {
"library.deckMoved": "Deck moved.",
"library.deckMovedUnorganized": "Deck moved to Unorganized.",
"library.noDecks": "No decks yet",
"library.emptyBody": "Freeze a page of notes into a feed of flashcards and quizzes.",
"library.emptyHint": "Using your own Ollama models —",
"library.emptySettings": "check the connection in Settings",
"library.emptyBody": "Freeze a page of notes into flashcards and quizzes.",
"library.renameFolderAria": "Folder name",
"library.nothingUnfiled": "Nothing unfiled right now.",
"library.emptyFolder": "Empty folder — freeze a deck into it or move one over from Unorganized.",
@ -817,7 +813,7 @@ const cs: Record<string, string> = {
"homework.history.openTitle": "Otevřít „{title}“",
"homework.history.deleteConfirm": "Smazat tento výsledek?",
"homework.emptyTitle": "Zatím žádné úkoly",
"homework.emptyBody": "Uložená řešení úkolů a návody krok za krokem se objeví tady.",
"homework.emptyBody": "Vyřešená zadání a návody krok za krokem se objeví tady.",
"homework.open": "Otevřít",
"homework.steps.one": "{n} krok",
"homework.steps.few": "{n} kroky",
@ -828,10 +824,8 @@ const cs: Record<string, string> = {
"tabHomework": "Úkoly",
/* quick actions (library empty state + /create switcher) ----------- */
"emptyStateCreateDeck": "Studijní balíček",
"emptyStateHomework": "Řešitel úkolů",
"actionStudyDeck": "Studijní balíček",
"actionHomework": "Řešitel úkolů",
"emptyStateCreateDeck": "Vytvořit studijní balíček",
"emptyStateHomework": "Vyřešit úkol",
/* library --------------------------------------------------------- */
"library.title": "Tvoje mraznička",
@ -844,9 +838,7 @@ const cs: Record<string, string> = {
"library.deckMoved": "Sada přesunuta.",
"library.deckMovedUnorganized": "Sada přesunuta do Nezařazených.",
"library.noDecks": "Zatím žádné sady",
"library.emptyBody": "Zmraz stránku poznámek do feedu kartiček a kvízů.",
"library.emptyHint": "S tvými vlastními modely Ollama —",
"library.emptySettings": "zkontroluj připojení v Nastavení",
"library.emptyBody": "Zmraz stránku poznámek do kartiček a kvízů.",
"library.renameFolderAria": "Název složky",
"library.nothingUnfiled": "Teď tu nic nezařazeného není.",
"library.emptyFolder": "Prázdná složka — zmraz do ní sadu nebo ji sem přesuň z Nezařazených.",

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v10";
const CACHE_VERSION = "freezmo-v11";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;