v0.9.5 web client interactivity improvement

This commit is contained in:
2026-07-06 14:07:16 -04:00
parent d7d31283d6
commit a23ed1fb21
5 changed files with 261 additions and 79 deletions
+6 -6
View File
@@ -4,21 +4,21 @@ go 1.26.0
require (
github.com/go-playground/validator/v10 v10.30.3
github.com/gofiber/fiber/v2 v2.52.13
github.com/gofiber/fiber/v2 v2.52.14
github.com/google/uuid v1.6.0
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226
github.com/mattn/go-sqlite3 v1.14.45
github.com/mattn/go-sqlite3 v1.14.47
golang.org/x/term v0.44.0
)
require (
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
@@ -26,8 +26,8 @@ require (
github.com/philhofer/fwd v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.71.0 // indirect
github.com/valyala/fasthttp v1.72.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/text v0.39.0 // indirect
)
+12
View File
@@ -2,6 +2,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -24,6 +26,8 @@ github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE=
github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -32,6 +36,8 @@ github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA=
@@ -56,6 +62,8 @@ github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCK
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -72,6 +80,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
@@ -99,5 +109,7 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+191 -71
View File
@@ -15,6 +15,8 @@ let gameState = {
authToken: null,
userId: null,
username: null,
authBusy: false,
newGameBusy: false,
};
// Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display
@@ -23,6 +25,26 @@ const pieceMap = {
'P': '♙', 'R': '♜', 'N': '♞', 'B': '♝', 'Q': '♛', 'K': '♚'
};
// How long a success message stays visible in a modal before it auto-closes
const MODAL_SUCCESS_DISPLAY_MS = 700;
// Shared helpers: show/clear a status line inside a modal. Distinct from
// flashErrorMessage, which is not visible while a modal's backdrop is up.
function setModalMessage(elementId, message, type = 'error') {
const el = document.getElementById(elementId);
if (!el) return;
el.textContent = message;
el.classList.remove('error', 'success');
el.classList.add('show', type);
}
function clearModalMessage(elementId) {
const el = document.getElementById(elementId);
if (!el) return;
el.textContent = '';
el.classList.remove('show', 'error', 'success');
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
const config = await getConfig();
@@ -115,6 +137,20 @@ function handleAuthClick() {
}
}
// Disables/enables every interactive control in the auth modal at once, and tracks
// whether a login/register request is in flight (or its success message is showing).
// Guards re-entrancy from the Enter-key handler, which bypasses individual button
// disabled state, and stops the user editing fields or switching tabs mid-request.
function setAuthModalBusy(busy) {
gameState.authBusy = busy;
document.getElementById('login-submit-btn').disabled = busy;
document.getElementById('register-submit-btn').disabled = busy;
document.getElementById('auth-cancel-btn').disabled = busy;
document.getElementById('auth-cancel-btn-2').disabled = busy;
document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy);
document.querySelectorAll('.auth-form input').forEach(i => i.disabled = busy);
}
function showAuthModal() {
document.getElementById('auth-modal-overlay').classList.add('show');
document.getElementById('login-identifier').focus();
@@ -127,12 +163,21 @@ function hideAuthModal() {
document.getElementById('auth-modal-overlay').classList.remove('show');
document.querySelectorAll('.auth-form input').forEach(input => input.value = '');
document.removeEventListener('keydown', handleAuthModalKeydown);
clearModalMessage('auth-modal-message');
setAuthModalBusy(false);
}
function handleAuthModalKeydown(e) {
const modal = document.getElementById('auth-modal-overlay');
if (!modal.classList.contains('show')) return;
// While a request is in flight, block just Enter (re-submit) and Escape
// (close); everything else (Tab, copy shortcuts, etc.) passes through.
if (gameState.authBusy) {
if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
hideAuthModal();
@@ -153,6 +198,7 @@ function switchAuthTab(tab) {
document.getElementById('login-form').style.display = tab === 'login' ? 'block' : 'none';
document.getElementById('register-form').style.display = tab === 'register' ? 'block' : 'none';
clearModalMessage('auth-modal-message');
}
// Shared helper: safely parse error response regardless of Content-Type
@@ -165,93 +211,125 @@ async function parseErrorResponse(response) {
}
async function handleLogin() {
if (gameState.authBusy) return;
const identifier = document.getElementById('login-identifier').value.trim();
const password = document.getElementById('login-password').value;
if (!identifier || !password) {
flashErrorMessage('Fill all fields');
setModalMessage('auth-modal-message', 'Fill all fields', 'error');
return;
}
const submitBtn = document.getElementById('login-submit-btn');
submitBtn.disabled = true;
setAuthModalBusy(true);
clearModalMessage('auth-modal-message');
let response;
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password })
});
if (!response.ok) {
const err = await parseErrorResponse(response);
flashErrorMessage(err.details || err.error || 'Login failed', 3000);
return;
}
const auth = await response.json();
gameState.authToken = auth.token;
gameState.userId = auth.userId;
gameState.username = auth.username;
localStorage.setItem('authToken', auth.token);
updateAuthIndicator(true);
hideAuthModal();
} catch (error) {
flashErrorMessage('Connection failed');
} finally {
submitBtn.disabled = false;
const errorInfo = handleApiError('login', error);
setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error');
setAuthModalBusy(false);
return;
}
if (!response.ok) {
const err = await parseErrorResponse(response);
setModalMessage('auth-modal-message', err.details || err.error || 'Login failed', 'error');
setAuthModalBusy(false);
return;
}
let auth;
try {
auth = await response.json();
} catch (error) {
console.error('Login: response OK but JSON parse failed:', error);
setModalMessage('auth-modal-message', 'Unexpected response from server', 'error');
setAuthModalBusy(false);
return;
}
gameState.authToken = auth.token;
gameState.userId = auth.userId;
gameState.username = auth.username;
localStorage.setItem('authToken', auth.token);
updateAuthIndicator(true);
setModalMessage('auth-modal-message', `Logged in as ${auth.username}`, 'success');
setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS);
}
async function handleRegister() {
if (gameState.authBusy) return;
const username = document.getElementById('register-username').value.trim();
const email = document.getElementById('register-email').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !password) {
flashErrorMessage('Username and password required');
setModalMessage('auth-modal-message', 'Username and password required', 'error');
return;
}
if (password.length < 8) {
flashErrorMessage('Password min 8 chars');
setModalMessage('auth-modal-message', 'Password min 8 chars', 'error');
return;
}
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
flashErrorMessage('Password needs a letter and number');
setModalMessage('auth-modal-message', 'Password needs a letter and number', 'error');
return;
}
const submitBtn = document.getElementById('register-submit-btn');
submitBtn.disabled = true;
setAuthModalBusy(true);
clearModalMessage('auth-modal-message');
const body = { username, password };
if (email) body.email = email;
let response;
try {
const body = { username, password };
if (email) body.email = email;
const response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, {
response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const err = await parseErrorResponse(response);
flashErrorMessage(err.details || err.error || 'Registration failed', 3000);
return;
}
const auth = await response.json();
gameState.authToken = auth.token;
gameState.userId = auth.userId;
gameState.username = auth.username;
localStorage.setItem('authToken', auth.token);
updateAuthIndicator(true);
hideAuthModal();
} catch (error) {
flashErrorMessage('Connection failed');
} finally {
submitBtn.disabled = false;
const errorInfo = handleApiError('register', error);
setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error');
setAuthModalBusy(false);
return;
}
if (!response.ok) {
const err = await parseErrorResponse(response);
setModalMessage('auth-modal-message', err.details || err.error || 'Registration failed', 'error');
setAuthModalBusy(false);
return;
}
let auth;
try {
auth = await response.json();
} catch (error) {
console.error('Register: response OK but JSON parse failed:', error);
setModalMessage('auth-modal-message', 'Unexpected response from server', 'error');
setAuthModalBusy(false);
return;
}
gameState.authToken = auth.token;
gameState.userId = auth.userId;
gameState.username = auth.username;
localStorage.setItem('authToken', auth.token);
updateAuthIndicator(true);
setModalMessage('auth-modal-message', `Account created, welcome ${auth.username}`, 'success');
setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS);
}
async function handleLogout() {
@@ -375,6 +453,19 @@ function updateTurnIndicator(state, turn) {
indicator.setAttribute('data-status', tooltipText);
}
// Disables/enables every interactive control in the new-game modal at once, and
// tracks whether a create-game request is in flight (or its success message is
// showing). Same rationale as setAuthModalBusy.
function setNewGameModalBusy(busy) {
gameState.newGameBusy = busy;
document.getElementById('start-game-btn').disabled = busy;
document.getElementById('cancel-btn').disabled = busy;
document.getElementById('computer-level').disabled = busy;
document.getElementById('search-time').disabled = busy;
document.getElementById('starting-fen').disabled = busy;
document.querySelectorAll('input[name="player-color"]').forEach(r => r.disabled = busy);
}
function showNewGameModal() {
const modal = document.getElementById('modal-overlay');
modal.classList.add('show');
@@ -385,6 +476,8 @@ function hideNewGameModal() {
const modal = document.getElementById('modal-overlay');
modal.classList.remove('show');
teardownModalKeyboardNav();
clearModalMessage('new-game-modal-message');
setNewGameModalBusy(false);
}
function setupModalKeyboardNav() {
@@ -399,6 +492,14 @@ function handleModalKeydown(e) {
const modal = document.getElementById('modal-overlay');
if (!modal.classList.contains('show')) return;
// While a request is in flight, block just Enter (re-submit) and Escape
// (close); the color/level/time shortcuts fall through as no-ops since
// those controls are disabled and there's nothing else bound to those keys.
if (gameState.newGameBusy) {
if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault();
return;
}
switch(e.key) {
case 'Enter':
e.preventDefault();
@@ -474,14 +575,16 @@ function copyHistory() {
}
async function startNewGame() {
if (gameState.newGameBusy) return;
const playerColor = document.querySelector('input[name="player-color"]:checked').value;
const computerLevel = parseInt(document.getElementById('computer-level').value);
const searchTime = parseInt(document.getElementById('search-time').value);
const startingFEN = document.getElementById('starting-fen').value.trim();
gameState.isPlayerWhite = (playerColor === 'white');
const willBePlayerWhite = (playerColor === 'white');
const whiteConfig = gameState.isPlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime };
const blackConfig = gameState.isPlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 };
const whiteConfig = willBePlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime };
const blackConfig = willBePlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 };
const requestBody = {
white: whiteConfig,
@@ -493,34 +596,51 @@ async function startNewGame() {
requestBody.fen = startingFEN;
}
setNewGameModalBusy(true);
clearModalMessage('new-game-modal-message');
let response;
try {
const response = await authFetch(`${gameState.apiUrl}/api/v1/games`, {
response = await authFetch(`${gameState.apiUrl}/api/v1/games`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorInfo = handleApiError('create game', null, response);
throw new Error(errorInfo.statusMessage);
}
const game = await response.json();
gameState.gameId = game.gameId;
gameState.moveList = [];
hideNewGameModal();
initializeBoard();
updateGameDisplay(game);
document.getElementById('undo-btn').disabled = true;
if (!gameState.isPlayerWhite) triggerComputerMove();
} catch (error) {
if (error.message === 'Failed to fetch') {
handleApiError('create game', error);
} else {
flashErrorMessage(error.message);
}
updateTurnIndicator('', '');
const errorInfo = handleApiError('create game', error);
setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error');
setNewGameModalBusy(false);
return;
}
if (!response.ok) {
const errorInfo = handleApiError('create game', null, response);
setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error');
setNewGameModalBusy(false);
return;
}
let game;
try {
game = await response.json();
} catch (error) {
console.error('Create game: response OK but JSON parse failed:', error);
setModalMessage('new-game-modal-message', 'Unexpected response from server', 'error');
setNewGameModalBusy(false);
return;
}
// isPlayerWhite is only committed to global state now that success is confirmed
gameState.isPlayerWhite = willBePlayerWhite;
gameState.gameId = game.gameId;
gameState.moveList = [];
initializeBoard();
updateGameDisplay(game);
document.getElementById('undo-btn').disabled = true;
if (!gameState.isPlayerWhite) triggerComputerMove();
setModalMessage('new-game-modal-message', `Game started - you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success');
setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS);
}
function initializeBoard() {
@@ -75,6 +75,8 @@
<button class="auth-tab" data-tab="register">Register</button>
</div>
<div id="auth-modal-message" class="modal-message"></div>
<!-- Login Form -->
<div id="login-form" class="auth-form">
<div class="form-group">
@@ -117,6 +119,7 @@
<div id="modal-overlay" class="modal-overlay">
<div class="modal">
<h2>New Game</h2>
<div id="new-game-modal-message" class="modal-message"></div>
<div class="form-group">
<label class="group-label">Your Color</label>
<div class="radio-group">
@@ -146,4 +149,4 @@
<script src="app.js"></script>
</body>
</html>
</html>
@@ -659,6 +659,53 @@ input[type="range"]::-webkit-slider-thumb {
cursor: pointer;
}
/* --- Modal status message (Issue 2) --- */
.modal-message {
display: none;
margin-bottom: 1rem;
padding: 0.6rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
}
.modal-message.show {
display: block;
}
.modal-message.error {
background: rgba(247, 118, 142, 0.12);
color: var(--tokyo-red);
border: 1px solid rgba(247, 118, 142, 0.4);
}
.modal-message.success {
background: rgba(158, 206, 106, 0.12);
color: var(--tokyo-green);
border: 1px solid rgba(158, 206, 106, 0.4);
}
/* --- Disabled state while a modal request is in flight (Issue 1) --- */
.auth-form input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-tab:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fen-input:disabled,
input[type="range"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-group input:disabled + span {
opacity: 0.5;
}
/* Mobile/Responsiveness */
@media (max-width: 978px) {
@@ -861,4 +908,4 @@ input[type="range"]::-webkit-slider-thumb {
width: clamp(360px, 83vw, 440px);
min-width: clamp(360px, 83vw, 440px);
}
}
}