aboutsummaryrefslogtreecommitdiffhomepage
path: root/src
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
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')
-rw-r--r--src/client/App.test.tsx8
-rw-r--r--src/client/components/CreateCardModal.test.tsx20
-rw-r--r--src/client/components/CreateCardModal.tsx163
-rw-r--r--src/client/components/CreateDeckModal.test.tsx20
-rw-r--r--src/client/components/CreateDeckModal.tsx160
-rw-r--r--src/client/components/DeleteCardModal.tsx122
-rw-r--r--src/client/components/DeleteDeckModal.tsx125
-rw-r--r--src/client/components/EditCardModal.test.tsx24
-rw-r--r--src/client/components/EditCardModal.tsx169
-rw-r--r--src/client/components/EditDeckModal.test.tsx24
-rw-r--r--src/client/components/EditDeckModal.tsx164
-rw-r--r--src/client/components/OfflineBanner.test.tsx3
-rw-r--r--src/client/components/OfflineBanner.tsx31
-rw-r--r--src/client/components/SyncButton.tsx65
-rw-r--r--src/client/components/SyncStatusIndicator.tsx116
-rw-r--r--src/client/main.tsx1
-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
-rw-r--r--src/client/pwa.test.ts4
-rw-r--r--src/client/styles.css129
27 files changed, 1715 insertions, 1141 deletions
diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index 8359e67..fe870b7 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -114,14 +114,16 @@ describe("App routing", () => {
it("renders login page at /login", () => {
renderWithRouter("/login");
- expect(screen.getByRole("heading", { name: "Login" })).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Welcome back" })).toBeDefined();
});
it("renders 404 page for unknown routes", () => {
renderWithRouter("/unknown-route");
+ expect(screen.getByRole("heading", { name: "404" })).toBeDefined();
expect(
- screen.getByRole("heading", { name: "404 - Not Found" }),
+ screen.getByRole("heading", { name: "Page Not Found" }),
).toBeDefined();
- expect(screen.getByRole("link", { name: "Go to Home" })).toBeDefined();
+ expect(screen.getByRole("link", { name: /Go Home/i })).toBeDefined();
});
});
diff --git a/src/client/components/CreateCardModal.test.tsx b/src/client/components/CreateCardModal.test.tsx
index 6b429c8..7244824 100644
--- a/src/client/components/CreateCardModal.test.tsx
+++ b/src/client/components/CreateCardModal.test.tsx
@@ -84,7 +84,7 @@ describe("CreateCardModal", () => {
expect(screen.getByLabelText("Front")).toBeDefined();
expect(screen.getByLabelText("Back")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Create Card" })).toBeDefined();
});
it("disables create button when front is empty", async () => {
@@ -93,7 +93,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Back"), "Answer");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Card" });
expect(createButton).toHaveProperty("disabled", true);
});
@@ -103,7 +103,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Card" });
expect(createButton).toHaveProperty("disabled", true);
});
@@ -114,7 +114,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Card" });
expect(createButton).toHaveProperty("disabled", false);
});
@@ -181,7 +181,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "What is 2+2?");
await user.type(screen.getByLabelText("Back"), "4");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(
@@ -213,7 +213,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), " Question ");
await user.type(screen.getByLabelText("Back"), " Answer ");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(
@@ -241,7 +241,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Creating..." })).toHaveProperty(
@@ -271,7 +271,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -291,7 +291,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -358,7 +358,7 @@ describe("CreateCardModal", () => {
// Create a card
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
diff --git a/src/client/components/CreateCardModal.tsx b/src/client/components/CreateCardModal.tsx
index c28cf0f..3913e82 100644
--- a/src/client/components/CreateCardModal.tsx
+++ b/src/client/components/CreateCardModal.tsx
@@ -83,18 +83,7 @@ export function CreateCardModal({
role="dialog"
aria-modal="true"
aria-labelledby="create-card-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -106,88 +95,82 @@ export function CreateCardModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "500px",
- margin: "1rem",
- }}
- >
- <h2 id="create-card-title" style={{ marginTop: 0 }}>
- Create New Card
- </h2>
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="create-card-title"
+ className="font-display text-xl font-medium text-ink mb-6"
+ >
+ Create New Card
+ </h2>
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {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 style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="card-front"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Front
- </label>
- <textarea
- id="card-front"
- value={front}
- onChange={(e) => setFront(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Question or prompt"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="card-front"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Front
+ </label>
+ <textarea
+ id="card-front"
+ value={front}
+ onChange={(e) => setFront(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Question or prompt"
+ 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 resize-none"
+ />
+ </div>
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="card-back"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Back
- </label>
- <textarea
- id="card-back"
- value={back}
- onChange={(e) => setBack(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Answer or explanation"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="card-back"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Back
+ </label>
+ <textarea
+ id="card-back"
+ value={back}
+ onChange={(e) => setBack(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Answer or explanation"
+ 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 resize-none"
+ />
+ </div>
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !isFormValid}>
- {isSubmitting ? "Creating..." : "Create"}
- </button>
- </div>
- </form>
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !isFormValid}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Creating..." : "Create Card"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/CreateDeckModal.test.tsx b/src/client/components/CreateDeckModal.test.tsx
index 984f6d0..cdc5f97 100644
--- a/src/client/components/CreateDeckModal.test.tsx
+++ b/src/client/components/CreateDeckModal.test.tsx
@@ -79,13 +79,13 @@ describe("CreateDeckModal", () => {
expect(screen.getByLabelText("Name")).toBeDefined();
expect(screen.getByLabelText("Description (optional)")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Create Deck" })).toBeDefined();
});
it("disables create button when name is empty", () => {
render(<CreateDeckModal {...defaultProps} />);
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Deck" });
expect(createButton).toHaveProperty("disabled", true);
});
@@ -96,7 +96,7 @@ describe("CreateDeckModal", () => {
const nameInput = screen.getByLabelText("Name");
await user.type(nameInput, "My Deck");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Deck" });
expect(createButton).toHaveProperty("disabled", false);
});
@@ -161,7 +161,7 @@ describe("CreateDeckModal", () => {
);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
@@ -206,7 +206,7 @@ describe("CreateDeckModal", () => {
screen.getByLabelText("Description (optional)"),
"A test description",
);
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
@@ -236,7 +236,7 @@ describe("CreateDeckModal", () => {
screen.getByLabelText("Description (optional)"),
" Description ",
);
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
@@ -256,7 +256,7 @@ describe("CreateDeckModal", () => {
render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Creating..." })).toHaveProperty(
@@ -288,7 +288,7 @@ describe("CreateDeckModal", () => {
render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -307,7 +307,7 @@ describe("CreateDeckModal", () => {
render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -376,7 +376,7 @@ describe("CreateDeckModal", () => {
// Create a deck
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
diff --git a/src/client/components/CreateDeckModal.tsx b/src/client/components/CreateDeckModal.tsx
index 85afb0c..4541a68 100644
--- a/src/client/components/CreateDeckModal.tsx
+++ b/src/client/components/CreateDeckModal.tsx
@@ -78,18 +78,7 @@ export function CreateDeckModal({
role="dialog"
aria-modal="true"
aria-labelledby="create-deck-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -101,83 +90,84 @@ export function CreateDeckModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="create-deck-title" style={{ marginTop: 0 }}>
- Create New Deck
- </h2>
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="create-deck-title"
+ className="font-display text-xl font-medium text-ink mb-6"
+ >
+ Create New Deck
+ </h2>
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {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 style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="deck-name"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Name
- </label>
- <input
- id="deck-name"
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- required
- maxLength={255}
- disabled={isSubmitting}
- style={{ width: "100%", boxSizing: "border-box" }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="deck-name"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Name
+ </label>
+ <input
+ id="deck-name"
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ required
+ maxLength={255}
+ 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="My New Deck"
+ />
+ </div>
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="deck-description"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Description (optional)
- </label>
- <textarea
- id="deck-description"
- value={description}
- onChange={(e) => setDescription(e.target.value)}
- maxLength={1000}
- disabled={isSubmitting}
- rows={3}
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="deck-description"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Description{" "}
+ <span className="text-muted font-normal">(optional)</span>
+ </label>
+ <textarea
+ id="deck-description"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ maxLength={1000}
+ disabled={isSubmitting}
+ rows={3}
+ 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 resize-none"
+ placeholder="What will you learn?"
+ />
+ </div>
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !name.trim()}>
- {isSubmitting ? "Creating..." : "Create"}
- </button>
- </div>
- </form>
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !name.trim()}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Creating..." : "Create Deck"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx
index 99abbd0..44a745d 100644
--- a/src/client/components/DeleteCardModal.tsx
+++ b/src/client/components/DeleteCardModal.tsx
@@ -81,18 +81,7 @@ export function DeleteCardModal({
role="dialog"
aria-modal="true"
aria-labelledby="delete-card-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -104,56 +93,69 @@ export function DeleteCardModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="delete-card-title" style={{ marginTop: 0 }}>
- Delete Card
- </h2>
-
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <div className="w-12 h-12 mx-auto mb-4 bg-error/10 rounded-full flex items-center justify-center">
+ <svg
+ className="w-6 h-6 text-error"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
+ />
+ </svg>
</div>
- )}
-
- <p>Are you sure you want to delete this card?</p>
- <p style={{ color: "#666", fontStyle: "italic" }}>"{displayFront}"</p>
- <p style={{ color: "#666" }}>This action cannot be undone.</p>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- marginTop: "1.5rem",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isDeleting}>
- Cancel
- </button>
- <button
- type="button"
- onClick={handleDelete}
- disabled={isDeleting}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: isDeleting ? "not-allowed" : "pointer",
- }}
+
+ <h2
+ id="delete-card-title"
+ className="font-display text-xl font-medium text-ink text-center mb-2"
>
- {isDeleting ? "Deleting..." : "Delete"}
- </button>
+ Delete Card
+ </h2>
+
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4"
+ >
+ {error}
+ </div>
+ )}
+
+ <p className="text-slate text-center mb-2">
+ Are you sure you want to delete this card?
+ </p>
+ <p className="text-muted text-sm text-center italic mb-2">
+ "{displayFront}"
+ </p>
+ <p className="text-muted text-sm text-center mb-6">
+ This action cannot be undone.
+ </p>
+
+ <div className="flex gap-3 justify-center">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isDeleting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50 min-w-[100px]"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+ >
+ {isDeleting ? "Deleting..." : "Delete"}
+ </button>
+ </div>
</div>
</div>
</div>
diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx
index 307451c..5a252e6 100644
--- a/src/client/components/DeleteDeckModal.tsx
+++ b/src/client/components/DeleteDeckModal.tsx
@@ -75,18 +75,7 @@ export function DeleteDeckModal({
role="dialog"
aria-modal="true"
aria-labelledby="delete-deck-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -98,60 +87,68 @@ export function DeleteDeckModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="delete-deck-title" style={{ marginTop: 0 }}>
- Delete Deck
- </h2>
-
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <div className="w-12 h-12 mx-auto mb-4 bg-error/10 rounded-full flex items-center justify-center">
+ <svg
+ className="w-6 h-6 text-error"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
+ />
+ </svg>
</div>
- )}
-
- <p>
- Are you sure you want to delete <strong>{deck.name}</strong>?
- </p>
- <p style={{ color: "#666" }}>
- This action cannot be undone. All cards in this deck will also be
- deleted.
- </p>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- marginTop: "1.5rem",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isDeleting}>
- Cancel
- </button>
- <button
- type="button"
- onClick={handleDelete}
- disabled={isDeleting}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: isDeleting ? "not-allowed" : "pointer",
- }}
+
+ <h2
+ id="delete-deck-title"
+ className="font-display text-xl font-medium text-ink text-center mb-2"
>
- {isDeleting ? "Deleting..." : "Delete"}
- </button>
+ Delete Deck
+ </h2>
+
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4"
+ >
+ {error}
+ </div>
+ )}
+
+ <p className="text-slate text-center mb-2">
+ Are you sure you want to delete{" "}
+ <span className="font-semibold">{deck.name}</span>?
+ </p>
+ <p className="text-muted text-sm text-center mb-6">
+ This action cannot be undone. All cards in this deck will also be
+ deleted.
+ </p>
+
+ <div className="flex gap-3 justify-center">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isDeleting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50 min-w-[100px]"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+ >
+ {isDeleting ? "Deleting..." : "Delete"}
+ </button>
+ </div>
</div>
</div>
</div>
diff --git a/src/client/components/EditCardModal.test.tsx b/src/client/components/EditCardModal.test.tsx
index f37698f..b07dd4b 100644
--- a/src/client/components/EditCardModal.test.tsx
+++ b/src/client/components/EditCardModal.test.tsx
@@ -76,7 +76,7 @@ describe("EditCardModal", () => {
expect(screen.getByLabelText("Front")).toBeDefined();
expect(screen.getByLabelText("Back")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined();
});
it("populates form with card values", () => {
@@ -96,7 +96,7 @@ describe("EditCardModal", () => {
const frontInput = screen.getByLabelText("Front");
await user.clear(frontInput);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", true);
});
@@ -107,14 +107,14 @@ describe("EditCardModal", () => {
const backInput = screen.getByLabelText("Back");
await user.clear(backInput);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", true);
});
it("enables save button when both front and back have content", () => {
render(<EditCardModal {...defaultProps} />);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", false);
});
@@ -180,7 +180,7 @@ describe("EditCardModal", () => {
const frontInput = screen.getByLabelText("Front");
await user.clear(frontInput);
await user.type(frontInput, "Updated front");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
@@ -232,7 +232,7 @@ describe("EditCardModal", () => {
const backInput = screen.getByLabelText("Back");
await user.clear(backInput);
await user.type(backInput, "Updated back");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
@@ -270,7 +270,7 @@ describe("EditCardModal", () => {
};
render(<EditCardModal {...defaultProps} card={cardWithWhitespace} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
@@ -297,7 +297,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty(
@@ -323,7 +323,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain("Card not found");
@@ -337,7 +337,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -353,7 +353,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -396,7 +396,7 @@ describe("EditCardModal", () => {
);
// Trigger error
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx
index 2d04581..e38a2b1 100644
--- a/src/client/components/EditCardModal.tsx
+++ b/src/client/components/EditCardModal.tsx
@@ -99,18 +99,7 @@ export function EditCardModal({
role="dialog"
aria-modal="true"
aria-labelledby="edit-card-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -122,88 +111,82 @@ export function EditCardModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "500px",
- margin: "1rem",
- }}
- >
- <h2 id="edit-card-title" style={{ marginTop: 0 }}>
- Edit Card
- </h2>
-
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-card-front"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Front
- </label>
- <textarea
- id="edit-card-front"
- value={front}
- onChange={(e) => setFront(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Question or prompt"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-card-back"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Back
- </label>
- <textarea
- id="edit-card-back"
- value={back}
- onChange={(e) => setBack(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Answer or explanation"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="edit-card-title"
+ className="font-display text-xl font-medium text-ink mb-6"
>
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !isFormValid}>
- {isSubmitting ? "Saving..." : "Save"}
- </button>
- </div>
- </form>
+ Edit Card
+ </h2>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {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="edit-card-front"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Front
+ </label>
+ <textarea
+ id="edit-card-front"
+ value={front}
+ onChange={(e) => setFront(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Question or prompt"
+ 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 resize-none"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="edit-card-back"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Back
+ </label>
+ <textarea
+ id="edit-card-back"
+ value={back}
+ onChange={(e) => setBack(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Answer or explanation"
+ 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 resize-none"
+ />
+ </div>
+
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !isFormValid}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Saving..." : "Save Changes"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx
index e4c997e..c627dd5 100644
--- a/src/client/components/EditDeckModal.test.tsx
+++ b/src/client/components/EditDeckModal.test.tsx
@@ -76,7 +76,7 @@ describe("EditDeckModal", () => {
expect(screen.getByLabelText("Name")).toBeDefined();
expect(screen.getByLabelText("Description (optional)")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined();
});
it("populates form with deck values", () => {
@@ -106,14 +106,14 @@ describe("EditDeckModal", () => {
const nameInput = screen.getByLabelText("Name");
await user.clear(nameInput);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", true);
});
it("enables save button when name has content", () => {
render(<EditDeckModal {...defaultProps} />);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", false);
});
@@ -179,7 +179,7 @@ describe("EditDeckModal", () => {
const nameInput = screen.getByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Updated Deck");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -228,7 +228,7 @@ describe("EditDeckModal", () => {
const descInput = screen.getByLabelText("Description (optional)");
await user.clear(descInput);
await user.type(descInput, "New description");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -267,7 +267,7 @@ describe("EditDeckModal", () => {
const descInput = screen.getByLabelText("Description (optional)");
await user.clear(descInput);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -299,7 +299,7 @@ describe("EditDeckModal", () => {
};
render(<EditDeckModal {...defaultProps} deck={deckWithWhitespace} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -323,7 +323,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty(
@@ -352,7 +352,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -368,7 +368,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -384,7 +384,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -430,7 +430,7 @@ describe("EditDeckModal", () => {
);
// Trigger error
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx
index 46f1d4b..e589900 100644
--- a/src/client/components/EditDeckModal.tsx
+++ b/src/client/components/EditDeckModal.tsx
@@ -96,18 +96,7 @@ export function EditDeckModal({
role="dialog"
aria-modal="true"
aria-labelledby="edit-deck-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -119,83 +108,82 @@ export function EditDeckModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="edit-deck-title" style={{ marginTop: 0 }}>
- Edit Deck
- </h2>
-
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-deck-name"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Name
- </label>
- <input
- id="edit-deck-name"
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- required
- maxLength={255}
- disabled={isSubmitting}
- style={{ width: "100%", boxSizing: "border-box" }}
- />
- </div>
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-deck-description"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Description (optional)
- </label>
- <textarea
- id="edit-deck-description"
- value={description}
- onChange={(e) => setDescription(e.target.value)}
- maxLength={1000}
- disabled={isSubmitting}
- rows={3}
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="edit-deck-title"
+ className="font-display text-xl font-medium text-ink mb-6"
>
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !name.trim()}>
- {isSubmitting ? "Saving..." : "Save"}
- </button>
- </div>
- </form>
+ Edit Deck
+ </h2>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {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="edit-deck-name"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Name
+ </label>
+ <input
+ id="edit-deck-name"
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ required
+ maxLength={255}
+ 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"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="edit-deck-description"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Description{" "}
+ <span className="text-muted font-normal">(optional)</span>
+ </label>
+ <textarea
+ id="edit-deck-description"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ maxLength={1000}
+ disabled={isSubmitting}
+ rows={3}
+ 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 resize-none"
+ />
+ </div>
+
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !name.trim()}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Saving..." : "Save Changes"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/OfflineBanner.test.tsx b/src/client/components/OfflineBanner.test.tsx
index 41679d9..53ba815 100644
--- a/src/client/components/OfflineBanner.test.tsx
+++ b/src/client/components/OfflineBanner.test.tsx
@@ -79,7 +79,8 @@ describe("OfflineBanner", () => {
render(<OfflineBanner />);
const banner = screen.getByTestId("offline-banner");
- expect(banner.getAttribute("role")).toBe("status");
+ // <output> element has implicit role="status", so we check it's an output element
+ expect(banner.tagName.toLowerCase()).toBe("output");
expect(banner.getAttribute("aria-live")).toBe("polite");
});
});
diff --git a/src/client/components/OfflineBanner.tsx b/src/client/components/OfflineBanner.tsx
index faca3e7..bf94908 100644
--- a/src/client/components/OfflineBanner.tsx
+++ b/src/client/components/OfflineBanner.tsx
@@ -10,26 +10,27 @@ export function OfflineBanner() {
return (
<output
data-testid="offline-banner"
- role="status"
aria-live="polite"
- style={{
- backgroundColor: "#6c757d",
- color: "white",
- padding: "0.5rem 1rem",
- textAlign: "center",
- fontSize: "0.875rem",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- gap: "0.5rem",
- }}
+ className="bg-slate text-white py-2 px-4 text-sm flex items-center justify-center gap-2"
>
- <span aria-hidden="true">âš¡</span>
+ <svg
+ className="w-4 h-4 text-warning"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
+ />
+ </svg>
<span>
You're offline. Changes will sync when you reconnect.
{pendingCount > 0 && (
- <span data-testid="offline-pending-count">
- {" "}
+ <span data-testid="offline-pending-count" className="ml-1 opacity-80">
({pendingCount} pending)
</span>
)}
diff --git a/src/client/components/SyncButton.tsx b/src/client/components/SyncButton.tsx
index 1ebfa2e..82a6c68 100644
--- a/src/client/components/SyncButton.tsx
+++ b/src/client/components/SyncButton.tsx
@@ -9,13 +9,6 @@ export function SyncButton() {
const isDisabled = !isOnline || isSyncing;
- const getButtonText = (): string => {
- if (isSyncing) {
- return "Syncing...";
- }
- return "Sync";
- };
-
return (
<button
type="button"
@@ -23,17 +16,55 @@ export function SyncButton() {
onClick={handleSync}
disabled={isDisabled}
title={!isOnline ? "Cannot sync while offline" : undefined}
- style={{
- padding: "0.25rem 0.5rem",
- borderRadius: "4px",
- border: "1px solid #dee2e6",
- backgroundColor: isDisabled ? "#e9ecef" : "#007bff",
- color: isDisabled ? "#6c757d" : "white",
- cursor: isDisabled ? "not-allowed" : "pointer",
- fontSize: "0.875rem",
- }}
+ className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
+ isDisabled
+ ? "bg-ivory text-muted cursor-not-allowed"
+ : "bg-primary text-white hover:bg-primary-dark active:scale-[0.98]"
+ }`}
>
- {getButtonText()}
+ {isSyncing ? (
+ <>
+ <svg
+ className="w-4 h-4 animate-spin"
+ fill="none"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ <span>Syncing...</span>
+ </>
+ ) : (
+ <>
+ <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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+ />
+ </svg>
+ <span>Sync</span>
+ </>
+ )}
</button>
);
}
diff --git a/src/client/components/SyncStatusIndicator.tsx b/src/client/components/SyncStatusIndicator.tsx
index 23e3ec6..0f555ca 100644
--- a/src/client/components/SyncStatusIndicator.tsx
+++ b/src/client/components/SyncStatusIndicator.tsx
@@ -20,63 +20,115 @@ export function SyncStatusIndicator() {
return "Synced";
};
- const getStatusColor = (): string => {
+ const getStatusStyles = (): string => {
if (!isOnline) {
- return "#6c757d"; // gray
+ return "bg-muted/10 text-muted";
}
if (isSyncing) {
- return "#007bff"; // blue
+ return "bg-info/10 text-info";
}
if (status === SyncStatus.Error) {
- return "#dc3545"; // red
+ return "bg-error/10 text-error";
}
if (pendingCount > 0) {
- return "#ffc107"; // yellow
+ return "bg-warning/10 text-warning";
}
- return "#28a745"; // green
+ return "bg-success/10 text-success";
};
- const getStatusIcon = (): string => {
+ const getStatusIcon = () => {
if (!isOnline) {
- return "\u25CB"; // hollow circle
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <circle cx="10" cy="10" r="4" />
+ </svg>
+ );
}
if (isSyncing) {
- return "\u21BB"; // rotating arrows
+ return (
+ <svg
+ className="w-3.5 h-3.5 animate-spin"
+ fill="none"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ );
}
if (status === SyncStatus.Error) {
- return "\u2717"; // cross mark
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <path
+ fillRule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
}
if (pendingCount > 0) {
- return "\u25D4"; // partial circle
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <path
+ fillRule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
}
- return "\u2713"; // check mark
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <path
+ fillRule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
};
return (
<div
data-testid="sync-status-indicator"
- style={{
- display: "inline-flex",
- alignItems: "center",
- gap: "0.25rem",
- padding: "0.25rem 0.5rem",
- borderRadius: "4px",
- backgroundColor: "#f8f9fa",
- border: "1px solid #dee2e6",
- fontSize: "0.875rem",
- }}
+ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${getStatusStyles()}`}
title={lastError || undefined}
>
- <span
- style={{
- color: getStatusColor(),
- fontWeight: "bold",
- }}
- aria-hidden="true"
- >
- {getStatusIcon()}
- </span>
- <span style={{ color: getStatusColor() }}>{getStatusText()}</span>
+ {getStatusIcon()}
+ <span>{getStatusText()}</span>
</div>
);
}
diff --git a/src/client/main.tsx b/src/client/main.tsx
index bff0889..4809bc1 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { AuthProvider, SyncProvider } from "./stores";
+import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
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>
);
}
diff --git a/src/client/pwa.test.ts b/src/client/pwa.test.ts
index 18522c0..b19eb79 100644
--- a/src/client/pwa.test.ts
+++ b/src/client/pwa.test.ts
@@ -21,8 +21,8 @@ describe("PWA Configuration", () => {
expect(viteConfig).toContain(
'description: "A spaced repetition learning app"',
);
- expect(viteConfig).toContain('theme_color: "#4CAF50"');
- expect(viteConfig).toContain('background_color: "#ffffff"');
+ expect(viteConfig).toContain('theme_color: "#1a535c"');
+ expect(viteConfig).toContain('background_color: "#faf9f6"');
expect(viteConfig).toContain('display: "standalone"');
expect(viteConfig).toContain('start_url: "/"');
});
diff --git a/src/client/styles.css b/src/client/styles.css
new file mode 100644
index 0000000..2c10cfe
--- /dev/null
+++ b/src/client/styles.css
@@ -0,0 +1,129 @@
+@import "tailwindcss";
+
+@theme {
+ /* Color palette - Warm minimal Japanese aesthetic */
+ --color-cream: #faf9f6;
+ --color-ivory: #f5f4f0;
+ --color-ink: #1a1a1a;
+ --color-slate: #334155;
+ --color-muted: #94a3b8;
+ --color-border: #e2e0dc;
+
+ /* Primary - Deep teal */
+ --color-primary: #1a535c;
+ --color-primary-dark: #0f3439;
+ --color-primary-light: #2a7a87;
+
+ /* Rating colors */
+ --color-again: #dc2626;
+ --color-hard: #ea580c;
+ --color-good: #16a34a;
+ --color-easy: #2563eb;
+
+ /* Semantic colors */
+ --color-success: #059669;
+ --color-warning: #d97706;
+ --color-error: #c43535;
+ --color-info: #2563eb;
+
+ /* Typography */
+ --font-display: "Fraunces", "Georgia", serif;
+ --font-body: "DM Sans", system-ui, sans-serif;
+
+ /* Border radius */
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-2xl: 1.5rem;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03);
+ --shadow-md:
+ 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
+ --shadow-lg:
+ 0 10px 15px -3px rgb(0 0 0 / 0.05), 0 4px 6px -4px rgb(0 0 0 / 0.05);
+ --shadow-card:
+ 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
+
+ /* Animation */
+ --animate-fade-in: fade-in 0.3s ease-out;
+ --animate-slide-up: slide-up 0.3s ease-out;
+ --animate-scale-in: scale-in 0.2s ease-out;
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes scale-in {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+/* Base styles */
+html {
+ font-family: var(--font-body);
+ background-color: var(--color-cream);
+ color: var(--color-slate);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+}
+
+/* Focus styles */
+:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-ivory);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-muted);
+}
+
+/* Selection */
+::selection {
+ background-color: var(--color-primary);
+ color: white;
+}