aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-08 00:18:03 +0900
committernsfisis <nsfisis@gmail.com>2025-12-08 00:18:03 +0900
commit65c0adfd769b9ef11b897c96a3634c61120055b8 (patch)
tree74668feef8f134c1b132beaab125e42fa9d77b2e
parent7cf55a3b7e37971ea0835118a26f032d895ff71f (diff)
downloadkioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.gz
kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.tar.zst
kioku-65c0adfd769b9ef11b897c96a3634c61120055b8.zip
feat(client): redesign frontend with TailwindCSS v4
Replace inline styles with TailwindCSS, implementing a cohesive Japanese-inspired design system with custom colors (cream, teal primary), typography (Fraunces, DM Sans), and animations. Update all pages and components with consistent styling, improve accessibility by adding aria-hidden to decorative SVGs, and configure Biome for Tailwind CSS syntax support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--biome.json9
-rw-r--r--index.html5
-rw-r--r--package.json2
-rw-r--r--pnpm-lock.yaml345
-rw-r--r--src/client/App.test.tsx8
-rw-r--r--src/client/components/CreateCardModal.test.tsx20
-rw-r--r--src/client/components/CreateCardModal.tsx163
-rw-r--r--src/client/components/CreateDeckModal.test.tsx20
-rw-r--r--src/client/components/CreateDeckModal.tsx160
-rw-r--r--src/client/components/DeleteCardModal.tsx122
-rw-r--r--src/client/components/DeleteDeckModal.tsx125
-rw-r--r--src/client/components/EditCardModal.test.tsx24
-rw-r--r--src/client/components/EditCardModal.tsx169
-rw-r--r--src/client/components/EditDeckModal.test.tsx24
-rw-r--r--src/client/components/EditDeckModal.tsx164
-rw-r--r--src/client/components/OfflineBanner.test.tsx3
-rw-r--r--src/client/components/OfflineBanner.tsx31
-rw-r--r--src/client/components/SyncButton.tsx65
-rw-r--r--src/client/components/SyncStatusIndicator.tsx116
-rw-r--r--src/client/main.tsx1
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx68
-rw-r--r--src/client/pages/DeckDetailPage.tsx452
-rw-r--r--src/client/pages/HomePage.test.tsx65
-rw-r--r--src/client/pages/HomePage.tsx252
-rw-r--r--src/client/pages/LoginPage.test.tsx15
-rw-r--r--src/client/pages/LoginPage.tsx139
-rw-r--r--src/client/pages/NotFoundPage.tsx50
-rw-r--r--src/client/pages/StudyPage.test.tsx13
-rw-r--r--src/client/pages/StudyPage.tsx454
-rw-r--r--src/client/pwa.test.ts4
-rw-r--r--src/client/styles.css129
-rw-r--r--vite.config.ts6
32 files changed, 2065 insertions, 1158 deletions
diff --git a/biome.json b/biome.json
index 67a7dbc..c9b490f 100644
--- a/biome.json
+++ b/biome.json
@@ -24,6 +24,15 @@
"quoteStyle": "double"
}
},
+ "css": {
+ "parser": {
+ "cssModules": true,
+ "tailwindDirectives": true
+ },
+ "linter": {
+ "enabled": true
+ }
+ },
"assist": {
"enabled": true,
"actions": {
diff --git a/index.html b/index.html
index d7c3aca..c71fcf2 100644
--- a/index.html
+++ b/index.html
@@ -3,10 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <meta name="theme-color" content="#4CAF50" />
+ <meta name="theme-color" content="#1a535c" />
<meta name="description" content="A spaced repetition learning app" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;0,9..144,700;1,9..144,400&display=swap" rel="stylesheet" />
<title>Kioku</title>
</head>
<body>
diff --git a/package.json b/package.json
index 5f4a362..d462eb4 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"devDependencies": {
"@biomejs/biome": "^2.3.8",
"@hono/cli": "^0.1.3",
+ "@tailwindcss/vite": "^4.1.17",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
@@ -58,6 +59,7 @@
"esbuild": "^0.27.1",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.2.0",
+ "tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vite-plugin-pwa": "^1.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 290fdbd..4d200dd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,6 +57,9 @@ importers:
'@hono/cli':
specifier: ^0.1.3
version: 0.1.3
+ '@tailwindcss/vite':
+ specifier: ^4.1.17
+ version: 4.1.17(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))
'@testing-library/dom':
specifier: ^10.4.1
version: 10.4.1
@@ -80,7 +83,7 @@ importers:
version: 19.2.3(@types/react@19.2.7)
'@vitejs/plugin-react':
specifier: ^5.1.1
- version: 5.1.1(vite@7.2.6(@types/node@24.10.1)(terser@5.44.1))
+ version: 5.1.1(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))
esbuild:
specifier: ^0.27.1
version: 0.27.1
@@ -90,18 +93,21 @@ importers:
jsdom:
specifier: ^27.2.0
version: 27.2.0
+ tailwindcss:
+ specifier: ^4.1.17
+ version: 4.1.17
typescript:
specifier: ^5.9.3
version: 5.9.3
vite:
specifier: ^7.2.6
- version: 7.2.6(@types/node@24.10.1)(terser@5.44.1)
+ version: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
vite-plugin-pwa:
specifier: ^1.2.0
- version: 1.2.0(vite@7.2.6(@types/node@24.10.1)(terser@5.44.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
+ version: 1.2.0(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
vitest:
specifier: ^4.0.15
- version: 4.0.15(@types/node@24.10.1)(jsdom@27.2.0)(terser@5.44.1)
+ version: 4.0.15(@types/node@24.10.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1)
packages:
@@ -1392,6 +1398,96 @@ packages:
'@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
+ '@tailwindcss/node@4.1.17':
+ resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==}
+
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.17':
+ resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.1.17':
+ resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/vite@4.1.17':
+ resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7
+
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@@ -1712,6 +1808,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
dexie@4.2.1:
resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==}
@@ -1835,6 +1935,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ enhanced-resolve@5.18.3:
+ resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
+ engines: {node: '>=10.13.0'}
+
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
@@ -2176,6 +2280,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ jiti@2.6.1:
+ resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
+ hasBin: true
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2215,6 +2323,76 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
+ lightningcss-android-arm64@1.30.2:
+ resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.30.2:
+ resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.30.2:
+ resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.30.2:
+ resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.30.2:
+ resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.30.2:
+ resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
+ engines: {node: '>= 12.0.0'}
+
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -2636,6 +2814,13 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ tailwindcss@4.1.17:
+ resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
+
+ tapable@2.3.0:
+ resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
+ engines: {node: '>=6'}
+
temp-dir@2.0.0:
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
engines: {node: '>=8'}
@@ -4160,6 +4345,74 @@ snapshots:
magic-string: 0.25.9
string.prototype.matchall: 4.0.12
+ '@tailwindcss/node@4.1.17':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.18.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.1.17
+
+ '@tailwindcss/oxide-android-arm64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.1.17':
+ optional: true
+
+ '@tailwindcss/oxide@4.1.17':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-arm64': 4.1.17
+ '@tailwindcss/oxide-darwin-x64': 4.1.17
+ '@tailwindcss/oxide-freebsd-x64': 4.1.17
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-arm64-musl': 4.1.17
+ '@tailwindcss/oxide-linux-x64-gnu': 4.1.17
+ '@tailwindcss/oxide-linux-x64-musl': 4.1.17
+ '@tailwindcss/oxide-wasm32-wasi': 4.1.17
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17
+ '@tailwindcss/oxide-win32-x64-msvc': 4.1.17
+
+ '@tailwindcss/vite@4.1.17(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))':
+ dependencies:
+ '@tailwindcss/node': 4.1.17
+ '@tailwindcss/oxide': 4.1.17
+ tailwindcss: 4.1.17
+ vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
+
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.27.1
@@ -4241,7 +4494,7 @@ snapshots:
'@types/trusted-types@2.0.7': {}
- '@vitejs/plugin-react@5.1.1(vite@7.2.6(@types/node@24.10.1)(terser@5.44.1))':
+ '@vitejs/plugin-react@5.1.1(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))':
dependencies:
'@babel/core': 7.28.5
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
@@ -4249,7 +4502,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.47
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
- vite: 7.2.6(@types/node@24.10.1)(terser@5.44.1)
+ vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
transitivePeerDependencies:
- supports-color
@@ -4262,13 +4515,13 @@ snapshots:
chai: 6.2.1
tinyrainbow: 3.0.3
- '@vitest/mocker@4.0.15(vite@7.2.6(@types/node@24.10.1)(terser@5.44.1))':
+ '@vitest/mocker@4.0.15(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))':
dependencies:
'@vitest/spy': 4.0.15
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.2.6(@types/node@24.10.1)(terser@5.44.1)
+ vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
'@vitest/pretty-format@4.0.15':
dependencies:
@@ -4509,6 +4762,8 @@ snapshots:
dequal@2.0.3: {}
+ detect-libc@2.1.2: {}
+
dexie@4.2.1: {}
dom-accessibility-api@0.5.16: {}
@@ -4545,6 +4800,11 @@ snapshots:
emoji-regex@9.2.2: {}
+ enhanced-resolve@5.18.3:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.0
+
entities@6.0.1: {}
es-abstract@1.24.0:
@@ -5009,6 +5269,8 @@ snapshots:
filelist: 1.0.4
picocolors: 1.1.1
+ jiti@2.6.1: {}
+
js-tokens@4.0.0: {}
jsdom@27.2.0:
@@ -5056,6 +5318,55 @@ snapshots:
leven@3.1.0: {}
+ lightningcss-android-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-arm64@1.30.2:
+ optional: true
+
+ lightningcss-darwin-x64@1.30.2:
+ optional: true
+
+ lightningcss-freebsd-x64@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.30.2:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.30.2:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.30.2:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.30.2:
+ optional: true
+
+ lightningcss@1.30.2:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.30.2
+ lightningcss-darwin-arm64: 1.30.2
+ lightningcss-darwin-x64: 1.30.2
+ lightningcss-freebsd-x64: 1.30.2
+ lightningcss-linux-arm-gnueabihf: 1.30.2
+ lightningcss-linux-arm64-gnu: 1.30.2
+ lightningcss-linux-arm64-musl: 1.30.2
+ lightningcss-linux-x64-gnu: 1.30.2
+ lightningcss-linux-x64-musl: 1.30.2
+ lightningcss-win32-arm64-msvc: 1.30.2
+ lightningcss-win32-x64-msvc: 1.30.2
+
lodash.debounce@4.0.8: {}
lodash.sortby@4.7.0: {}
@@ -5507,6 +5818,10 @@ snapshots:
symbol-tree@3.2.4: {}
+ tailwindcss@4.1.17: {}
+
+ tapable@2.3.0: {}
+
temp-dir@2.0.0: {}
tempy@0.6.0:
@@ -5631,18 +5946,18 @@ snapshots:
uuid@13.0.0: {}
- vite-plugin-pwa@1.2.0(vite@7.2.6(@types/node@24.10.1)(terser@5.44.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0):
+ vite-plugin-pwa@1.2.0(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0):
dependencies:
debug: 4.4.3
pretty-bytes: 6.1.1
tinyglobby: 0.2.15
- vite: 7.2.6(@types/node@24.10.1)(terser@5.44.1)
+ vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
workbox-build: 7.4.0(@types/babel__core@7.20.5)
workbox-window: 7.4.0
transitivePeerDependencies:
- supports-color
- vite@7.2.6(@types/node@24.10.1)(terser@5.44.1):
+ vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -5653,12 +5968,14 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.1
fsevents: 2.3.3
+ jiti: 2.6.1
+ lightningcss: 1.30.2
terser: 5.44.1
- vitest@4.0.15(@types/node@24.10.1)(jsdom@27.2.0)(terser@5.44.1):
+ vitest@4.0.15(@types/node@24.10.1)(jiti@2.6.1)(jsdom@27.2.0)(lightningcss@1.30.2)(terser@5.44.1):
dependencies:
'@vitest/expect': 4.0.15
- '@vitest/mocker': 4.0.15(vite@7.2.6(@types/node@24.10.1)(terser@5.44.1))
+ '@vitest/mocker': 4.0.15(vite@7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))
'@vitest/pretty-format': 4.0.15
'@vitest/runner': 4.0.15
'@vitest/snapshot': 4.0.15
@@ -5675,7 +5992,7 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
- vite: 7.2.6(@types/node@24.10.1)(terser@5.44.1)
+ vite: 7.2.6(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.10.1
diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index 8359e67..fe870b7 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -114,14 +114,16 @@ describe("App routing", () => {
it("renders login page at /login", () => {
renderWithRouter("/login");
- expect(screen.getByRole("heading", { name: "Login" })).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Welcome back" })).toBeDefined();
});
it("renders 404 page for unknown routes", () => {
renderWithRouter("/unknown-route");
+ expect(screen.getByRole("heading", { name: "404" })).toBeDefined();
expect(
- screen.getByRole("heading", { name: "404 - Not Found" }),
+ screen.getByRole("heading", { name: "Page Not Found" }),
).toBeDefined();
- expect(screen.getByRole("link", { name: "Go to Home" })).toBeDefined();
+ expect(screen.getByRole("link", { name: /Go Home/i })).toBeDefined();
});
});
diff --git a/src/client/components/CreateCardModal.test.tsx b/src/client/components/CreateCardModal.test.tsx
index 6b429c8..7244824 100644
--- a/src/client/components/CreateCardModal.test.tsx
+++ b/src/client/components/CreateCardModal.test.tsx
@@ -84,7 +84,7 @@ describe("CreateCardModal", () => {
expect(screen.getByLabelText("Front")).toBeDefined();
expect(screen.getByLabelText("Back")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Create Card" })).toBeDefined();
});
it("disables create button when front is empty", async () => {
@@ -93,7 +93,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Back"), "Answer");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Card" });
expect(createButton).toHaveProperty("disabled", true);
});
@@ -103,7 +103,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Card" });
expect(createButton).toHaveProperty("disabled", true);
});
@@ -114,7 +114,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Card" });
expect(createButton).toHaveProperty("disabled", false);
});
@@ -181,7 +181,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "What is 2+2?");
await user.type(screen.getByLabelText("Back"), "4");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(
@@ -213,7 +213,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), " Question ");
await user.type(screen.getByLabelText("Back"), " Answer ");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(
@@ -241,7 +241,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Creating..." })).toHaveProperty(
@@ -271,7 +271,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -291,7 +291,7 @@ describe("CreateCardModal", () => {
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -358,7 +358,7 @@ describe("CreateCardModal", () => {
// Create a card
await user.type(screen.getByLabelText("Front"), "Question");
await user.type(screen.getByLabelText("Back"), "Answer");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Card" }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
diff --git a/src/client/components/CreateCardModal.tsx b/src/client/components/CreateCardModal.tsx
index c28cf0f..3913e82 100644
--- a/src/client/components/CreateCardModal.tsx
+++ b/src/client/components/CreateCardModal.tsx
@@ -83,18 +83,7 @@ export function CreateCardModal({
role="dialog"
aria-modal="true"
aria-labelledby="create-card-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -106,88 +95,82 @@ export function CreateCardModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "500px",
- margin: "1rem",
- }}
- >
- <h2 id="create-card-title" style={{ marginTop: 0 }}>
- Create New Card
- </h2>
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="create-card-title"
+ className="font-display text-xl font-medium text-ink mb-6"
+ >
+ Create New Card
+ </h2>
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20"
+ >
+ {error}
+ </div>
+ )}
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="card-front"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Front
- </label>
- <textarea
- id="card-front"
- value={front}
- onChange={(e) => setFront(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Question or prompt"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="card-front"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Front
+ </label>
+ <textarea
+ id="card-front"
+ value={front}
+ onChange={(e) => setFront(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Question or prompt"
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
+ />
+ </div>
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="card-back"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Back
- </label>
- <textarea
- id="card-back"
- value={back}
- onChange={(e) => setBack(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Answer or explanation"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="card-back"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Back
+ </label>
+ <textarea
+ id="card-back"
+ value={back}
+ onChange={(e) => setBack(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Answer or explanation"
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
+ />
+ </div>
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !isFormValid}>
- {isSubmitting ? "Creating..." : "Create"}
- </button>
- </div>
- </form>
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !isFormValid}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Creating..." : "Create Card"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/CreateDeckModal.test.tsx b/src/client/components/CreateDeckModal.test.tsx
index 984f6d0..cdc5f97 100644
--- a/src/client/components/CreateDeckModal.test.tsx
+++ b/src/client/components/CreateDeckModal.test.tsx
@@ -79,13 +79,13 @@ describe("CreateDeckModal", () => {
expect(screen.getByLabelText("Name")).toBeDefined();
expect(screen.getByLabelText("Description (optional)")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Create Deck" })).toBeDefined();
});
it("disables create button when name is empty", () => {
render(<CreateDeckModal {...defaultProps} />);
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Deck" });
expect(createButton).toHaveProperty("disabled", true);
});
@@ -96,7 +96,7 @@ describe("CreateDeckModal", () => {
const nameInput = screen.getByLabelText("Name");
await user.type(nameInput, "My Deck");
- const createButton = screen.getByRole("button", { name: "Create" });
+ const createButton = screen.getByRole("button", { name: "Create Deck" });
expect(createButton).toHaveProperty("disabled", false);
});
@@ -161,7 +161,7 @@ describe("CreateDeckModal", () => {
);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
@@ -206,7 +206,7 @@ describe("CreateDeckModal", () => {
screen.getByLabelText("Description (optional)"),
"A test description",
);
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
@@ -236,7 +236,7 @@ describe("CreateDeckModal", () => {
screen.getByLabelText("Description (optional)"),
" Description ",
);
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(apiClient.rpc.api.decks.$post).toHaveBeenCalledWith(
@@ -256,7 +256,7 @@ describe("CreateDeckModal", () => {
render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Creating..." })).toHaveProperty(
@@ -288,7 +288,7 @@ describe("CreateDeckModal", () => {
render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -307,7 +307,7 @@ describe("CreateDeckModal", () => {
render(<CreateDeckModal {...defaultProps} />);
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -376,7 +376,7 @@ describe("CreateDeckModal", () => {
// Create a deck
await user.type(screen.getByLabelText("Name"), "Test Deck");
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
diff --git a/src/client/components/CreateDeckModal.tsx b/src/client/components/CreateDeckModal.tsx
index 85afb0c..4541a68 100644
--- a/src/client/components/CreateDeckModal.tsx
+++ b/src/client/components/CreateDeckModal.tsx
@@ -78,18 +78,7 @@ export function CreateDeckModal({
role="dialog"
aria-modal="true"
aria-labelledby="create-deck-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -101,83 +90,84 @@ export function CreateDeckModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="create-deck-title" style={{ marginTop: 0 }}>
- Create New Deck
- </h2>
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="create-deck-title"
+ className="font-display text-xl font-medium text-ink mb-6"
+ >
+ Create New Deck
+ </h2>
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20"
+ >
+ {error}
+ </div>
+ )}
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="deck-name"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Name
- </label>
- <input
- id="deck-name"
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- required
- maxLength={255}
- disabled={isSubmitting}
- style={{ width: "100%", boxSizing: "border-box" }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="deck-name"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Name
+ </label>
+ <input
+ id="deck-name"
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ required
+ maxLength={255}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ placeholder="My New Deck"
+ />
+ </div>
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="deck-description"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Description (optional)
- </label>
- <textarea
- id="deck-description"
- value={description}
- onChange={(e) => setDescription(e.target.value)}
- maxLength={1000}
- disabled={isSubmitting}
- rows={3}
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
+ <div>
+ <label
+ htmlFor="deck-description"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Description{" "}
+ <span className="text-muted font-normal">(optional)</span>
+ </label>
+ <textarea
+ id="deck-description"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ maxLength={1000}
+ disabled={isSubmitting}
+ rows={3}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
+ placeholder="What will you learn?"
+ />
+ </div>
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !name.trim()}>
- {isSubmitting ? "Creating..." : "Create"}
- </button>
- </div>
- </form>
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !name.trim()}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Creating..." : "Create Deck"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx
index 99abbd0..44a745d 100644
--- a/src/client/components/DeleteCardModal.tsx
+++ b/src/client/components/DeleteCardModal.tsx
@@ -81,18 +81,7 @@ export function DeleteCardModal({
role="dialog"
aria-modal="true"
aria-labelledby="delete-card-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -104,56 +93,69 @@ export function DeleteCardModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="delete-card-title" style={{ marginTop: 0 }}>
- Delete Card
- </h2>
-
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <div className="w-12 h-12 mx-auto mb-4 bg-error/10 rounded-full flex items-center justify-center">
+ <svg
+ className="w-6 h-6 text-error"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
+ />
+ </svg>
</div>
- )}
-
- <p>Are you sure you want to delete this card?</p>
- <p style={{ color: "#666", fontStyle: "italic" }}>"{displayFront}"</p>
- <p style={{ color: "#666" }}>This action cannot be undone.</p>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- marginTop: "1.5rem",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isDeleting}>
- Cancel
- </button>
- <button
- type="button"
- onClick={handleDelete}
- disabled={isDeleting}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: isDeleting ? "not-allowed" : "pointer",
- }}
+
+ <h2
+ id="delete-card-title"
+ className="font-display text-xl font-medium text-ink text-center mb-2"
>
- {isDeleting ? "Deleting..." : "Delete"}
- </button>
+ Delete Card
+ </h2>
+
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4"
+ >
+ {error}
+ </div>
+ )}
+
+ <p className="text-slate text-center mb-2">
+ Are you sure you want to delete this card?
+ </p>
+ <p className="text-muted text-sm text-center italic mb-2">
+ "{displayFront}"
+ </p>
+ <p className="text-muted text-sm text-center mb-6">
+ This action cannot be undone.
+ </p>
+
+ <div className="flex gap-3 justify-center">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isDeleting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50 min-w-[100px]"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+ >
+ {isDeleting ? "Deleting..." : "Delete"}
+ </button>
+ </div>
</div>
</div>
</div>
diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx
index 307451c..5a252e6 100644
--- a/src/client/components/DeleteDeckModal.tsx
+++ b/src/client/components/DeleteDeckModal.tsx
@@ -75,18 +75,7 @@ export function DeleteDeckModal({
role="dialog"
aria-modal="true"
aria-labelledby="delete-deck-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -98,60 +87,68 @@ export function DeleteDeckModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="delete-deck-title" style={{ marginTop: 0 }}>
- Delete Deck
- </h2>
-
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <div className="w-12 h-12 mx-auto mb-4 bg-error/10 rounded-full flex items-center justify-center">
+ <svg
+ className="w-6 h-6 text-error"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
+ />
+ </svg>
</div>
- )}
-
- <p>
- Are you sure you want to delete <strong>{deck.name}</strong>?
- </p>
- <p style={{ color: "#666" }}>
- This action cannot be undone. All cards in this deck will also be
- deleted.
- </p>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- marginTop: "1.5rem",
- }}
- >
- <button type="button" onClick={handleClose} disabled={isDeleting}>
- Cancel
- </button>
- <button
- type="button"
- onClick={handleDelete}
- disabled={isDeleting}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: isDeleting ? "not-allowed" : "pointer",
- }}
+
+ <h2
+ id="delete-deck-title"
+ className="font-display text-xl font-medium text-ink text-center mb-2"
>
- {isDeleting ? "Deleting..." : "Delete"}
- </button>
+ Delete Deck
+ </h2>
+
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20 mb-4"
+ >
+ {error}
+ </div>
+ )}
+
+ <p className="text-slate text-center mb-2">
+ Are you sure you want to delete{" "}
+ <span className="font-semibold">{deck.name}</span>?
+ </p>
+ <p className="text-muted text-sm text-center mb-6">
+ This action cannot be undone. All cards in this deck will also be
+ deleted.
+ </p>
+
+ <div className="flex gap-3 justify-center">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isDeleting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50 min-w-[100px]"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
+ >
+ {isDeleting ? "Deleting..." : "Delete"}
+ </button>
+ </div>
</div>
</div>
</div>
diff --git a/src/client/components/EditCardModal.test.tsx b/src/client/components/EditCardModal.test.tsx
index f37698f..b07dd4b 100644
--- a/src/client/components/EditCardModal.test.tsx
+++ b/src/client/components/EditCardModal.test.tsx
@@ -76,7 +76,7 @@ describe("EditCardModal", () => {
expect(screen.getByLabelText("Front")).toBeDefined();
expect(screen.getByLabelText("Back")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined();
});
it("populates form with card values", () => {
@@ -96,7 +96,7 @@ describe("EditCardModal", () => {
const frontInput = screen.getByLabelText("Front");
await user.clear(frontInput);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", true);
});
@@ -107,14 +107,14 @@ describe("EditCardModal", () => {
const backInput = screen.getByLabelText("Back");
await user.clear(backInput);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", true);
});
it("enables save button when both front and back have content", () => {
render(<EditCardModal {...defaultProps} />);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", false);
});
@@ -180,7 +180,7 @@ describe("EditCardModal", () => {
const frontInput = screen.getByLabelText("Front");
await user.clear(frontInput);
await user.type(frontInput, "Updated front");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
@@ -232,7 +232,7 @@ describe("EditCardModal", () => {
const backInput = screen.getByLabelText("Back");
await user.clear(backInput);
await user.type(backInput, "Updated back");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
@@ -270,7 +270,7 @@ describe("EditCardModal", () => {
};
render(<EditCardModal {...defaultProps} card={cardWithWhitespace} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
@@ -297,7 +297,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty(
@@ -323,7 +323,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain("Card not found");
@@ -337,7 +337,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -353,7 +353,7 @@ describe("EditCardModal", () => {
render(<EditCardModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -396,7 +396,7 @@ describe("EditCardModal", () => {
);
// Trigger error
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx
index 2d04581..e38a2b1 100644
--- a/src/client/components/EditCardModal.tsx
+++ b/src/client/components/EditCardModal.tsx
@@ -99,18 +99,7 @@ export function EditCardModal({
role="dialog"
aria-modal="true"
aria-labelledby="edit-card-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -122,88 +111,82 @@ export function EditCardModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "500px",
- margin: "1rem",
- }}
- >
- <h2 id="edit-card-title" style={{ marginTop: 0 }}>
- Edit Card
- </h2>
-
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-card-front"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Front
- </label>
- <textarea
- id="edit-card-front"
- value={front}
- onChange={(e) => setFront(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Question or prompt"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-card-back"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Back
- </label>
- <textarea
- id="edit-card-back"
- value={back}
- onChange={(e) => setBack(e.target.value)}
- required
- disabled={isSubmitting}
- rows={3}
- placeholder="Answer or explanation"
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="edit-card-title"
+ className="font-display text-xl font-medium text-ink mb-6"
>
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !isFormValid}>
- {isSubmitting ? "Saving..." : "Save"}
- </button>
- </div>
- </form>
+ Edit Card
+ </h2>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20"
+ >
+ {error}
+ </div>
+ )}
+
+ <div>
+ <label
+ htmlFor="edit-card-front"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Front
+ </label>
+ <textarea
+ id="edit-card-front"
+ value={front}
+ onChange={(e) => setFront(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Question or prompt"
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="edit-card-back"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Back
+ </label>
+ <textarea
+ id="edit-card-back"
+ value={back}
+ onChange={(e) => setBack(e.target.value)}
+ required
+ disabled={isSubmitting}
+ rows={3}
+ placeholder="Answer or explanation"
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
+ />
+ </div>
+
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !isFormValid}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Saving..." : "Save Changes"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx
index e4c997e..c627dd5 100644
--- a/src/client/components/EditDeckModal.test.tsx
+++ b/src/client/components/EditDeckModal.test.tsx
@@ -76,7 +76,7 @@ describe("EditDeckModal", () => {
expect(screen.getByLabelText("Name")).toBeDefined();
expect(screen.getByLabelText("Description (optional)")).toBeDefined();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined();
- expect(screen.getByRole("button", { name: "Save" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Save Changes" })).toBeDefined();
});
it("populates form with deck values", () => {
@@ -106,14 +106,14 @@ describe("EditDeckModal", () => {
const nameInput = screen.getByLabelText("Name");
await user.clear(nameInput);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", true);
});
it("enables save button when name has content", () => {
render(<EditDeckModal {...defaultProps} />);
- const saveButton = screen.getByRole("button", { name: "Save" });
+ const saveButton = screen.getByRole("button", { name: "Save Changes" });
expect(saveButton).toHaveProperty("disabled", false);
});
@@ -179,7 +179,7 @@ describe("EditDeckModal", () => {
const nameInput = screen.getByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Updated Deck");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -228,7 +228,7 @@ describe("EditDeckModal", () => {
const descInput = screen.getByLabelText("Description (optional)");
await user.clear(descInput);
await user.type(descInput, "New description");
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -267,7 +267,7 @@ describe("EditDeckModal", () => {
const descInput = screen.getByLabelText("Description (optional)");
await user.clear(descInput);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -299,7 +299,7 @@ describe("EditDeckModal", () => {
};
render(<EditDeckModal {...defaultProps} deck={deckWithWhitespace} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", {
@@ -323,7 +323,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
expect(screen.getByRole("button", { name: "Saving..." })).toBeDefined();
expect(screen.getByRole("button", { name: "Saving..." })).toHaveProperty(
@@ -352,7 +352,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -368,7 +368,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -384,7 +384,7 @@ describe("EditDeckModal", () => {
render(<EditDeckModal {...defaultProps} />);
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toContain(
@@ -430,7 +430,7 @@ describe("EditDeckModal", () => {
);
// Trigger error
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeDefined();
});
diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx
index 46f1d4b..e589900 100644
--- a/src/client/components/EditDeckModal.tsx
+++ b/src/client/components/EditDeckModal.tsx
@@ -96,18 +96,7 @@ export function EditDeckModal({
role="dialog"
aria-modal="true"
aria-labelledby="edit-deck-title"
- style={{
- position: "fixed",
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- zIndex: 1000,
- }}
+ className="fixed inset-0 bg-ink/40 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleClose();
@@ -119,83 +108,82 @@ export function EditDeckModal({
}
}}
>
- <div
- style={{
- backgroundColor: "white",
- padding: "1.5rem",
- borderRadius: "8px",
- width: "100%",
- maxWidth: "400px",
- margin: "1rem",
- }}
- >
- <h2 id="edit-deck-title" style={{ marginTop: 0 }}>
- Edit Deck
- </h2>
-
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- </div>
- )}
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-deck-name"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Name
- </label>
- <input
- id="edit-deck-name"
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- required
- maxLength={255}
- disabled={isSubmitting}
- style={{ width: "100%", boxSizing: "border-box" }}
- />
- </div>
-
- <div style={{ marginBottom: "1rem" }}>
- <label
- htmlFor="edit-deck-description"
- style={{ display: "block", marginBottom: "0.25rem" }}
- >
- Description (optional)
- </label>
- <textarea
- id="edit-deck-description"
- value={description}
- onChange={(e) => setDescription(e.target.value)}
- maxLength={1000}
- disabled={isSubmitting}
- rows={3}
- style={{
- width: "100%",
- boxSizing: "border-box",
- resize: "vertical",
- }}
- />
- </div>
-
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "flex-end",
- }}
+ <div className="bg-white rounded-2xl shadow-xl w-full max-w-md animate-scale-in">
+ <div className="p-6">
+ <h2
+ id="edit-deck-title"
+ className="font-display text-xl font-medium text-ink mb-6"
>
- <button type="button" onClick={handleClose} disabled={isSubmitting}>
- Cancel
- </button>
- <button type="submit" disabled={isSubmitting || !name.trim()}>
- {isSubmitting ? "Saving..." : "Save"}
- </button>
- </div>
- </form>
+ Edit Deck
+ </h2>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20"
+ >
+ {error}
+ </div>
+ )}
+
+ <div>
+ <label
+ htmlFor="edit-deck-name"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Name
+ </label>
+ <input
+ id="edit-deck-name"
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ required
+ maxLength={255}
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="edit-deck-description"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Description{" "}
+ <span className="text-muted font-normal">(optional)</span>
+ </label>
+ <textarea
+ id="edit-deck-description"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ maxLength={1000}
+ disabled={isSubmitting}
+ rows={3}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
+ />
+ </div>
+
+ <div className="flex gap-3 justify-end pt-2">
+ <button
+ type="button"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="px-4 py-2 text-slate hover:bg-ivory rounded-lg transition-colors disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ type="submit"
+ disabled={isSubmitting || !name.trim()}
+ className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isSubmitting ? "Saving..." : "Save Changes"}
+ </button>
+ </div>
+ </form>
+ </div>
</div>
</div>
);
diff --git a/src/client/components/OfflineBanner.test.tsx b/src/client/components/OfflineBanner.test.tsx
index 41679d9..53ba815 100644
--- a/src/client/components/OfflineBanner.test.tsx
+++ b/src/client/components/OfflineBanner.test.tsx
@@ -79,7 +79,8 @@ describe("OfflineBanner", () => {
render(<OfflineBanner />);
const banner = screen.getByTestId("offline-banner");
- expect(banner.getAttribute("role")).toBe("status");
+ // <output> element has implicit role="status", so we check it's an output element
+ expect(banner.tagName.toLowerCase()).toBe("output");
expect(banner.getAttribute("aria-live")).toBe("polite");
});
});
diff --git a/src/client/components/OfflineBanner.tsx b/src/client/components/OfflineBanner.tsx
index faca3e7..bf94908 100644
--- a/src/client/components/OfflineBanner.tsx
+++ b/src/client/components/OfflineBanner.tsx
@@ -10,26 +10,27 @@ export function OfflineBanner() {
return (
<output
data-testid="offline-banner"
- role="status"
aria-live="polite"
- style={{
- backgroundColor: "#6c757d",
- color: "white",
- padding: "0.5rem 1rem",
- textAlign: "center",
- fontSize: "0.875rem",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- gap: "0.5rem",
- }}
+ className="bg-slate text-white py-2 px-4 text-sm flex items-center justify-center gap-2"
>
- <span aria-hidden="true">âš¡</span>
+ <svg
+ className="w-4 h-4 text-warning"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
+ />
+ </svg>
<span>
You're offline. Changes will sync when you reconnect.
{pendingCount > 0 && (
- <span data-testid="offline-pending-count">
- {" "}
+ <span data-testid="offline-pending-count" className="ml-1 opacity-80">
({pendingCount} pending)
</span>
)}
diff --git a/src/client/components/SyncButton.tsx b/src/client/components/SyncButton.tsx
index 1ebfa2e..82a6c68 100644
--- a/src/client/components/SyncButton.tsx
+++ b/src/client/components/SyncButton.tsx
@@ -9,13 +9,6 @@ export function SyncButton() {
const isDisabled = !isOnline || isSyncing;
- const getButtonText = (): string => {
- if (isSyncing) {
- return "Syncing...";
- }
- return "Sync";
- };
-
return (
<button
type="button"
@@ -23,17 +16,55 @@ export function SyncButton() {
onClick={handleSync}
disabled={isDisabled}
title={!isOnline ? "Cannot sync while offline" : undefined}
- style={{
- padding: "0.25rem 0.5rem",
- borderRadius: "4px",
- border: "1px solid #dee2e6",
- backgroundColor: isDisabled ? "#e9ecef" : "#007bff",
- color: isDisabled ? "#6c757d" : "white",
- cursor: isDisabled ? "not-allowed" : "pointer",
- fontSize: "0.875rem",
- }}
+ className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
+ isDisabled
+ ? "bg-ivory text-muted cursor-not-allowed"
+ : "bg-primary text-white hover:bg-primary-dark active:scale-[0.98]"
+ }`}
>
- {getButtonText()}
+ {isSyncing ? (
+ <>
+ <svg
+ className="w-4 h-4 animate-spin"
+ fill="none"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ <span>Syncing...</span>
+ </>
+ ) : (
+ <>
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+ />
+ </svg>
+ <span>Sync</span>
+ </>
+ )}
</button>
);
}
diff --git a/src/client/components/SyncStatusIndicator.tsx b/src/client/components/SyncStatusIndicator.tsx
index 23e3ec6..0f555ca 100644
--- a/src/client/components/SyncStatusIndicator.tsx
+++ b/src/client/components/SyncStatusIndicator.tsx
@@ -20,63 +20,115 @@ export function SyncStatusIndicator() {
return "Synced";
};
- const getStatusColor = (): string => {
+ const getStatusStyles = (): string => {
if (!isOnline) {
- return "#6c757d"; // gray
+ return "bg-muted/10 text-muted";
}
if (isSyncing) {
- return "#007bff"; // blue
+ return "bg-info/10 text-info";
}
if (status === SyncStatus.Error) {
- return "#dc3545"; // red
+ return "bg-error/10 text-error";
}
if (pendingCount > 0) {
- return "#ffc107"; // yellow
+ return "bg-warning/10 text-warning";
}
- return "#28a745"; // green
+ return "bg-success/10 text-success";
};
- const getStatusIcon = (): string => {
+ const getStatusIcon = () => {
if (!isOnline) {
- return "\u25CB"; // hollow circle
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <circle cx="10" cy="10" r="4" />
+ </svg>
+ );
}
if (isSyncing) {
- return "\u21BB"; // rotating arrows
+ return (
+ <svg
+ className="w-3.5 h-3.5 animate-spin"
+ fill="none"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ );
}
if (status === SyncStatus.Error) {
- return "\u2717"; // cross mark
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <path
+ fillRule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
}
if (pendingCount > 0) {
- return "\u25D4"; // partial circle
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <path
+ fillRule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
}
- return "\u2713"; // check mark
+ return (
+ <svg
+ className="w-3.5 h-3.5"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ aria-hidden="true"
+ >
+ <path
+ fillRule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
+ clipRule="evenodd"
+ />
+ </svg>
+ );
};
return (
<div
data-testid="sync-status-indicator"
- style={{
- display: "inline-flex",
- alignItems: "center",
- gap: "0.25rem",
- padding: "0.25rem 0.5rem",
- borderRadius: "4px",
- backgroundColor: "#f8f9fa",
- border: "1px solid #dee2e6",
- fontSize: "0.875rem",
- }}
+ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${getStatusStyles()}`}
title={lastError || undefined}
>
- <span
- style={{
- color: getStatusColor(),
- fontWeight: "bold",
- }}
- aria-hidden="true"
- >
- {getStatusIcon()}
- </span>
- <span style={{ color: getStatusColor() }}>{getStatusText()}</span>
+ {getStatusIcon()}
+ <span>{getStatusText()}</span>
</div>
);
}
diff --git a/src/client/main.tsx b/src/client/main.tsx
index bff0889..4809bc1 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { AuthProvider, SyncProvider } from "./stores";
+import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index 0589073..e4ecade 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -151,7 +151,8 @@ describe("DeckDetailPage", () => {
renderWithProviders();
- expect(screen.getByText("Loading...")).toBeDefined();
+ // Loading state shows spinner (svg with animate-spin class)
+ expect(document.querySelector(".animate-spin")).toBeDefined();
});
it("displays empty state when no cards exist", async () => {
@@ -168,9 +169,9 @@ describe("DeckDetailPage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.getByText("This deck has no cards yet.")).toBeDefined();
+ expect(screen.getByText("No cards yet")).toBeDefined();
});
- expect(screen.getByText("Add cards to start studying!")).toBeDefined();
+ expect(screen.getByText("Add cards to start studying")).toBeDefined();
});
it("displays list of cards", async () => {
@@ -208,7 +209,7 @@ describe("DeckDetailPage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.getByRole("heading", { name: "Cards (2)" })).toBeDefined();
+ expect(screen.getByText("(2)")).toBeDefined();
});
});
@@ -226,9 +227,9 @@ describe("DeckDetailPage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.getByText("State: New")).toBeDefined();
+ expect(screen.getByText("New")).toBeDefined();
});
- expect(screen.getByText("State: Review")).toBeDefined();
+ expect(screen.getByText("Review")).toBeDefined();
});
it("displays card stats (reps and lapses)", async () => {
@@ -245,11 +246,10 @@ describe("DeckDetailPage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.getByText("Reviews: 0")).toBeDefined();
+ expect(screen.getByText("0 reviews")).toBeDefined();
});
- expect(screen.getByText("Reviews: 5")).toBeDefined();
- expect(screen.getByText("Lapses: 0")).toBeDefined();
- expect(screen.getByText("Lapses: 1")).toBeDefined();
+ expect(screen.getByText("5 reviews")).toBeDefined();
+ expect(screen.getByText("1 lapses")).toBeDefined();
});
it("displays error on API failure for deck", async () => {
@@ -391,7 +391,9 @@ describe("DeckDetailPage", () => {
expect(screen.getByText("Hello")).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete card",
+ });
expect(deleteButtons.length).toBe(2);
});
@@ -414,7 +416,9 @@ describe("DeckDetailPage", () => {
expect(screen.getByText("Hello")).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete card",
+ });
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
await user.click(firstDeleteButton);
@@ -445,7 +449,9 @@ describe("DeckDetailPage", () => {
expect(screen.getByText("Hello")).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete card",
+ });
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
await user.click(firstDeleteButton);
@@ -488,18 +494,20 @@ describe("DeckDetailPage", () => {
expect(screen.getByText("Hello")).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete card",
+ });
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
await user.click(firstDeleteButton);
}
- // Find the Delete button in the modal (not the card list)
- const modalDeleteButtons = screen.getAllByRole("button", {
- name: "Delete",
- });
- const confirmDeleteButton = modalDeleteButtons.find((btn) =>
- btn.closest('[role="dialog"]'),
+ // Find the Delete button in the modal (using the button's text content)
+ const dialog = screen.getByRole("dialog");
+ const modalButtons = dialog.querySelectorAll("button");
+ // Find the button with "Delete" text (not "Cancel")
+ const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
+ btn.textContent?.includes("Delete"),
);
if (confirmDeleteButton) {
await user.click(confirmDeleteButton);
@@ -518,9 +526,7 @@ describe("DeckDetailPage", () => {
// Verify card count updated
await waitFor(() => {
- expect(
- screen.getByRole("heading", { name: "Cards (1)" }),
- ).toBeDefined();
+ expect(screen.getByText("(1)")).toBeDefined();
});
});
@@ -550,18 +556,20 @@ describe("DeckDetailPage", () => {
expect(screen.getByText("Hello")).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete card",
+ });
const firstDeleteButton = deleteButtons[0];
if (firstDeleteButton) {
await user.click(firstDeleteButton);
}
- // Find the Delete button in the modal
- const modalDeleteButtons = screen.getAllByRole("button", {
- name: "Delete",
- });
- const confirmDeleteButton = modalDeleteButtons.find((btn) =>
- btn.closest('[role="dialog"]'),
+ // Find the Delete button in the modal (using the button's text content)
+ const dialog = screen.getByRole("dialog");
+ const modalButtons = dialog.querySelectorAll("button");
+ // Find the button with "Delete" text (not "Cancel")
+ const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
+ btn.textContent?.includes("Delete"),
);
if (confirmDeleteButton) {
await user.click(confirmDeleteButton);
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index 3d7ffb5..cb1e3fb 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -31,6 +31,13 @@ const CardStateLabels: Record<number, string> = {
3: "Relearning",
};
+const CardStateColors: Record<number, string> = {
+ 0: "bg-info/10 text-info",
+ 1: "bg-warning/10 text-warning",
+ 2: "bg-success/10 text-success",
+ 3: "bg-error/10 text-error",
+};
+
export function DeckDetailPage() {
const { deckId } = useParams<{ deckId: string }>();
const [deck, setDeck] = useState<Deck | null>(null);
@@ -114,195 +121,318 @@ export function DeckDetailPage() {
if (!deckId) {
return (
- <div>
- <p>Invalid deck ID</p>
- <Link href="/">Back to decks</Link>
+ <div className="min-h-screen bg-cream flex items-center justify-center">
+ <div className="text-center">
+ <p className="text-muted mb-4">Invalid deck ID</p>
+ <Link
+ href="/"
+ className="text-primary hover:text-primary-dark font-medium"
+ >
+ Back to decks
+ </Link>
+ </div>
</div>
);
}
return (
- <div>
- <header style={{ marginBottom: "1rem" }}>
- <Link href="/" style={{ textDecoration: "none" }}>
- &larr; Back to Decks
- </Link>
- </header>
-
- {isLoading && <p>Loading...</p>}
-
- {error && (
- <div role="alert" style={{ color: "red" }}>
- {error}
- <button
- type="button"
- onClick={fetchData}
- style={{ marginLeft: "0.5rem" }}
+ <div className="min-h-screen bg-cream">
+ {/* Header */}
+ <header className="bg-white border-b border-border/50">
+ <div className="max-w-4xl mx-auto px-4 py-4">
+ <Link
+ href="/"
+ className="inline-flex items-center gap-2 text-muted hover:text-slate transition-colors text-sm"
>
- Retry
- </button>
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M15 19l-7-7 7-7"
+ />
+ </svg>
+ Back to Decks
+ </Link>
</div>
- )}
+ </header>
- {!isLoading && !error && deck && (
- <main>
- <div style={{ marginBottom: "1.5rem" }}>
- <h1 style={{ margin: 0 }}>{deck.name}</h1>
- {deck.description && (
- <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
- {deck.description}
- </p>
- )}
+ {/* Main Content */}
+ <main className="max-w-4xl mx-auto px-4 py-8">
+ {/* Loading State */}
+ {isLoading && (
+ <div className="flex items-center justify-center py-12">
+ <svg
+ className="animate-spin h-8 w-8 text-primary"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
</div>
+ )}
+ {/* Error State */}
+ {error && (
<div
- style={{
- display: "flex",
- gap: "0.5rem",
- marginBottom: "1rem",
- }}
+ role="alert"
+ className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between"
>
- <Link href={`/decks/${deckId}/study`}>
+ <span className="text-error">{error}</span>
+ <button
+ type="button"
+ onClick={fetchData}
+ className="text-error hover:text-error/80 font-medium text-sm"
+ >
+ Retry
+ </button>
+ </div>
+ )}
+
+ {/* Deck Content */}
+ {!isLoading && !error && deck && (
+ <div className="animate-fade-in">
+ {/* Deck Header */}
+ <div className="mb-8">
+ <h1 className="font-display text-3xl font-semibold text-ink mb-2">
+ {deck.name}
+ </h1>
+ {deck.description && (
+ <p className="text-muted">{deck.description}</p>
+ )}
+ </div>
+
+ {/* Study Button */}
+ <div className="mb-8">
+ <Link
+ href={`/decks/${deckId}/study`}
+ className="inline-flex items-center gap-2 bg-success hover:bg-success/90 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
+ />
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ Study Now
+ </Link>
+ </div>
+
+ {/* Cards Section */}
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="font-display text-xl font-medium text-slate">
+ Cards{" "}
+ <span className="text-muted font-normal">({cards.length})</span>
+ </h2>
<button
type="button"
- style={{
- backgroundColor: "#28a745",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: "pointer",
- }}
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
>
- Study Now
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ Add Card
</button>
- </Link>
- </div>
-
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h2 style={{ margin: 0 }}>Cards ({cards.length})</h2>
- <button type="button" onClick={() => setIsCreateModalOpen(true)}>
- Add Card
- </button>
- </div>
-
- {cards.length === 0 && (
- <div>
- <p>This deck has no cards yet.</p>
- <p>Add cards to start studying!</p>
</div>
- )}
-
- {cards.length > 0 && (
- <ul style={{ listStyle: "none", padding: 0 }}>
- {cards.map((card) => (
- <li
- key={card.id}
- style={{
- border: "1px solid #ccc",
- padding: "1rem",
- marginBottom: "0.5rem",
- borderRadius: "4px",
- }}
+
+ {/* Empty State */}
+ {cards.length === 0 && (
+ <div className="text-center py-12 bg-white rounded-xl border border-border/50">
+ <div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center">
+ <svg
+ className="w-7 h-7 text-muted"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={1.5}
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
+ />
+ </svg>
+ </div>
+ <h3 className="font-display text-lg font-medium text-slate mb-2">
+ No cards yet
+ </h3>
+ <p className="text-muted text-sm mb-4">
+ Add cards to start studying
+ </p>
+ <button
+ type="button"
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200"
>
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ Add Your First Card
+ </button>
+ </div>
+ )}
+
+ {/* Card List */}
+ {cards.length > 0 && (
+ <div className="space-y-3">
+ {cards.map((card, index) => (
<div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "flex-start",
- }}
+ key={card.id}
+ className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200"
+ style={{ animationDelay: `${index * 30}ms` }}
>
- <div style={{ flex: 1, minWidth: 0 }}>
- <div
- style={{
- display: "flex",
- gap: "1rem",
- marginBottom: "0.5rem",
- }}
- >
- <div style={{ flex: 1, minWidth: 0 }}>
- <strong>Front:</strong>
- <p
- style={{
- margin: "0.25rem 0 0 0",
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
- >
- {card.front}
- </p>
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ {/* Front/Back Preview */}
+ <div className="grid grid-cols-2 gap-4 mb-3">
+ <div>
+ <span className="text-xs font-medium text-muted uppercase tracking-wide">
+ Front
+ </span>
+ <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
+ {card.front}
+ </p>
+ </div>
+ <div>
+ <span className="text-xs font-medium text-muted uppercase tracking-wide">
+ Back
+ </span>
+ <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
+ {card.back}
+ </p>
+ </div>
</div>
- <div style={{ flex: 1, minWidth: 0 }}>
- <strong>Back:</strong>
- <p
- style={{
- margin: "0.25rem 0 0 0",
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
+
+ {/* Card Stats */}
+ <div className="flex items-center gap-3 text-xs">
+ <span
+ className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`}
>
- {card.back}
- </p>
+ {CardStateLabels[card.state] || "Unknown"}
+ </span>
+ <span className="text-muted">
+ {card.reps} reviews
+ </span>
+ {card.lapses > 0 && (
+ <span className="text-muted">
+ {card.lapses} lapses
+ </span>
+ )}
</div>
</div>
- <div
- style={{
- display: "flex",
- gap: "1rem",
- fontSize: "0.875rem",
- color: "#666",
- }}
- >
- <span>
- State: {CardStateLabels[card.state] || "Unknown"}
- </span>
- <span>Reviews: {card.reps}</span>
- <span>Lapses: {card.lapses}</span>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1 shrink-0">
+ <button
+ type="button"
+ onClick={() => setEditingCard(card)}
+ className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+ title="Edit card"
+ >
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ <button
+ type="button"
+ onClick={() => setDeletingCard(card)}
+ className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
+ title="Delete card"
+ >
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ </button>
</div>
</div>
- <div
- style={{
- display: "flex",
- gap: "0.5rem",
- marginLeft: "1rem",
- }}
- >
- <button
- type="button"
- onClick={() => setEditingCard(card)}
- >
- Edit
- </button>
- <button
- type="button"
- onClick={() => setDeletingCard(card)}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.5rem 1rem",
- borderRadius: "4px",
- cursor: "pointer",
- }}
- >
- Delete
- </button>
- </div>
</div>
- </li>
- ))}
- </ul>
- )}
- </main>
- )}
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </main>
+ {/* Modals */}
{deckId && (
<CreateCardModal
isOpen={isCreateModalOpen}
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
index 18c2e76..5b8489a 100644
--- a/src/client/pages/HomePage.test.tsx
+++ b/src/client/pages/HomePage.test.tsx
@@ -137,7 +137,8 @@ describe("HomePage", () => {
renderWithProviders();
- expect(screen.getByText("Loading decks...")).toBeDefined();
+ // Loading state shows spinner (svg with animate-spin class)
+ expect(document.querySelector(".animate-spin")).toBeDefined();
});
it("displays empty state when no decks exist", async () => {
@@ -151,10 +152,10 @@ describe("HomePage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.getByText("You don't have any decks yet.")).toBeDefined();
+ expect(screen.getByText("No decks yet")).toBeDefined();
});
expect(
- screen.getByText("Create your first deck to start learning!"),
+ screen.getByText("Create your first deck to start learning"),
).toBeDefined();
});
@@ -255,7 +256,7 @@ describe("HomePage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.queryByText("Loading decks...")).toBeNull();
+ expect(screen.getByText("No decks yet")).toBeDefined();
});
await user.click(screen.getByRole("button", { name: "Logout" }));
@@ -290,11 +291,11 @@ describe("HomePage", () => {
).toBeDefined();
});
- // The deck item should only contain the heading, no description paragraph
- const deckItem = screen
+ // The deck card should only contain the heading, no description paragraph
+ const deckCard = screen
.getByRole("heading", { name: "No Description Deck" })
- .closest("li");
- expect(deckItem?.querySelectorAll("p").length).toBe(0);
+ .closest("div[class*='bg-white']");
+ expect(deckCard?.querySelectorAll("p").length).toBe(0);
});
it("passes auth header when fetching decks", async () => {
@@ -315,7 +316,7 @@ describe("HomePage", () => {
});
describe("Create Deck", () => {
- it("shows Create Deck button", async () => {
+ it("shows New Deck button", async () => {
vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
mockResponse({
ok: true,
@@ -326,13 +327,13 @@ describe("HomePage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.queryByText("Loading decks...")).toBeNull();
+ expect(screen.getByText("No decks yet")).toBeDefined();
});
- expect(screen.getByRole("button", { name: "Create Deck" })).toBeDefined();
+ expect(screen.getByRole("button", { name: /New Deck/i })).toBeDefined();
});
- it("opens modal when Create Deck button is clicked", async () => {
+ it("opens modal when New Deck button is clicked", async () => {
const user = userEvent.setup();
vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
mockResponse({
@@ -344,10 +345,10 @@ describe("HomePage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.queryByText("Loading decks...")).toBeNull();
+ expect(screen.getByText("No decks yet")).toBeDefined();
});
- await user.click(screen.getByRole("button", { name: "Create Deck" }));
+ await user.click(screen.getByRole("button", { name: /New Deck/i }));
expect(screen.getByRole("dialog")).toBeDefined();
expect(
@@ -367,10 +368,10 @@ describe("HomePage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.queryByText("Loading decks...")).toBeNull();
+ expect(screen.getByText("No decks yet")).toBeDefined();
});
- await user.click(screen.getByRole("button", { name: "Create Deck" }));
+ await user.click(screen.getByRole("button", { name: /New Deck/i }));
expect(screen.getByRole("dialog")).toBeDefined();
await user.click(screen.getByRole("button", { name: "Cancel" }));
@@ -413,11 +414,11 @@ describe("HomePage", () => {
renderWithProviders();
await waitFor(() => {
- expect(screen.queryByText("Loading decks...")).toBeNull();
+ expect(screen.getByText("No decks yet")).toBeDefined();
});
// Open modal
- await user.click(screen.getByRole("button", { name: "Create Deck" }));
+ await user.click(screen.getByRole("button", { name: /New Deck/i }));
// Fill in form
await user.type(screen.getByLabelText("Name"), "New Deck");
@@ -427,7 +428,7 @@ describe("HomePage", () => {
);
// Submit
- await user.click(screen.getByRole("button", { name: "Create" }));
+ await user.click(screen.getByRole("button", { name: "Create Deck" }));
// Modal should close
await waitFor(() => {
@@ -462,7 +463,7 @@ describe("HomePage", () => {
).toBeDefined();
});
- const editButtons = screen.getAllByRole("button", { name: "Edit" });
+ const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
expect(editButtons.length).toBe(2);
});
@@ -483,7 +484,7 @@ describe("HomePage", () => {
).toBeDefined();
});
- const editButtons = screen.getAllByRole("button", { name: "Edit" });
+ const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
await user.click(editButtons.at(0) as HTMLElement);
expect(screen.getByRole("dialog")).toBeDefined();
@@ -511,7 +512,7 @@ describe("HomePage", () => {
).toBeDefined();
});
- const editButtons = screen.getAllByRole("button", { name: "Edit" });
+ const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
await user.click(editButtons.at(0) as HTMLElement);
expect(screen.getByRole("dialog")).toBeDefined();
@@ -556,7 +557,7 @@ describe("HomePage", () => {
});
// Click Edit on first deck
- const editButtons = screen.getAllByRole("button", { name: "Edit" });
+ const editButtons = screen.getAllByRole("button", { name: "Edit deck" });
await user.click(editButtons.at(0) as HTMLElement);
// Update name
@@ -565,7 +566,7 @@ describe("HomePage", () => {
await user.type(nameInput, "Updated Japanese");
// Save
- await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.click(screen.getByRole("button", { name: "Save Changes" }));
// Modal should close
await waitFor(() => {
@@ -601,7 +602,9 @@ describe("HomePage", () => {
).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete deck",
+ });
expect(deleteButtons.length).toBe(2);
});
@@ -622,7 +625,9 @@ describe("HomePage", () => {
).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete deck",
+ });
await user.click(deleteButtons.at(0) as HTMLElement);
expect(screen.getByRole("dialog")).toBeDefined();
@@ -651,7 +656,9 @@ describe("HomePage", () => {
).toBeDefined();
});
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete deck",
+ });
await user.click(deleteButtons.at(0) as HTMLElement);
expect(screen.getByRole("dialog")).toBeDefined();
@@ -692,7 +699,9 @@ describe("HomePage", () => {
});
// Click Delete on first deck
- const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete deck",
+ });
await user.click(deleteButtons.at(0) as HTMLElement);
// Wait for modal to appear
diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx
index 783e623..fcae971 100644
--- a/src/client/pages/HomePage.tsx
+++ b/src/client/pages/HomePage.tsx
@@ -62,122 +62,226 @@ export function HomePage() {
}, [fetchDecks]);
return (
- <div>
- <header
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h1>Kioku</h1>
- <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
- <SyncStatusIndicator />
- <SyncButton />
- <button type="button" onClick={logout}>
- Logout
- </button>
+ <div className="min-h-screen bg-cream">
+ {/* Header */}
+ <header className="bg-white border-b border-border/50 sticky top-0 z-10">
+ <div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
+ <h1 className="font-display text-2xl font-semibold text-ink">
+ Kioku
+ </h1>
+ <div className="flex items-center gap-3">
+ <SyncStatusIndicator />
+ <SyncButton />
+ <button
+ type="button"
+ onClick={logout}
+ className="text-sm text-muted hover:text-slate transition-colors px-3 py-1.5 rounded-lg hover:bg-ivory"
+ >
+ Logout
+ </button>
+ </div>
</div>
</header>
- <main>
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h2 style={{ margin: 0 }}>Your Decks</h2>
- <button type="button" onClick={() => setIsCreateModalOpen(true)}>
- Create Deck
+ {/* Main Content */}
+ <main className="max-w-4xl mx-auto px-4 py-8">
+ {/* Section Header */}
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="font-display text-xl font-medium text-slate">
+ Your Decks
+ </h2>
+ <button
+ type="button"
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ New Deck
</button>
</div>
- {isLoading && <p>Loading decks...</p>}
+ {/* Loading State */}
+ {isLoading && (
+ <div className="flex items-center justify-center py-12">
+ <svg
+ className="animate-spin h-8 w-8 text-primary"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ </div>
+ )}
+ {/* Error State */}
{error && (
- <div role="alert" style={{ color: "red" }}>
- {error}
+ <div
+ role="alert"
+ className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between"
+ >
+ <span className="text-error">{error}</span>
<button
type="button"
onClick={fetchDecks}
- style={{ marginLeft: "0.5rem" }}
+ className="text-error hover:text-error/80 font-medium text-sm"
>
Retry
</button>
</div>
)}
+ {/* Empty State */}
{!isLoading && !error && decks.length === 0 && (
- <div>
- <p>You don't have any decks yet.</p>
- <p>Create your first deck to start learning!</p>
+ <div className="text-center py-16 animate-fade-in">
+ <div className="w-16 h-16 mx-auto mb-4 bg-ivory rounded-2xl flex items-center justify-center">
+ <svg
+ className="w-8 h-8 text-muted"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={1.5}
+ d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
+ />
+ </svg>
+ </div>
+ <h3 className="font-display text-lg font-medium text-slate mb-2">
+ No decks yet
+ </h3>
+ <p className="text-muted text-sm mb-6">
+ Create your first deck to start learning
+ </p>
+ <button
+ type="button"
+ onClick={() => setIsCreateModalOpen(true)}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ Create Your First Deck
+ </button>
</div>
)}
+ {/* Deck List */}
{!isLoading && !error && decks.length > 0 && (
- <ul style={{ listStyle: "none", padding: 0 }}>
- {decks.map((deck) => (
- <li
+ <div className="space-y-3 animate-fade-in">
+ {decks.map((deck, index) => (
+ <div
key={deck.id}
- style={{
- border: "1px solid #ccc",
- padding: "1rem",
- marginBottom: "0.5rem",
- borderRadius: "4px",
- }}
+ className="bg-white rounded-xl border border-border/50 p-5 shadow-card hover:shadow-md transition-all duration-200 group"
+ style={{ animationDelay: `${index * 50}ms` }}
>
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "flex-start",
- }}
- >
- <div>
- <h3 style={{ margin: 0 }}>
- <Link
- href={`/decks/${deck.id}`}
- style={{ textDecoration: "none", color: "inherit" }}
- >
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ <Link
+ href={`/decks/${deck.id}`}
+ className="block group-hover:text-primary transition-colors"
+ >
+ <h3 className="font-display text-lg font-medium text-slate truncate">
{deck.name}
- </Link>
- </h3>
+ </h3>
+ </Link>
{deck.description && (
- <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
+ <p className="text-muted text-sm mt-1 line-clamp-2">
{deck.description}
</p>
)}
</div>
- <div style={{ display: "flex", gap: "0.5rem" }}>
- <button type="button" onClick={() => setEditingDeck(deck)}>
- Edit
+ <div className="flex items-center gap-2 shrink-0">
+ <button
+ type="button"
+ onClick={() => setEditingDeck(deck)}
+ className="p-2 text-muted hover:text-slate hover:bg-ivory rounded-lg transition-colors"
+ title="Edit deck"
+ >
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
</button>
<button
type="button"
onClick={() => setDeletingDeck(deck)}
- style={{
- backgroundColor: "#dc3545",
- color: "white",
- border: "none",
- padding: "0.25rem 0.5rem",
- borderRadius: "4px",
- cursor: "pointer",
- }}
+ className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
+ title="Delete deck"
>
- Delete
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
</button>
</div>
</div>
- </li>
+ </div>
))}
- </ul>
+ </div>
)}
</main>
+ {/* Modals */}
<CreateDeckModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
diff --git a/src/client/pages/LoginPage.test.tsx b/src/client/pages/LoginPage.test.tsx
index 724f433..e4dac95 100644
--- a/src/client/pages/LoginPage.test.tsx
+++ b/src/client/pages/LoginPage.test.tsx
@@ -55,10 +55,11 @@ describe("LoginPage", () => {
it("renders login form", async () => {
renderWithProviders();
- expect(screen.getByRole("heading", { name: "Login" })).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Welcome back" })).toBeDefined();
expect(screen.getByLabelText("Username")).toBeDefined();
expect(screen.getByLabelText("Password")).toBeDefined();
- expect(screen.getByRole("button", { name: "Login" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Sign in" })).toBeDefined();
});
it("submits form and logs in successfully", async () => {
@@ -74,7 +75,7 @@ describe("LoginPage", () => {
await user.type(screen.getByLabelText("Username"), "testuser");
await user.type(screen.getByLabelText("Password"), "password123");
- await user.click(screen.getByRole("button", { name: "Login" }));
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(apiClient.login).toHaveBeenCalledWith("testuser", "password123");
@@ -92,7 +93,7 @@ describe("LoginPage", () => {
await user.type(screen.getByLabelText("Username"), "testuser");
await user.type(screen.getByLabelText("Password"), "wrongpassword");
- await user.click(screen.getByRole("button", { name: "Login" }));
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toBe("Invalid credentials");
@@ -107,7 +108,7 @@ describe("LoginPage", () => {
await user.type(screen.getByLabelText("Username"), "testuser");
await user.type(screen.getByLabelText("Password"), "password123");
- await user.click(screen.getByRole("button", { name: "Login" }));
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(screen.getByRole("alert").textContent).toBe(
@@ -126,10 +127,10 @@ describe("LoginPage", () => {
await user.type(screen.getByLabelText("Username"), "testuser");
await user.type(screen.getByLabelText("Password"), "password123");
- await user.click(screen.getByRole("button", { name: "Login" }));
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
- const button = screen.getByRole("button", { name: "Logging in..." });
+ const button = screen.getByRole("button", { name: /Signing in/ });
expect(button.hasAttribute("disabled")).toBe(true);
});
expect(
diff --git a/src/client/pages/LoginPage.tsx b/src/client/pages/LoginPage.tsx
index cc59105..89dd053 100644
--- a/src/client/pages/LoginPage.tsx
+++ b/src/client/pages/LoginPage.tsx
@@ -38,42 +38,113 @@ export function LoginPage() {
};
return (
- <div>
- <h1>Login</h1>
- <form onSubmit={handleSubmit}>
- {error && (
- <div role="alert" style={{ color: "red" }}>
- {error}
- </div>
- )}
- <div>
- <label htmlFor="username">Username</label>
- <input
- id="username"
- type="text"
- value={username}
- onChange={(e) => setUsername(e.target.value)}
- required
- autoComplete="username"
- disabled={isSubmitting}
- />
+ <div className="min-h-screen flex items-center justify-center px-4 py-12 bg-cream">
+ <div className="w-full max-w-sm animate-slide-up">
+ {/* Logo/Brand */}
+ <div className="text-center mb-10">
+ <h1 className="font-display text-4xl font-semibold text-ink tracking-tight">
+ Kioku
+ </h1>
+ <p className="mt-2 text-muted text-sm">Your memory, amplified</p>
</div>
- <div>
- <label htmlFor="password">Password</label>
- <input
- id="password"
- type="password"
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- required
- autoComplete="current-password"
- disabled={isSubmitting}
- />
+
+ {/* Login Card */}
+ <div className="bg-white rounded-2xl shadow-lg p-8 border border-border/50">
+ <h2 className="font-display text-xl font-medium text-slate mb-6">
+ Welcome back
+ </h2>
+
+ <form onSubmit={handleSubmit} className="space-y-5">
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 text-error text-sm px-4 py-3 rounded-lg border border-error/20"
+ >
+ {error}
+ </div>
+ )}
+
+ <div>
+ <label
+ htmlFor="username"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Username
+ </label>
+ <input
+ id="username"
+ type="text"
+ value={username}
+ onChange={(e) => setUsername(e.target.value)}
+ required
+ autoComplete="username"
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ placeholder="Enter your username"
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="password"
+ className="block text-sm font-medium text-slate mb-1.5"
+ >
+ Password
+ </label>
+ <input
+ id="password"
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ required
+ autoComplete="current-password"
+ disabled={isSubmitting}
+ className="w-full px-4 py-2.5 bg-ivory border border-border rounded-lg text-slate placeholder-muted transition-all duration-200 hover:border-muted focus:border-primary focus:ring-2 focus:ring-primary/10 disabled:opacity-50 disabled:cursor-not-allowed"
+ placeholder="Enter your password"
+ />
+ </div>
+
+ <button
+ type="submit"
+ disabled={isSubmitting}
+ className="w-full bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-4 rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98] shadow-sm hover:shadow-md"
+ >
+ {isSubmitting ? (
+ <span className="flex items-center justify-center gap-2">
+ <svg
+ className="animate-spin h-4 w-4"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
+ Signing in...
+ </span>
+ ) : (
+ "Sign in"
+ )}
+ </button>
+ </form>
</div>
- <button type="submit" disabled={isSubmitting}>
- {isSubmitting ? "Logging in..." : "Login"}
- </button>
- </form>
+
+ {/* Footer note */}
+ <p className="text-center text-muted text-xs mt-6">
+ Spaced repetition learning
+ </p>
+ </div>
</div>
);
}
diff --git a/src/client/pages/NotFoundPage.tsx b/src/client/pages/NotFoundPage.tsx
index 289dab5..72531c1 100644
--- a/src/client/pages/NotFoundPage.tsx
+++ b/src/client/pages/NotFoundPage.tsx
@@ -2,10 +2,52 @@ import { Link } from "wouter";
export function NotFoundPage() {
return (
- <div>
- <h1>404 - Not Found</h1>
- <p>The page you're looking for doesn't exist.</p>
- <Link href="/">Go to Home</Link>
+ <div className="min-h-screen bg-cream flex items-center justify-center px-4">
+ <div className="text-center animate-fade-in">
+ <div className="w-20 h-20 mx-auto mb-6 bg-ivory rounded-2xl flex items-center justify-center">
+ <svg
+ className="w-10 h-10 text-muted"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={1.5}
+ d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ </div>
+ <h1 className="font-display text-6xl font-bold text-ink mb-2">404</h1>
+ <h2 className="font-display text-xl font-medium text-slate mb-4">
+ Page Not Found
+ </h2>
+ <p className="text-muted mb-8 max-w-sm mx-auto">
+ The page you're looking for doesn't exist or has been moved.
+ </p>
+ <Link
+ href="/"
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ <svg
+ className="w-5 h-5"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+ />
+ </svg>
+ Go Home
+ </Link>
+ </div>
</div>
);
}
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx
index bab9193..146322a 100644
--- a/src/client/pages/StudyPage.test.tsx
+++ b/src/client/pages/StudyPage.test.tsx
@@ -129,7 +129,8 @@ describe("StudyPage", () => {
renderWithProviders();
- expect(screen.getByText("Loading study session...")).toBeDefined();
+ // Loading state shows spinner (svg with animate-spin class)
+ expect(document.querySelector(".animate-spin")).toBeDefined();
});
it("renders deck name and back link", async () => {
@@ -147,7 +148,7 @@ describe("StudyPage", () => {
await waitFor(() => {
expect(
- screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }),
+ screen.getByRole("heading", { name: /Japanese Vocabulary/ }),
).toBeDefined();
});
@@ -229,7 +230,7 @@ describe("StudyPage", () => {
await waitFor(() => {
expect(
- screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }),
+ screen.getByRole("heading", { name: /Japanese Vocabulary/ }),
).toBeDefined();
});
});
@@ -252,9 +253,9 @@ describe("StudyPage", () => {
await waitFor(() => {
expect(screen.getByTestId("no-cards")).toBeDefined();
});
- expect(screen.getByText("No cards to study")).toBeDefined();
+ expect(screen.getByText("All caught up!")).toBeDefined();
expect(
- screen.getByText("There are no due cards in this deck right now."),
+ screen.getByText("No cards due for review right now"),
).toBeDefined();
});
});
@@ -633,7 +634,7 @@ describe("StudyPage", () => {
expect(screen.getByTestId("session-complete")).toBeDefined();
});
- expect(screen.getByText("Back to Deck")).toBeDefined();
+ expect(screen.getAllByText("Back to Deck").length).toBeGreaterThan(0);
expect(screen.getByText("All Decks")).toBeDefined();
});
});
diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx
index 03cb537..16c1a1c 100644
--- a/src/client/pages/StudyPage.tsx
+++ b/src/client/pages/StudyPage.tsx
@@ -29,11 +29,11 @@ const RatingLabels: Record<Rating, string> = {
4: "Easy",
};
-const RatingColors: Record<Rating, string> = {
- 1: "#dc3545",
- 2: "#fd7e14",
- 3: "#28a745",
- 4: "#007bff",
+const RatingStyles: Record<Rating, string> = {
+ 1: "bg-again hover:bg-again/90 focus:ring-again/30",
+ 2: "bg-hard hover:bg-hard/90 focus:ring-hard/30",
+ 3: "bg-good hover:bg-good/90 focus:ring-good/30",
+ 4: "bg-easy hover:bg-easy/90 focus:ring-easy/30",
};
export function StudyPage() {
@@ -217,9 +217,16 @@ export function StudyPage() {
if (!deckId) {
return (
- <div>
- <p>Invalid deck ID</p>
- <Link href="/">Back to decks</Link>
+ <div className="min-h-screen bg-cream flex items-center justify-center">
+ <div className="text-center">
+ <p className="text-muted mb-4">Invalid deck ID</p>
+ <Link
+ href="/"
+ className="text-primary hover:text-primary-dark font-medium"
+ >
+ Back to decks
+ </Link>
+ </div>
</div>
);
}
@@ -230,218 +237,259 @@ export function StudyPage() {
const remainingCards = cards.length - currentIndex;
return (
- <div style={{ maxWidth: "600px", margin: "0 auto", padding: "1rem" }}>
- <header style={{ marginBottom: "1rem" }}>
- <Link href={`/decks/${deckId}`} style={{ textDecoration: "none" }}>
- &larr; Back to Deck
- </Link>
- </header>
-
- {isLoading && <p>Loading study session...</p>}
-
- {error && (
- <div role="alert" style={{ color: "red", marginBottom: "1rem" }}>
- {error}
- <button
- type="button"
- onClick={fetchData}
- style={{ marginLeft: "0.5rem" }}
+ <div className="min-h-screen bg-cream flex flex-col">
+ {/* Header */}
+ <header className="bg-white border-b border-border/50 shrink-0">
+ <div className="max-w-2xl mx-auto px-4 py-4">
+ <Link
+ href={`/decks/${deckId}`}
+ className="inline-flex items-center gap-2 text-muted hover:text-slate transition-colors text-sm"
>
- Retry
- </button>
+ <svg
+ className="w-4 h-4"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M15 19l-7-7 7-7"
+ />
+ </svg>
+ Back to Deck
+ </Link>
</div>
- )}
+ </header>
- {!isLoading && !error && deck && (
- <>
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: "1rem",
- }}
- >
- <h1 style={{ margin: 0 }}>Study: {deck.name}</h1>
- {!isSessionComplete && !hasNoCards && (
- <span
- data-testid="remaining-count"
- style={{
- backgroundColor: "#f0f0f0",
- padding: "0.25rem 0.75rem",
- borderRadius: "12px",
- fontSize: "0.875rem",
- }}
- >
- {remainingCards} remaining
- </span>
- )}
+ {/* Main Content */}
+ <main className="flex-1 flex flex-col max-w-2xl mx-auto w-full px-4 py-6">
+ {/* Loading State */}
+ {isLoading && (
+ <div className="flex-1 flex items-center justify-center">
+ <svg
+ className="animate-spin h-8 w-8 text-primary"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ fill="none"
+ />
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
+ />
+ </svg>
</div>
+ )}
- {hasNoCards && (
- <div
- data-testid="no-cards"
- style={{
- textAlign: "center",
- padding: "3rem 1rem",
- backgroundColor: "#f8f9fa",
- borderRadius: "8px",
- }}
+ {/* Error State */}
+ {error && (
+ <div
+ role="alert"
+ className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between mb-4"
+ >
+ <span className="text-error">{error}</span>
+ <button
+ type="button"
+ onClick={fetchData}
+ className="text-error hover:text-error/80 font-medium text-sm"
>
- <h2 style={{ marginTop: 0 }}>No cards to study</h2>
- <p style={{ color: "#666" }}>
- There are no due cards in this deck right now.
- </p>
- <Link href={`/decks/${deckId}`}>
- <button type="button">Back to Deck</button>
- </Link>
+ Retry
+ </button>
+ </div>
+ )}
+
+ {/* Study Content */}
+ {!isLoading && !error && deck && (
+ <div className="flex-1 flex flex-col animate-fade-in">
+ {/* Study Header */}
+ <div className="flex items-center justify-between mb-6">
+ <h1 className="font-display text-xl font-medium text-slate truncate">
+ {deck.name}
+ </h1>
+ {!isSessionComplete && !hasNoCards && (
+ <span
+ data-testid="remaining-count"
+ className="bg-ivory text-slate px-3 py-1 rounded-full text-sm font-medium"
+ >
+ {remainingCards} remaining
+ </span>
+ )}
</div>
- )}
-
- {isSessionComplete && (
- <div
- data-testid="session-complete"
- style={{
- textAlign: "center",
- padding: "3rem 1rem",
- backgroundColor: "#d4edda",
- borderRadius: "8px",
- }}
- >
- <h2 style={{ marginTop: 0, color: "#155724" }}>
- Session Complete!
- </h2>
- <p style={{ fontSize: "1.25rem", marginBottom: "1.5rem" }}>
- You reviewed{" "}
- <strong data-testid="completed-count">{completedCount}</strong>{" "}
- card{completedCount !== 1 ? "s" : ""}.
- </p>
+
+ {/* No Cards State */}
+ {hasNoCards && (
<div
- style={{
- display: "flex",
- gap: "1rem",
- justifyContent: "center",
- }}
+ data-testid="no-cards"
+ className="flex-1 flex items-center justify-center"
>
- <Link href={`/decks/${deckId}`}>
- <button type="button">Back to Deck</button>
- </Link>
- <Link href="/">
- <button type="button">All Decks</button>
- </Link>
+ <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-card max-w-sm w-full">
+ <div className="w-16 h-16 mx-auto mb-4 bg-success/10 rounded-2xl flex items-center justify-center">
+ <svg
+ className="w-8 h-8 text-success"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M5 13l4 4L19 7"
+ />
+ </svg>
+ </div>
+ <h2 className="font-display text-xl font-medium text-slate mb-2">
+ All caught up!
+ </h2>
+ <p className="text-muted text-sm mb-6">
+ No cards due for review right now
+ </p>
+ <Link
+ href={`/decks/${deckId}`}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ Back to Deck
+ </Link>
+ </div>
</div>
- </div>
- )}
-
- {currentCard && !isSessionComplete && (
- <div data-testid="study-card">
- <button
- type="button"
- data-testid="card-container"
- onClick={!isFlipped ? handleFlip : undefined}
- aria-label={
- isFlipped ? "Card showing answer" : "Click to reveal answer"
- }
- disabled={isFlipped}
- style={{
- width: "100%",
- border: "1px solid #ccc",
- borderRadius: "8px",
- padding: "2rem",
- minHeight: "200px",
- display: "flex",
- flexDirection: "column",
- justifyContent: "center",
- alignItems: "center",
- cursor: isFlipped ? "default" : "pointer",
- backgroundColor: isFlipped ? "#f8f9fa" : "white",
- transition: "background-color 0.2s",
- font: "inherit",
- }}
+ )}
+
+ {/* Session Complete State */}
+ {isSessionComplete && (
+ <div
+ data-testid="session-complete"
+ className="flex-1 flex items-center justify-center"
>
- {!isFlipped ? (
- <>
- <p
- data-testid="card-front"
- style={{
- fontSize: "1.25rem",
- textAlign: "center",
- margin: 0,
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
+ <div className="text-center py-12 px-6 bg-white rounded-2xl border border-border/50 shadow-lg max-w-sm w-full animate-scale-in">
+ <div className="w-20 h-20 mx-auto mb-6 bg-success/10 rounded-full flex items-center justify-center">
+ <svg
+ className="w-10 h-10 text-success"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
>
- {currentCard.front}
- </p>
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ </div>
+ <h2 className="font-display text-2xl font-semibold text-ink mb-2">
+ Session Complete!
+ </h2>
+ <p className="text-muted mb-1">You reviewed</p>
+ <p className="text-4xl font-display font-bold text-primary mb-1">
+ <span data-testid="completed-count">{completedCount}</span>
+ </p>
+ <p className="text-muted mb-8">
+ card{completedCount !== 1 ? "s" : ""}
+ </p>
+ <div className="flex flex-col sm:flex-row gap-3 justify-center">
+ <Link
+ href={`/decks/${deckId}`}
+ className="inline-flex items-center justify-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ Back to Deck
+ </Link>
+ <Link
+ href="/"
+ className="inline-flex items-center justify-center gap-2 bg-ivory hover:bg-border text-slate font-medium py-2.5 px-5 rounded-lg transition-all duration-200"
+ >
+ All Decks
+ </Link>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Active Study Card */}
+ {currentCard && !isSessionComplete && (
+ <div data-testid="study-card" className="flex-1 flex flex-col">
+ {/* Card */}
+ <button
+ type="button"
+ data-testid="card-container"
+ onClick={!isFlipped ? handleFlip : undefined}
+ aria-label={
+ isFlipped ? "Card showing answer" : "Click to reveal answer"
+ }
+ disabled={isFlipped}
+ className={`flex-1 min-h-[280px] bg-white rounded-2xl border border-border/50 shadow-card p-8 flex flex-col items-center justify-center text-center transition-all duration-300 ${
+ !isFlipped
+ ? "cursor-pointer hover:shadow-lg hover:border-primary/30 active:scale-[0.99]"
+ : "bg-ivory/50"
+ }`}
+ >
+ {!isFlipped ? (
+ <>
+ <p
+ data-testid="card-front"
+ className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed"
+ >
+ {currentCard.front}
+ </p>
+ <p className="mt-8 text-muted text-sm flex items-center gap-2">
+ <kbd className="px-2 py-0.5 bg-ivory rounded text-xs font-mono">
+ Space
+ </kbd>
+ <span>or tap to reveal</span>
+ </p>
+ </>
+ ) : (
<p
- style={{
- marginTop: "1.5rem",
- color: "#666",
- fontSize: "0.875rem",
- }}
+ data-testid="card-back"
+ className="text-xl md:text-2xl text-ink font-medium whitespace-pre-wrap break-words leading-relaxed animate-fade-in"
>
- Click or press Space to reveal
+ {currentCard.back}
</p>
- </>
- ) : (
- <p
- data-testid="card-back"
- style={{
- fontSize: "1.25rem",
- textAlign: "center",
- margin: 0,
- whiteSpace: "pre-wrap",
- wordBreak: "break-word",
- }}
+ )}
+ </button>
+
+ {/* Rating Buttons */}
+ {isFlipped && (
+ <div
+ data-testid="rating-buttons"
+ className="mt-6 grid grid-cols-4 gap-2 animate-slide-up"
>
- {currentCard.back}
- </p>
+ {([1, 2, 3, 4] as Rating[]).map((rating) => (
+ <button
+ key={rating}
+ type="button"
+ data-testid={`rating-${rating}`}
+ onClick={() => handleRating(rating)}
+ disabled={isSubmitting}
+ className={`py-4 px-2 rounded-xl text-white font-medium transition-all duration-200 focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.97] ${RatingStyles[rating]}`}
+ >
+ <span className="block text-base font-semibold">
+ {RatingLabels[rating]}
+ </span>
+ <span className="block text-xs opacity-80 mt-0.5">
+ {rating}
+ </span>
+ </button>
+ ))}
+ </div>
)}
- </button>
-
- {isFlipped && (
- <div
- data-testid="rating-buttons"
- style={{
- display: "flex",
- gap: "0.5rem",
- justifyContent: "center",
- marginTop: "1rem",
- }}
- >
- {([1, 2, 3, 4] as Rating[]).map((rating) => (
- <button
- key={rating}
- type="button"
- data-testid={`rating-${rating}`}
- onClick={() => handleRating(rating)}
- disabled={isSubmitting}
- style={{
- flex: 1,
- padding: "0.75rem 1rem",
- backgroundColor: RatingColors[rating],
- color: "white",
- border: "none",
- borderRadius: "4px",
- cursor: isSubmitting ? "not-allowed" : "pointer",
- opacity: isSubmitting ? 0.6 : 1,
- fontSize: "0.875rem",
- }}
- >
- <span style={{ display: "block", fontWeight: "bold" }}>
- {RatingLabels[rating]}
- </span>
- <span style={{ display: "block", fontSize: "0.75rem" }}>
- {rating}
- </span>
- </button>
- ))}
- </div>
- )}
- </div>
- )}
- </>
- )}
+ </div>
+ )}
+ </div>
+ )}
+ </main>
</div>
);
}
diff --git a/src/client/pwa.test.ts b/src/client/pwa.test.ts
index 18522c0..b19eb79 100644
--- a/src/client/pwa.test.ts
+++ b/src/client/pwa.test.ts
@@ -21,8 +21,8 @@ describe("PWA Configuration", () => {
expect(viteConfig).toContain(
'description: "A spaced repetition learning app"',
);
- expect(viteConfig).toContain('theme_color: "#4CAF50"');
- expect(viteConfig).toContain('background_color: "#ffffff"');
+ expect(viteConfig).toContain('theme_color: "#1a535c"');
+ expect(viteConfig).toContain('background_color: "#faf9f6"');
expect(viteConfig).toContain('display: "standalone"');
expect(viteConfig).toContain('start_url: "/"');
});
diff --git a/src/client/styles.css b/src/client/styles.css
new file mode 100644
index 0000000..2c10cfe
--- /dev/null
+++ b/src/client/styles.css
@@ -0,0 +1,129 @@
+@import "tailwindcss";
+
+@theme {
+ /* Color palette - Warm minimal Japanese aesthetic */
+ --color-cream: #faf9f6;
+ --color-ivory: #f5f4f0;
+ --color-ink: #1a1a1a;
+ --color-slate: #334155;
+ --color-muted: #94a3b8;
+ --color-border: #e2e0dc;
+
+ /* Primary - Deep teal */
+ --color-primary: #1a535c;
+ --color-primary-dark: #0f3439;
+ --color-primary-light: #2a7a87;
+
+ /* Rating colors */
+ --color-again: #dc2626;
+ --color-hard: #ea580c;
+ --color-good: #16a34a;
+ --color-easy: #2563eb;
+
+ /* Semantic colors */
+ --color-success: #059669;
+ --color-warning: #d97706;
+ --color-error: #c43535;
+ --color-info: #2563eb;
+
+ /* Typography */
+ --font-display: "Fraunces", "Georgia", serif;
+ --font-body: "DM Sans", system-ui, sans-serif;
+
+ /* Border radius */
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.75rem;
+ --radius-xl: 1rem;
+ --radius-2xl: 1.5rem;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03);
+ --shadow-md:
+ 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
+ --shadow-lg:
+ 0 10px 15px -3px rgb(0 0 0 / 0.05), 0 4px 6px -4px rgb(0 0 0 / 0.05);
+ --shadow-card:
+ 0 1px 3px 0 rgb(0 0 0 / 0.04), 0 1px 2px -1px rgb(0 0 0 / 0.04);
+
+ /* Animation */
+ --animate-fade-in: fade-in 0.3s ease-out;
+ --animate-slide-up: slide-up 0.3s ease-out;
+ --animate-scale-in: scale-in 0.2s ease-out;
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes scale-in {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+/* Base styles */
+html {
+ font-family: var(--font-body);
+ background-color: var(--color-cream);
+ color: var(--color-slate);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+}
+
+/* Focus styles */
+:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+/* Custom scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-ivory);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-muted);
+}
+
+/* Selection */
+::selection {
+ background-color: var(--color-primary);
+ color: white;
+}
diff --git a/vite.config.ts b/vite.config.ts
index b35f941..aa731e7 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,9 +1,11 @@
+import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
+ tailwindcss(),
react(),
VitePWA({
registerType: "autoUpdate",
@@ -12,8 +14,8 @@ export default defineConfig({
name: "Kioku",
short_name: "Kioku",
description: "A spaced repetition learning app",
- theme_color: "#4CAF50",
- background_color: "#ffffff",
+ theme_color: "#1a535c",
+ background_color: "#faf9f6",
display: "standalone",
scope: "/",
start_url: "/",