diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-08 00:18:03 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-08 00:18:03 +0900 |
| commit | 65c0adfd769b9ef11b897c96a3634c61120055b8 (patch) | |
| tree | 74668feef8f134c1b132beaab125e42fa9d77b2e /src | |
| parent | 7cf55a3b7e37971ea0835118a26f032d895ff71f (diff) | |
| download | kioku-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')
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" }}> - ← 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" }}> - ← 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; +} |
