aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-08 00:18:03 +0900
committernsfisis <nsfisis@gmail.com>2025-12-08 00:18:03 +0900
commit65c0adfd769b9ef11b897c96a3634c61120055b8 (patch)
tree74668feef8f134c1b132beaab125e42fa9d77b2e /src/client/pages
parent7cf55a3b7e37971ea0835118a26f032d895ff71f (diff)
downloadkioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.gz
kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.zst
kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.zip
feat(client): redesign frontend with TailwindCSS v4
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages')
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx68
-rw-r--r--src/client/pages/DeckDetailPage.tsx452
-rw-r--r--src/client/pages/HomePage.test.tsx65
-rw-r--r--src/client/pages/HomePage.tsx252
-rw-r--r--src/client/pages/LoginPage.test.tsx15
-rw-r--r--src/client/pages/LoginPage.tsx139
-rw-r--r--src/client/pages/NotFoundPage.tsx50
-rw-r--r--src/client/pages/StudyPage.test.tsx13
-rw-r--r--src/client/pages/StudyPage.tsx454
9 files changed, 961 insertions, 547 deletions
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<number, string> = {
3: "Relearning",
};
+const CardStateColors: Record<number, string> = {
+ 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<Deck | null>(null);
@@ -114,195 +121,318 @@ export function DeckDetailPage() {
if (!deckId) {
return (
- <div>
- <p>Invalid deck ID</p>
- <Link href="/">Back to decks</Link>
+ <div className="min-h-screen bg-cream flex items-center justify-center">
+ <div className="text-center">
+ <p className="text-muted mb-4">Invalid deck ID</p>
+ <Link
+ href="/"
+ className="text-primary hover:text-primary-dark font-medium"
+ >
+ Back to decks
+ </Link>
+ </div>
</div>
);
}
return (
- <div>
- <header style={{ marginBottom: "1rem" }}>
- <Link href="/" style={{ textDecoration: "none" }}>
- &larr; Back to Decks
- </Link>
- </header>
-
- {isLoading && <p>Loading...</p>}
-
- {error && (
- <div role="alert" style={{ color: "red" }}>
- {error}
- <button
- type="button"
- onClick={fetchData}
- style={{ marginLeft: "0.5rem" }}
+ <div className="min-h-screen bg-cream">
+ {/* Header */}
+ <header className="bg-white border-b border-border/50">
+ <div className="max-w-4xl mx-auto px-4 py-4">
+ <Link
+ href="/"
+ className="inline-flex items-center gap-2 text-muted hover:text-slate transition-colors text-sm"
>
- Retry
- </button>
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M15 19l-7-7 7-7"
+ />
+ </svg>
+ Back to Decks
+ </Link>
</div>
- )}
+ </header>
- {!isLoading && !error && deck && (
- <main>
- <div style={{ marginBottom: "1.5rem" }}>
- <h1 style={{ margin: 0 }}>{deck.name}</h1>
- {deck.description && (
- <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
- {deck.description}
- </p>
- )}
+ {/* Main Content */}
+ <main className="max-w-4xl mx-auto px-4 py-8">
+ {/* Loading State */}
+ {isLoading && (
+ <div className="flex items-center justify-center py-12">
+ <svg
+ className="animate-spin h-8 w-8 text-primary"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
</div>
+ )}
+ {/* Error State */}
+ {error && (
<div
- style={{
- display: "flex",
- gap: "0.5rem",
- marginBottom: "1rem",
- }}
+ role="alert"
+ className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between"
>
- <Link href={`/decks/${deckId}/study`}>
+ <span className="text-error">{error}</span>
+ <button
+ type="button"
+ onClick={fetchData}
+ className="text-error hover:text-error/80 font-medium text-sm"
+ >
+ Retry
+ </button>
+ </div>
+ )}
+
+ {/* Deck Content */}
+ {!isLoading && !error && deck && (
+ <div className="animate-fade-in">
+ {/* Deck Header */}
+ <div className="mb-8">
+ <h1 className="font-display text-3xl font-semibold text-ink mb-2">
+ {deck.name}
+ </h1>
+ {deck.description && (
+ <p className="text-muted">{deck.description}</p>
+ )}
+ </div>
+
+ {/* Study Button */}
+ <div className="mb-8">
+ <Link
+ href={`/decks/${deckId}/study`}
+ className="inline-flex items-center gap-2 bg-success hover:bg-success/90 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
+ />
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ Study Now
+ </Link>
+ </div>
+
+ {/* Cards Section */}
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="font-display text-xl font-medium text-slate">
+ Cards{" "}
+ <span className="text-muted font-normal">({cards.length})</span>
+ </h2>
<button
type="button"
- style={{
- backgroundColor: "#28a745",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: "pointer",
- }}
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
>
- Study Now
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ Add Card
</button>
- </Link>
- </div>
-
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h2 style={{ margin: 0 }}>Cards ({cards.length})</h2>
- <button type="button" onClick={() => setIsCreateModalOpen(true)}>
- Add Card
- </button>
- </div>
-
- {cards.length === 0 && (
- <div>
- <p>This deck has no cards yet.</p>
- <p>Add cards to start studying!</p>
</div>
- )}
-
- {cards.length > 0 && (
- <ul style={{ listStyle: "none", padding: 0 }}>
- {cards.map((card) => (
- <li
- key={card.id}
- style={{
- border: "1px solid #ccc",
- padding: "1rem",
- marginBottom: "0.5rem",
- borderRadius: "4px",
- }}
+
+ {/* Empty State */}
+ {cards.length === 0 && (
+ <div className="text-center py-12 bg-white rounded-xl border border-border/50">
+ <div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center">
+ <svg
+ className="w-7 h-7 text-muted"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={1.5}
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
+ />
+ </svg>
+ </div>
+ <h3 className="font-display text-lg font-medium text-slate mb-2">
+ No cards yet
+ </h3>
+ <p className="text-muted text-sm mb-4">
+ Add cards to start studying
+ </p>
+ <button
+ type="button"
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200"
>
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ Add Your First Card
+ </button>
+ </div>
+ )}
+
+ {/* Card List */}
+ {cards.length > 0 && (
+ <div className="space-y-3">
+ {cards.map((card, index) => (
<div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "flex-start",
- }}
+ key={card.id}
+ className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200"
+ style={{ animationDelay: `${index * 30}ms` }}
>
- <div style={{ flex: 1, minWidth: 0 }}>
- <div
- style={{
- display: "flex",
- gap: "1rem",
- marginBottom: "0.5rem",
- }}
- >
- <div style={{ flex: 1, minWidth: 0 }}>
- <strong>Front:</strong>
- <p
- style={{
- margin: "0.25rem 0 0 0",
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
- >
- {card.front}
- </p>
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ {/* Front/Back Preview */}
+ <div className="grid grid-cols-2 gap-4 mb-3">
+ <div>
+ <span className="text-xs font-medium text-muted uppercase tracking-wide">
+ Front
+ </span>
+ <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
+ {card.front}
+ </p>
+ </div>
+ <div>
+ <span className="text-xs font-medium text-muted uppercase tracking-wide">
+ Back
+ </span>
+ <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
+ {card.back}
+ </p>
+ </div>
</div>
- <div style={{ flex: 1, minWidth: 0 }}>
- <strong>Back:</strong>
- <p
- style={{
- margin: "0.25rem 0 0 0",
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
+
+ {/* Card Stats */}
+ <div className="flex items-center gap-3 text-xs">
+ <span
+ className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`}
>
- {card.back}
- </p>
+ {CardStateLabels[card.state] || "Unknown"}
+ </span>
+ <span className="text-muted">
+ {card.reps} reviews
+ </span>
+ {card.lapses > 0 && (
+ <span className="text-muted">
+ {card.lapses} lapses
+ </span>
+ )}
</div>
</div>
- <div
- style={{
- display: "flex",
- gap: "1rem",
- fontSize: "0.875rem",
- color: "#666",
- }}
- >
- <span>
- State: {CardStateLabels[card.state] || "Unknown"}
- </span>
- <span>Reviews: {card.reps}</span>
- <span>Lapses: {card.lapses}</span>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1 shrink-0">
+ <button
+ type="button"
+ onClick={() => setEditingCard(card)}
+ className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+ title="Edit card"
+ >
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ <button
+ type="button"
+ onClick={() => setDeletingCard(card)}
+ className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
+ title="Delete card"
+ >
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ </button>
</div>
</div>
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- marginLeft: "1rem",
- }}
- >
- <button
- type="button"
- onClick={() => setEditingCard(card)}
- >
- Edit
- </button>
- <button
- type="button"
- onClick={() => setDeletingCard(card)}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: "pointer",
- }}
- >
- Delete
- </button>
- </div>
</div>
- </li>
- ))}
- </ul>
- )}
- </main>
- )}
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </main>
+ {/* Modals */}
{deckId && (
<CreateCardModal
isOpen={isCreateModalOpen}
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
index 18c2e76..5b8489a 100644
--- a/src/client/pages/HomePage.test.tsx
+++ b/src/client/pages/HomePage.test.tsx
@@ -137,7 +137,8 @@ describe("HomePage", () => {
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 (
- <div>
- <header
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h1>Kioku</h1>
- <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
- <SyncStatusIndicator />
- <SyncButton />
- <button type="button" onClick={logout}>
- Logout
- </button>
+ <div className="min-h-screen bg-cream">
+ {/* Header */}
+ <header className="bg-white border-b border-border/50 sticky top-0 z-10">
+ <div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
+ <h1 className="font-display text-2xl font-semibold text-ink">
+ Kioku
+ </h1>
+ <div className="flex items-center gap-3">
+ <SyncStatusIndicator />
+ <SyncButton />
+ <button
+ type="button"
+ onClick={logout}
+ className="text-sm text-muted hover:text-slate transition-colors px-3 py-1.5 rounded-lg hover:bg-ivory"
+ >
+ Logout
+ </button>
+ </div>
</div>
</header>
- <main>
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h2 style={{ margin: 0 }}>Your Decks</h2>
- <button type="button" onClick={() => setIsCreateModalOpen(true)}>
- Create Deck
+ {/* Main Content */}
+ <main className="max-w-4xl mx-auto px-4 py-8">
+ {/* Section Header */}
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="font-display text-xl font-medium text-slate">
+ Your Decks
+ </h2>
+ <button
+ type="button"
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ New Deck
</button>
</div>
- {isLoading && <p>Loading decks...</p>}
+ {/* Loading State */}
+ {isLoading && (
+ <div className="flex items-center justify-center py-12">
+ <svg
+ className="animate-spin h-8 w-8 text-primary"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ </div>
+ )}
+ {/* Error State */}
{error && (
- <div role="alert" style={{ color: "red" }}>
- {error}
+ <div
+ role="alert"
+ className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between"
+ >
+ <span className="text-error">{error}</span>
<button
type="button"
onClick={fetchDecks}
- style={{ marginLeft: "0.5rem" }}
+ className="text-error hover:text-error/80 font-medium text-sm"
>
Retry
</button>
</div>
)}
+ {/* Empty State */}
{!isLoading && !error && decks.length === 0 && (
- <div>
- <p>You don't have any decks yet.</p>
- <p>Create your first deck to start learning!</p>
+ <div className="text-center py-16 animate-fade-in">
+ <div className="w-16 h-16 mx-auto mb-4 bg-ivory rounded-2xl flex items-center justify-center">
+ <svg
+ className="w-8 h-8 text-muted"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={1.5}
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
+ />
+ </svg>
+ </div>
+ <h3 className="font-display text-lg font-medium text-slate mb-2">
+ No decks yet
+ </h3>
+ <p className="text-muted text-sm mb-6">
+ Create your first deck to start learning
+ </p>
+ <button
+ type="button"
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ Create Your First Deck
+ </button>
</div>
)}
+ {/* Deck List */}
{!isLoading && !error && decks.length > 0 && (
- <ul style={{ listStyle: "none", padding: 0 }}>
- {decks.map((deck) => (
- <li
+ <div className="space-y-3 animate-fade-in">
+ {decks.map((deck, index) => (
+ <div
key={deck.id}
- style={{
- border: "1px solid #ccc",
- padding: "1rem",
- marginBottom: "0.5rem",
- borderRadius: "4px",
- }}
+ className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200 group"
+ style={{ animationDelay: `${index * 50}ms` }}
>
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "flex-start",
- }}
- >
- <div>
- <h3 style={{ margin: 0 }}>
- <Link
- href={`/decks/${deck.id}`}
- style={{ textDecoration: "none", color: "inherit" }}
- >
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ <Link
+ href={`/decks/${deck.id}`}
+ className="block group-hover:text-primary transition-colors"
+ >
+ <h3 className="font-display text-lg font-medium text-slate truncate">
{deck.name}
- </Link>
- </h3>
+ </h3>
+ </Link>
{deck.description && (
- <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
+ <p className="text-muted text-sm mt-1 line-clamp-2">
{deck.description}
</p>
)}
</div>
- <div style={{ display: "flex", gap: "0.5rem" }}>
- <button type="button" onClick={() => setEditingDeck(deck)}>
- Edit
+ <div className="flex items-center gap-2 shrink-0">
+ <button
+ type="button"
+ onClick={() => setEditingDeck(deck)}
+ className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+ title="Edit deck"
+ >
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
</button>
<button
type="button"
onClick={() => setDeletingDeck(deck)}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.25rem 0.5rem",
- borderRadius: "4px",
- cursor: "pointer",
- }}
+ className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
+ title="Delete deck"
>
- Delete
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
</button>
</div>
</div>
- </li>
+ </div>
))}
- </ul>
+ </div>
)}
</main>
+ {/* Modals */}
<CreateDeckModal
isOpen={isCreateModalOpen}
onClose={() => 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 (
- <div>
- <h1>Login</h1>
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red" }}>
- {error}
- </div>
- )}
- <div>
- <label htmlFor="username">Username</label>
- <input
- id="username"
- type="text"
- value={username}
- onChange={(e) => setUsername(e.target.value)}
- required
- autoComplete="username"
- disabled={isSubmitting}
- />
+ <div className="min-h-screen flex items-center justify-center px-4 py-12 bg-cream">
+ <div className="w-full max-w-sm animate-slide-up">
+ {/* Logo/Brand */}
+ <div className="text-center mb-10">
+ <h1 className="font-display text-4xl font-semibold text-ink tracking-tight">
+ Kioku
+ </h1>
+ <p className="mt-2 text-muted text-sm">Your memory, amplified</p>
</div>
- <div>
- <label htmlFor="password">Password</label>
- <input
- id="password"
- type="password"
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- required
- autoComplete="current-password"
- disabled={isSubmitting}
- />
+
+ {/* Login Card */}
+ <div className="bg-white rounded-2xl shadow-lg p-8 border border-border/50">
+ <h2 className="font-display text-xl font-medium text-slate mb-6">
+ Welcome back
+ </h2>
+
+ <form onSubmit={handleSubmit} className="space-y-5">
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20"
+ >
+ {error}
+ </div>
+ )}
+
+ <div>
+ <label
+ htmlFor="username"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Username
+ </label>
+ <input
+ id="username"
+ type="text"
+ value={username}
+ onChange={(e) => 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"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="password"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Password
+ </label>
+ <input
+ id="password"
+ type="password"
+ value={password}
+ onChange={(e) => 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"
+ />
+ </div>
+
+ <button
+ type="submit"
+ disabled={isSubmitting}
+ className="w-full bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-4 rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98] shadow-sm hover:shadow-md"
+ >
+ {isSubmitting ? (
+ <span className="flex items-center justify-center gap-2">
+ <svg
+ className="animate-spin h-4 w-4"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ Signing in...
+ </span>
+ ) : (
+ "Sign in"
+ )}
+ </button>
+ </form>
</div>
- <button type="submit" disabled={isSubmitting}>
- {isSubmitting ? "Logging in..." : "Login"}
- </button>
- </form>
+
+ {/* Footer note */}
+ <p className="text-center text-muted text-xs mt-6">
+ Spaced repetition learning
+ </p>
+ </div>
</div>
);
}
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 (
- <div>
- <h1>404 - Not Found</h1>
- <p>The page you're looking for doesn't exist.</p>
- <Link href="/">Go to Home</Link>
+ <div className="min-h-screen bg-cream flex items-center justify-center px-4">
+ <div className="text-center animate-fade-in">
+ <div className="w-20 h-20 mx-auto mb-6 bg-ivory rounded-2xl flex items-center justify-center">
+ <svg
+ className="w-10 h-10 text-muted"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={1.5}
+ d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ </div>
+ <h1 className="font-display text-6xl font-bold text-ink mb-2">404</h1>
+ <h2 className="font-display text-xl font-medium text-slate mb-4">
+ Page Not Found
+ </h2>
+ <p className="text-muted mb-8 max-w-sm mx-auto">
+ The page you're looking for doesn't exist or has been moved.
+ </p>
+ <Link
+ href="/"
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+ />
+ </svg>
+ Go Home
+ </Link>
+ </div>
</div>
);
}
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<Rating, string> = {
4: "Easy",
};
-const RatingColors: Record<Rating, string> = {
- 1: "#dc3545",
- 2: "#fd7e14",
- 3: "#28a745",
- 4: "#007bff",
+const RatingStyles: Record<Rating, string> = {
+ 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 (
- <div>
- <p>Invalid deck ID</p>
- <Link href="/">Back to decks</Link>
+ <div className="min-h-screen bg-cream flex items-center justify-center">
+ <div className="text-center">
+ <p className="text-muted mb-4">Invalid deck ID</p>
+ <Link
+ href="/"
+ className="text-primary hover:text-primary-dark font-medium"
+ >
+ Back to decks
+ </Link>
+ </div>
</div>
);
}
@@ -230,218 +237,259 @@ export function StudyPage() {
const remainingCards = cards.length - currentIndex;
return (
- <div style={{ maxWidth: "600px", margin: "0 auto", padding: "1rem" }}>
- <header style={{ marginBottom: "1rem" }}>
- <Link href={`/decks/${deckId}`} style={{ textDecoration: "none" }}>
- &larr; Back to Deck
- </Link>
- </header>
-
- {isLoading && <p>Loading study session...</p>}
-
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- <button
- type="button"
- onClick={fetchData}
- style={{ marginLeft: "0.5rem" }}
+ <div className="min-h-screen bg-cream flex flex-col">
+ {/* Header */}
+ <header className="bg-white border-b border-border/50 shrink-0">
+ <div className="max-w-2xl mx-auto px-4 py-4">
+ <Link
+ href={`/decks/${deckId}`}
+ className="inline-flex items-center gap-2 text-muted hover:text-slate transition-colors text-sm"
>
- Retry
- </button>
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M15 19l-7-7 7-7"
+ />
+ </svg>
+ Back to Deck
+ </Link>
</div>
- )}
+ </header>
- {!isLoading && !error && deck && (
- <>
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h1 style={{ margin: 0 }}>Study: {deck.name}</h1>
- {!isSessionComplete && !hasNoCards && (
- <span
- data-testid="remaining-count"
- style={{
- backgroundColor: "#f0f0f0",
- padding: "0.25rem 0.75rem",
- borderRadius: "12px",
- fontSize: "0.875rem",
- }}
- >
- {remainingCards} remaining
- </span>
- )}
+ {/* Main Content */}
+ <main className="flex-1 flex flex-col max-w-2xl mx-auto w-full px-4 py-6">
+ {/* Loading State */}
+ {isLoading && (
+ <div className="flex-1 flex items-center justify-center">
+ <svg
+ className="animate-spin h-8 w-8 text-primary"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
</div>
+ )}
- {hasNoCards && (
- <div
- data-testid="no-cards"
- style={{
- textAlign: "center",
- padding: "3rem 1rem",
- backgroundColor: "#f8f9fa",
- borderRadius: "8px",
- }}
+ {/* Error State */}
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between mb-4"
+ >
+ <span className="text-error">{error}</span>
+ <button
+ type="button"
+ onClick={fetchData}
+ className="text-error hover:text-error/80 font-medium text-sm"
>
- <h2 style={{ marginTop: 0 }}>No cards to study</h2>
- <p style={{ color: "#666" }}>
- There are no due cards in this deck right now.
- </p>
- <Link href={`/decks/${deckId}`}>
- <button type="button">Back to Deck</button>
- </Link>
+ Retry
+ </button>
+ </div>
+ )}
+
+ {/* Study Content */}
+ {!isLoading && !error && deck && (
+ <div className="flex-1 flex flex-col animate-fade-in">
+ {/* Study Header */}
+ <div className="flex items-center justify-between mb-6">
+ <h1 className="font-display text-xl font-medium text-slate truncate">
+ {deck.name}
+ </h1>
+ {!isSessionComplete && !hasNoCards && (
+ <span
+ data-testid="remaining-count"
+ className="bg-ivory text-slate px-3 py-1 rounded-full text-sm font-medium"
+ >
+ {remainingCards} remaining
+ </span>
+ )}
</div>
- )}
-
- {isSessionComplete && (
- <div
- data-testid="session-complete"
- style={{
- textAlign: "center",
- padding: "3rem 1rem",
- backgroundColor: "#d4edda",
- borderRadius: "8px",
- }}
- >
- <h2 style={{ marginTop: 0, color: "#155724" }}>
- Session Complete!
- </h2>
- <p style={{ fontSize: "1.25rem", marginBottom: "1.5rem" }}>
- You reviewed{" "}
- <strong data-testid="completed-count">{completedCount}</strong>{" "}
- card{completedCount !== 1 ? "s" : ""}.
- </p>
+
+ {/* No Cards State */}
+ {hasNoCards && (
<div
- style={{
- display: "flex",
- gap: "1rem",
- justifyContent: "center",
- }}
+ data-testid="no-cards"
+ className="flex-1 flex items-center justify-center"
>
- <Link href={`/decks/${deckId}`}>
- <button type="button">Back to Deck</button>
- </Link>
- <Link href="/">
- <button type="button">All Decks</button>
- </Link>
+ <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-card max-w-sm w-full">
+ <div className="w-16 h-16 mx-auto mb-4 bg-success/10 rounded-2xl flex items-center justify-center">
+ <svg
+ className="w-8 h-8 text-success"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M5 13l4 4L19 7"
+ />
+ </svg>
+ </div>
+ <h2 className="font-display text-xl font-medium text-slate mb-2">
+ All caught up!
+ </h2>
+ <p className="text-muted text-sm mb-6">
+ No cards due for review right now
+ </p>
+ <Link
+ href={`/decks/${deckId}`}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ Back to Deck
+ </Link>
+ </div>
</div>
- </div>
- )}
-
- {currentCard && !isSessionComplete && (
- <div data-testid="study-card">
- <button
- type="button"
- data-testid="card-container"
- onClick={!isFlipped ? handleFlip : undefined}
- aria-label={
- isFlipped ? "Card showing answer" : "Click to reveal answer"
- }
- disabled={isFlipped}
- style={{
- width: "100%",
- border: "1px solid #ccc",
- borderRadius: "8px",
- padding: "2rem",
- minHeight: "200px",
- display: "flex",
- flexDirection: "column",
- justifyContent: "center",
- alignItems: "center",
- cursor: isFlipped ? "default" : "pointer",
- backgroundColor: isFlipped ? "#f8f9fa" : "white",
- transition: "background-color 0.2s",
- font: "inherit",
- }}
+ )}
+
+ {/* Session Complete State */}
+ {isSessionComplete && (
+ <div
+ data-testid="session-complete"
+ className="flex-1 flex items-center justify-center"
>
- {!isFlipped ? (
- <>
- <p
- data-testid="card-front"
- style={{
- fontSize: "1.25rem",
- textAlign: "center",
- margin: 0,
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
+ <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-lg max-w-sm w-full animate-scale-in">
+ <div className="w-20 h-20 mx-auto mb-6 bg-success/10 rounded-full flex items-center justify-center">
+ <svg
+ className="w-10 h-10 text-success"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
>
- {currentCard.front}
- </p>
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ </div>
+ <h2 className="font-display text-2xl font-semibold text-ink mb-2">
+ Session Complete!
+ </h2>
+ <p className="text-muted mb-1">You reviewed</p>
+ <p className="text-4xl font-display font-bold text-primary mb-1">
+ <span data-testid="completed-count">{completedCount}</span>
+ </p>
+ <p className="text-muted mb-8">
+ card{completedCount !== 1 ? "s" : ""}
+ </p>
+ <div className="flex flex-col sm:flex-row gap-3 justify-center">
+ <Link
+ href={`/decks/${deckId}`}
+ className="inline-flex items-center justify-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ Back to Deck
+ </Link>
+ <Link
+ href="/"
+ className="inline-flex items-center justify-center gap-2 bg-ivory hover:bg-border text-slate font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ All Decks
+ </Link>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Active Study Card */}
+ {currentCard && !isSessionComplete && (
+ <div data-testid="study-card" className="flex-1 flex flex-col">
+ {/* Card */}
+ <button
+ type="button"
+ data-testid="card-container"
+ onClick={!isFlipped ? handleFlip : undefined}
+ aria-label={
+ isFlipped ? "Card showing answer" : "Click to reveal answer"
+ }
+ disabled={isFlipped}
+ className={`flex-1 min-h-[280px] bg-white rounded-2xl border border-border/50 shadow-card p-8 flex flex-col items-center justify-center text-center transition-all duration-300 ${
+ !isFlipped
+ ? "cursor-pointer hover:shadow-lg hover:border-primary/30 active:scale-[0.99]"
+ : "bg-ivory/50"
+ }`}
+ >
+ {!isFlipped ? (
+ <>
+ <p
+ data-testid="card-front"
+ className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed"
+ >
+ {currentCard.front}
+ </p>
+ <p className="mt-8 text-muted text-sm flex items-center gap-2">
+ <kbd className="px-2 py-0.5 bg-ivory rounded text-xs font-mono">
+ Space
+ </kbd>
+ <span>or tap to reveal</span>
+ </p>
+ </>
+ ) : (
<p
- style={{
- marginTop: "1.5rem",
- color: "#666",
- fontSize: "0.875rem",
- }}
+ data-testid="card-back"
+ className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed animate-fade-in"
>
- Click or press Space to reveal
+ {currentCard.back}
</p>
- </>
- ) : (
- <p
- data-testid="card-back"
- style={{
- fontSize: "1.25rem",
- textAlign: "center",
- margin: 0,
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
+ )}
+ </button>
+
+ {/* Rating Buttons */}
+ {isFlipped && (
+ <div
+ data-testid="rating-buttons"
+ className="mt-6 grid grid-cols-4 gap-2 animate-slide-up"
>
- {currentCard.back}
- </p>
+ {([1, 2, 3, 4] as Rating[]).map((rating) => (
+ <button
+ key={rating}
+ type="button"
+ data-testid={`rating-${rating}`}
+ onClick={() => handleRating(rating)}
+ disabled={isSubmitting}
+ className={`py-4 px-2 rounded-xl text-white font-medium transition-all duration-200 focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.97] ${RatingStyles[rating]}`}
+ >
+ <span className="block text-base font-semibold">
+ {RatingLabels[rating]}
+ </span>
+ <span className="block text-xs opacity-80 mt-0.5">
+ {rating}
+ </span>
+ </button>
+ ))}
+ </div>
)}
- </button>
-
- {isFlipped && (
- <div
- data-testid="rating-buttons"
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "center",
- marginTop: "1rem",
- }}
- >
- {([1, 2, 3, 4] as Rating[]).map((rating) => (
- <button
- key={rating}
- type="button"
- data-testid={`rating-${rating}`}
- onClick={() => handleRating(rating)}
- disabled={isSubmitting}
- style={{
- flex: 1,
- padding: "0.75rem 1rem",
- backgroundColor: RatingColors[rating],
- color: "white",
- border: "none",
- borderRadius: "4px",
- cursor: isSubmitting ? "not-allowed" : "pointer",
- opacity: isSubmitting ? 0.6 : 1,
- fontSize: "0.875rem",
- }}
- >
- <span style={{ display: "block", fontWeight: "bold" }}>
- {RatingLabels[rating]}
- </span>
- <span style={{ display: "block", fontSize: "0.75rem" }}>
- {rating}
- </span>
- </button>
- ))}
- </div>
- )}
- </div>
- )}
- </>
- )}
+ </div>
+ )}
+ </div>
+ )}
+ </main>
</div>
);
}