aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-08 00:18:03 +0900
committernsfisis <nsfisis@gmail.com>2025-12-08 00:18:03 +0900
commit65c0adfd769b9ef11b897c96a3634c61120055b8 (patch)
tree74668feef8f134c1b132beaab125e42fa9d77b2e /src/client/components
parent7cf55a3b7e37971ea0835118a26f032d895ff71f (diff)
downloadkioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.gz
kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.zst
kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.zip
feat(client): redesign frontend with TailwindCSS v4
Replace inline styles with TailwindCSS, implementing a cohesive Japanese-inspired design system with custom colors (cream, teal primary), typography (Fraunces, DM Sans), and animations. Update all pages and components with consistent styling, improve accessibility by adding aria-hidden to decorative SVGs, and configure Biome for Tailwind CSS syntax support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/client/components')
-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
14 files changed, 617 insertions, 589 deletions
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>
);
}