From 65c0adfd769b9ef11b897c96a3634c61120055b8 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 8 Dec 2025 00:18:03 +0900 Subject: feat(client): redesign frontend with TailwindCSS v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline styles with TailwindCSS, implementing a cohesive Japanese-inspired design system with custom colors (cream, teal primary), typography (Fraunces, DM Sans), and animations. Update all pages and components with consistent styling, improve accessibility by adding aria-hidden to decorative SVGs, and configure Biome for Tailwind CSS syntax support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/pages/DeckDetailPage.test.tsx | 68 +++-- src/client/pages/DeckDetailPage.tsx | 452 +++++++++++++++++++----------- src/client/pages/HomePage.test.tsx | 65 +++-- src/client/pages/HomePage.tsx | 252 ++++++++++++----- src/client/pages/LoginPage.test.tsx | 15 +- src/client/pages/LoginPage.tsx | 139 +++++++--- src/client/pages/NotFoundPage.tsx | 50 +++- src/client/pages/StudyPage.test.tsx | 13 +- src/client/pages/StudyPage.tsx | 454 +++++++++++++++++-------------- 9 files changed, 961 insertions(+), 547 deletions(-) (limited to 'src/client/pages') diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index 0589073..e4ecade 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -151,7 +151,8 @@ describe("DeckDetailPage", () => { renderWithProviders(); - expect(screen.getByText("Loading...")).toBeDefined(); + // Loading state shows spinner (svg with animate-spin class) + expect(document.querySelector(".animate-spin")).toBeDefined(); }); it("displays empty state when no cards exist", async () => { @@ -168,9 +169,9 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("This deck has no cards yet.")).toBeDefined(); + expect(screen.getByText("No cards yet")).toBeDefined(); }); - expect(screen.getByText("Add cards to start studying!")).toBeDefined(); + expect(screen.getByText("Add cards to start studying")).toBeDefined(); }); it("displays list of cards", async () => { @@ -208,7 +209,7 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByRole("heading", { name: "Cards (2)" })).toBeDefined(); + expect(screen.getByText("(2)")).toBeDefined(); }); }); @@ -226,9 +227,9 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("State: New")).toBeDefined(); + expect(screen.getByText("New")).toBeDefined(); }); - expect(screen.getByText("State: Review")).toBeDefined(); + expect(screen.getByText("Review")).toBeDefined(); }); it("displays card stats (reps and lapses)", async () => { @@ -245,11 +246,10 @@ describe("DeckDetailPage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("Reviews: 0")).toBeDefined(); + expect(screen.getByText("0 reviews")).toBeDefined(); }); - expect(screen.getByText("Reviews: 5")).toBeDefined(); - expect(screen.getByText("Lapses: 0")).toBeDefined(); - expect(screen.getByText("Lapses: 1")).toBeDefined(); + expect(screen.getByText("5 reviews")).toBeDefined(); + expect(screen.getByText("1 lapses")).toBeDefined(); }); it("displays error on API failure for deck", async () => { @@ -391,7 +391,9 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); expect(deleteButtons.length).toBe(2); }); @@ -414,7 +416,9 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); @@ -445,7 +449,9 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); @@ -488,18 +494,20 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } - // Find the Delete button in the modal (not the card list) - const modalDeleteButtons = screen.getAllByRole("button", { - name: "Delete", - }); - const confirmDeleteButton = modalDeleteButtons.find((btn) => - btn.closest('[role="dialog"]'), + // Find the Delete button in the modal (using the button's text content) + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + // Find the button with "Delete" text (not "Cancel") + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), ); if (confirmDeleteButton) { await user.click(confirmDeleteButton); @@ -518,9 +526,7 @@ describe("DeckDetailPage", () => { // Verify card count updated await waitFor(() => { - expect( - screen.getByRole("heading", { name: "Cards (1)" }), - ).toBeDefined(); + expect(screen.getByText("(1)")).toBeDefined(); }); }); @@ -550,18 +556,20 @@ describe("DeckDetailPage", () => { expect(screen.getByText("Hello")).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete card", + }); const firstDeleteButton = deleteButtons[0]; if (firstDeleteButton) { await user.click(firstDeleteButton); } - // Find the Delete button in the modal - const modalDeleteButtons = screen.getAllByRole("button", { - name: "Delete", - }); - const confirmDeleteButton = modalDeleteButtons.find((btn) => - btn.closest('[role="dialog"]'), + // Find the Delete button in the modal (using the button's text content) + const dialog = screen.getByRole("dialog"); + const modalButtons = dialog.querySelectorAll("button"); + // Find the button with "Delete" text (not "Cancel") + const confirmDeleteButton = Array.from(modalButtons).find((btn) => + btn.textContent?.includes("Delete"), ); if (confirmDeleteButton) { await user.click(confirmDeleteButton); diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index 3d7ffb5..cb1e3fb 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -31,6 +31,13 @@ const CardStateLabels: Record = { 3: "Relearning", }; +const CardStateColors: Record = { + 0: "bg-info/10 text-info", + 1: "bg-warning/10 text-warning", + 2: "bg-success/10 text-success", + 3: "bg-error/10 text-error", +}; + export function DeckDetailPage() { const { deckId } = useParams<{ deckId: string }>(); const [deck, setDeck] = useState(null); @@ -114,195 +121,318 @@ export function DeckDetailPage() { if (!deckId) { return ( -
-

Invalid deck ID

- Back to decks +
+
+

Invalid deck ID

+ + Back to decks + +
); } return ( -
-
- - ← Back to Decks - -
- - {isLoading &&

Loading...

} - - {error && ( -
- {error} - + + Back to Decks +
- )} + - {!isLoading && !error && deck && ( -
-
-

{deck.name}

- {deck.description && ( -

- {deck.description} -

- )} + {/* Main Content */} +
+ {/* Loading State */} + {isLoading && ( +
+
+ )} + {/* Error State */} + {error && (
- + {error} + +
+ )} + + {/* Deck Content */} + {!isLoading && !error && deck && ( +
+ {/* Deck Header */} +
+

+ {deck.name} +

+ {deck.description && ( +

{deck.description}

+ )} +
+ + {/* Study Button */} +
+ + + Study Now + +
+ + {/* Cards Section */} +
+

+ Cards{" "} + ({cards.length}) +

- -
- -
-

Cards ({cards.length})

- -
- - {cards.length === 0 && ( -
-

This deck has no cards yet.

-

Add cards to start studying!

- )} - - {cards.length > 0 && ( -
    - {cards.map((card) => ( -
  • +
    + +
    +

    + No cards yet +

    +

    + Add cards to start studying +

    + +
+ )} + + {/* Card List */} + {cards.length > 0 && ( +
+ {cards.map((card, index) => (
-
-
-
- Front: -

- {card.front} -

+
+
+ {/* Front/Back Preview */} +
+
+ + Front + +

+ {card.front} +

+
+
+ + Back + +

+ {card.back} +

+
-
- Back: -

+ - {card.back} -

+ {CardStateLabels[card.state] || "Unknown"} + + + {card.reps} reviews + + {card.lapses > 0 && ( + + {card.lapses} lapses + + )}
-
- - State: {CardStateLabels[card.state] || "Unknown"} - - Reviews: {card.reps} - Lapses: {card.lapses} + + {/* Actions */} +
+ +
-
- - -
- - ))} - - )} -
- )} + ))} +
+ )} +
+ )} + + {/* Modals */} {deckId && ( { renderWithProviders(); - expect(screen.getByText("Loading decks...")).toBeDefined(); + // Loading state shows spinner (svg with animate-spin class) + expect(document.querySelector(".animate-spin")).toBeDefined(); }); it("displays empty state when no decks exist", async () => { @@ -151,10 +152,10 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText("You don't have any decks yet.")).toBeDefined(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); expect( - screen.getByText("Create your first deck to start learning!"), + screen.getByText("Create your first deck to start learning"), ).toBeDefined(); }); @@ -255,7 +256,7 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); await user.click(screen.getByRole("button", { name: "Logout" })); @@ -290,11 +291,11 @@ describe("HomePage", () => { ).toBeDefined(); }); - // The deck item should only contain the heading, no description paragraph - const deckItem = screen + // The deck card should only contain the heading, no description paragraph + const deckCard = screen .getByRole("heading", { name: "No Description Deck" }) - .closest("li"); - expect(deckItem?.querySelectorAll("p").length).toBe(0); + .closest("div[class*='bg-white']"); + expect(deckCard?.querySelectorAll("p").length).toBe(0); }); it("passes auth header when fetching decks", async () => { @@ -315,7 +316,7 @@ describe("HomePage", () => { }); describe("Create Deck", () => { - it("shows Create Deck button", async () => { + it("shows New Deck button", async () => { vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue( mockResponse({ ok: true, @@ -326,13 +327,13 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); - expect(screen.getByRole("button", { name: "Create Deck" })).toBeDefined(); + expect(screen.getByRole("button", { name: /New Deck/i })).toBeDefined(); }); - it("opens modal when Create Deck button is clicked", async () => { + it("opens modal when New Deck button is clicked", async () => { const user = userEvent.setup(); vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue( mockResponse({ @@ -344,10 +345,10 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); - await user.click(screen.getByRole("button", { name: "Create Deck" })); + await user.click(screen.getByRole("button", { name: /New Deck/i })); expect(screen.getByRole("dialog")).toBeDefined(); expect( @@ -367,10 +368,10 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); - await user.click(screen.getByRole("button", { name: "Create Deck" })); + await user.click(screen.getByRole("button", { name: /New Deck/i })); expect(screen.getByRole("dialog")).toBeDefined(); await user.click(screen.getByRole("button", { name: "Cancel" })); @@ -413,11 +414,11 @@ describe("HomePage", () => { renderWithProviders(); await waitFor(() => { - expect(screen.queryByText("Loading decks...")).toBeNull(); + expect(screen.getByText("No decks yet")).toBeDefined(); }); // Open modal - await user.click(screen.getByRole("button", { name: "Create Deck" })); + await user.click(screen.getByRole("button", { name: /New Deck/i })); // Fill in form await user.type(screen.getByLabelText("Name"), "New Deck"); @@ -427,7 +428,7 @@ describe("HomePage", () => { ); // Submit - await user.click(screen.getByRole("button", { name: "Create" })); + await user.click(screen.getByRole("button", { name: "Create Deck" })); // Modal should close await waitFor(() => { @@ -462,7 +463,7 @@ describe("HomePage", () => { ).toBeDefined(); }); - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); expect(editButtons.length).toBe(2); }); @@ -483,7 +484,7 @@ describe("HomePage", () => { ).toBeDefined(); }); - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); await user.click(editButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -511,7 +512,7 @@ describe("HomePage", () => { ).toBeDefined(); }); - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); await user.click(editButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -556,7 +557,7 @@ describe("HomePage", () => { }); // Click Edit on first deck - const editButtons = screen.getAllByRole("button", { name: "Edit" }); + const editButtons = screen.getAllByRole("button", { name: "Edit deck" }); await user.click(editButtons.at(0) as HTMLElement); // Update name @@ -565,7 +566,7 @@ describe("HomePage", () => { await user.type(nameInput, "Updated Japanese"); // Save - await user.click(screen.getByRole("button", { name: "Save" })); + await user.click(screen.getByRole("button", { name: "Save Changes" })); // Modal should close await waitFor(() => { @@ -601,7 +602,9 @@ describe("HomePage", () => { ).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); expect(deleteButtons.length).toBe(2); }); @@ -622,7 +625,9 @@ describe("HomePage", () => { ).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); await user.click(deleteButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -651,7 +656,9 @@ describe("HomePage", () => { ).toBeDefined(); }); - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); await user.click(deleteButtons.at(0) as HTMLElement); expect(screen.getByRole("dialog")).toBeDefined(); @@ -692,7 +699,9 @@ describe("HomePage", () => { }); // Click Delete on first deck - const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + const deleteButtons = screen.getAllByRole("button", { + name: "Delete deck", + }); await user.click(deleteButtons.at(0) as HTMLElement); // Wait for modal to appear diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx index 783e623..fcae971 100644 --- a/src/client/pages/HomePage.tsx +++ b/src/client/pages/HomePage.tsx @@ -62,122 +62,226 @@ export function HomePage() { }, [fetchDecks]); return ( -
-
-

Kioku

-
- - - +
+ {/* Header */} +
+
+

+ Kioku +

+
+ + + +
-
-
-

Your Decks

-
- {isLoading &&

Loading decks...

} + {/* Loading State */} + {isLoading && ( +
+ +
+ )} + {/* Error State */} {error && ( -
- {error} +
+ {error}
)} + {/* Empty State */} {!isLoading && !error && decks.length === 0 && ( -
-

You don't have any decks yet.

-

Create your first deck to start learning!

+
+
+ +
+

+ No decks yet +

+

+ Create your first deck to start learning +

+
)} + {/* Deck List */} {!isLoading && !error && decks.length > 0 && ( -
    - {decks.map((deck) => ( -
  • + {decks.map((deck, index) => ( +
    -
    -
    -

    - +
    +
    + +

    {deck.name} - -

    +

    + {deck.description && ( -

    +

    {deck.description}

    )}
    -
    -
    -
  • +
))} - +
)}
+ {/* Modals */} setIsCreateModalOpen(false)} diff --git a/src/client/pages/LoginPage.test.tsx b/src/client/pages/LoginPage.test.tsx index 724f433..e4dac95 100644 --- a/src/client/pages/LoginPage.test.tsx +++ b/src/client/pages/LoginPage.test.tsx @@ -55,10 +55,11 @@ describe("LoginPage", () => { it("renders login form", async () => { renderWithProviders(); - expect(screen.getByRole("heading", { name: "Login" })).toBeDefined(); + expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined(); + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeDefined(); expect(screen.getByLabelText("Username")).toBeDefined(); expect(screen.getByLabelText("Password")).toBeDefined(); - expect(screen.getByRole("button", { name: "Login" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined(); }); it("submits form and logs in successfully", async () => { @@ -74,7 +75,7 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "password123"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(apiClient.login).toHaveBeenCalledWith("testuser", "password123"); @@ -92,7 +93,7 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "wrongpassword"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toBe("Invalid credentials"); @@ -107,7 +108,7 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "password123"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { expect(screen.getByRole("alert").textContent).toBe( @@ -126,10 +127,10 @@ describe("LoginPage", () => { await user.type(screen.getByLabelText("Username"), "testuser"); await user.type(screen.getByLabelText("Password"), "password123"); - await user.click(screen.getByRole("button", { name: "Login" })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { - const button = screen.getByRole("button", { name: "Logging in..." }); + const button = screen.getByRole("button", { name: /Signing in/ }); expect(button.hasAttribute("disabled")).toBe(true); }); expect( diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx index cc59105..89dd053 100644 --- a/src/client/pages/LoginPage.tsx +++ b/src/client/pages/LoginPage.tsx @@ -38,42 +38,113 @@ export function LoginPage() { }; return ( -
-

Login

-
- {error && ( -
- {error} -
- )} -
- - setUsername(e.target.value)} - required - autoComplete="username" - disabled={isSubmitting} - /> +
+
+ {/* Logo/Brand */} +
+

+ Kioku +

+

Your memory, amplified

-
- - setPassword(e.target.value)} - required - autoComplete="current-password" - disabled={isSubmitting} - /> + + {/* Login Card */} +
+

+ Welcome back +

+ + + {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + required + autoComplete="username" + disabled={isSubmitting} + className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed" + placeholder="Enter your username" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + disabled={isSubmitting} + className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed" + placeholder="Enter your password" + /> +
+ + +
- - + + {/* Footer note */} +

+ Spaced repetition learning +

+
); } diff --git a/src/client/pages/NotFoundPage.tsx b/src/client/pages/NotFoundPage.tsx index 289dab5..72531c1 100644 --- a/src/client/pages/NotFoundPage.tsx +++ b/src/client/pages/NotFoundPage.tsx @@ -2,10 +2,52 @@ import { Link } from "wouter"; export function NotFoundPage() { return ( -
-

404 - Not Found

-

The page you're looking for doesn't exist.

- Go to Home +
+
+
+ +
+

404

+

+ Page Not Found +

+

+ The page you're looking for doesn't exist or has been moved. +

+ + + Go Home + +
); } diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx index bab9193..146322a 100644 --- a/src/client/pages/StudyPage.test.tsx +++ b/src/client/pages/StudyPage.test.tsx @@ -129,7 +129,8 @@ describe("StudyPage", () => { renderWithProviders(); - expect(screen.getByText("Loading study session...")).toBeDefined(); + // Loading state shows spinner (svg with animate-spin class) + expect(document.querySelector(".animate-spin")).toBeDefined(); }); it("renders deck name and back link", async () => { @@ -147,7 +148,7 @@ describe("StudyPage", () => { await waitFor(() => { expect( - screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + screen.getByRole("heading", { name: /Japanese Vocabulary/ }), ).toBeDefined(); }); @@ -229,7 +230,7 @@ describe("StudyPage", () => { await waitFor(() => { expect( - screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + screen.getByRole("heading", { name: /Japanese Vocabulary/ }), ).toBeDefined(); }); }); @@ -252,9 +253,9 @@ describe("StudyPage", () => { await waitFor(() => { expect(screen.getByTestId("no-cards")).toBeDefined(); }); - expect(screen.getByText("No cards to study")).toBeDefined(); + expect(screen.getByText("All caught up!")).toBeDefined(); expect( - screen.getByText("There are no due cards in this deck right now."), + screen.getByText("No cards due for review right now"), ).toBeDefined(); }); }); @@ -633,7 +634,7 @@ describe("StudyPage", () => { expect(screen.getByTestId("session-complete")).toBeDefined(); }); - expect(screen.getByText("Back to Deck")).toBeDefined(); + expect(screen.getAllByText("Back to Deck").length).toBeGreaterThan(0); expect(screen.getByText("All Decks")).toBeDefined(); }); }); diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 03cb537..16c1a1c 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -29,11 +29,11 @@ const RatingLabels: Record = { 4: "Easy", }; -const RatingColors: Record = { - 1: "#dc3545", - 2: "#fd7e14", - 3: "#28a745", - 4: "#007bff", +const RatingStyles: Record = { + 1: "bg-again hover:bg-again/90 focus:ring-again/30", + 2: "bg-hard hover:bg-hard/90 focus:ring-hard/30", + 3: "bg-good hover:bg-good/90 focus:ring-good/30", + 4: "bg-easy hover:bg-easy/90 focus:ring-easy/30", }; export function StudyPage() { @@ -217,9 +217,16 @@ export function StudyPage() { if (!deckId) { return ( -
-

Invalid deck ID

- Back to decks +
+
+

Invalid deck ID

+ + Back to decks + +
); } @@ -230,218 +237,259 @@ export function StudyPage() { const remainingCards = cards.length - currentIndex; return ( -
-
- - ← Back to Deck - -
- - {isLoading &&

Loading study session...

} - - {error && ( -
- {error} - + + Back to Deck +
- )} +
- {!isLoading && !error && deck && ( - <> -
-

Study: {deck.name}

- {!isSessionComplete && !hasNoCards && ( - - {remainingCards} remaining - - )} + {/* Main Content */} +
+ {/* Loading State */} + {isLoading && ( +
+
+ )} - {hasNoCards && ( -
+ {error} + - + Retry + +
+ )} + + {/* Study Content */} + {!isLoading && !error && deck && ( +
+ {/* Study Header */} +
+

+ {deck.name} +

+ {!isSessionComplete && !hasNoCards && ( + + {remainingCards} remaining + + )}
- )} - - {isSessionComplete && ( -
-

- Session Complete! -

-

- You reviewed{" "} - {completedCount}{" "} - card{completedCount !== 1 ? "s" : ""}. -

+ + {/* No Cards State */} + {hasNoCards && (
- - - - - - +
+
+ +
+

+ All caught up! +

+

+ No cards due for review right now +

+ + Back to Deck + +
-
- )} - - {currentCard && !isSessionComplete && ( -
-
+
+ )} + + {/* Active Study Card */} + {currentCard && !isSessionComplete && ( +
+ {/* Card */} + + ))} +
)} - - - {isFlipped && ( -
- {([1, 2, 3, 4] as Rating[]).map((rating) => ( - - ))} -
- )} -
- )} - - )} +
+ )} +
+ )} + ); } -- cgit v1.2.3-70-g09d2