handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
- title={authenticated ? `View ${entry.netid}\'s profile` : 'Log in to view profiles'}
- >
+
handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
+ title={authenticated ? `View ${entry.netid}'s profile` : 'Log in to view profiles'}
+ role={authenticated ? 'button' : undefined}
+ tabIndex={authenticated ? 0 : -1}
+ onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') handleAvatarClick(entry.avatar_url, entry.netid); }) : undefined}
+ >
+

{
+ if (!allowTimed && testMode === 'timed') {
+ // Update race state first so the server/client agree on mode
+ setRaceState(prev => ({
+ ...prev,
+ timedTest: { ...prev.timedTest, enabled: false },
+ settings: { ...(prev.settings||{}), testMode: 'snippet' }
+ }));
+ setTestMode('snippet');
+ // Load a snippet immediately to reflect the change
+ setTimeout(() => { loadNewSnippet && loadNewSnippet(); }, 0);
+ }
+ }, [allowTimed, testMode, setRaceState, setTestMode, loadNewSnippet]);
+
// Fetch subjects on mount
React.useEffect(() => {
const fetchSubjects = async () => {
@@ -272,7 +288,7 @@ function TestConfigurator({
{/* Mode Selection Group */}
{renderButton('snippet', testMode, setTestMode, 'Snippets', QuoteIcon)}
- {renderButton('timed', testMode, setTestMode, 'Timed', ClockIcon)}
+ {allowTimed && renderButton('timed', testMode, setTestMode, 'Timed', ClockIcon)}
{/* Separator */}
@@ -372,13 +388,15 @@ function TestConfigurator({
{/* Timed Mode Duration Wrapper */}
-
-
-
- {DURATIONS.map(duration => renderButton(duration, testDuration, setTestDuration, `${duration}s`))}
+ {allowTimed && (
+
+
+
+ {DURATIONS.map(duration => renderButton(duration, testDuration, setTestDuration, `${duration}s`))}
+
-
-
+
+ )}
{/* End Conditional Options Container */}
@@ -409,6 +427,7 @@ TestConfigurator.propTypes = {
setRaceState: PropTypes.func.isRequired,
loadNewSnippet: PropTypes.func,
onShowLeaderboard: PropTypes.func.isRequired,
+ allowTimed: PropTypes.bool,
};
export default TestConfigurator;
diff --git a/client/src/components/Typing.css b/client/src/components/Typing.css
index c52770c9..25109e0b 100644
--- a/client/src/components/Typing.css
+++ b/client/src/components/Typing.css
@@ -226,7 +226,26 @@
.incorrect {
color: var(--incorrect-color)!important;
background-color: var(--incorrect-bg-color)!important;
- text-decoration: underline wavy rgba(255, 65, 47, 0.55);
+ position: relative;
+}
+
+/* Visual indicator for incorrectly typed characters - solid underline */
+.snippet-display .incorrect::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 2.5px;
+ background-color: var(--incorrect-color);
+ opacity: 0.9;
+ border-radius: 1px;
+ pointer-events: none;
+}
+
+/* Caret cursor: remove red background, keep solid underline */
+:root[data-cursor='caret'] .snippet-display .incorrect {
+ background-color: transparent !important;
}
.shake-animation {
@@ -415,9 +434,9 @@
pointer-events: none;
z-index: 0; /* behind the text */
transition:
- transform var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1),
- width var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1),
- height var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1);
+ transform var(--cursor-glide-duration, 90ms) ease-in-out,
+ width var(--cursor-glide-duration, 90ms) ease-in-out,
+ height var(--cursor-glide-duration, 90ms) ease-in-out;
opacity: calc(var(--glide-cursor-enabled, 0)); /* 0 or 1 set by Settings */
border-radius: 2px;
backface-visibility: hidden;
@@ -430,8 +449,7 @@
.cursor-overlay.caret {
background: transparent;
- border-left: var(--caret-width, 3px) solid var(--caret-color);
- box-shadow: 0 0 0.001px var(--caret-color); /* sub-pixel hinting */
+ border-left: var(--caret-width, 2px) solid var(--caret-color);
z-index: 2; /* above text so thin caret remains visible */
animation: caretBlink 1.2s ease-in-out infinite;
}
diff --git a/client/src/components/Typing.jsx b/client/src/components/Typing.jsx
index d4a74d52..feed73ac 100644
--- a/client/src/components/Typing.jsx
+++ b/client/src/components/Typing.jsx
@@ -314,7 +314,23 @@ function Typing({
const updateFromAttr = () => {
const enabled = root.getAttribute('data-glide') === '1';
setGlideEnabled(enabled);
- if (!enabled) initialCursorSetRef.current = false;
+ // Keep CSS variable in sync so the overlay hides/shows immediately
+ try {
+ root.style.setProperty('--glide-cursor-enabled', enabled ? '1' : '0');
+ } catch (_) {}
+ if (!enabled) {
+ initialCursorSetRef.current = false;
+ // Ensure any existing overlay is completely hidden when glide is off
+ const overlay = cursorRef.current;
+ if (overlay) {
+ overlay.classList.remove('typing-active');
+ overlay.classList.remove('caret');
+ // leave 'block' state irrelevant; force hidden
+ overlay.style.opacity = '0';
+ // move out of view to avoid accidental paints if other rules override opacity
+ overlay.style.transform = 'translate3d(-9999px, -9999px, 0)';
+ }
+ }
};
updateFromAttr();
const observer = new MutationObserver(() => updateFromAttr());
@@ -354,9 +370,10 @@ function Typing({
newValue = currentInput.substring(cursorPosition);
}
- // Let our existing word locking handle this modified input
- raceHandleInput(newValue);
- setInput(typingState.input);
+ // Let our existing word locking handle this modified input
+ raceHandleInput(newValue);
+ // Reflect raw change immediately; locking will reconcile on next state tick
+ setInput(newValue);
}
}
};
@@ -608,10 +625,11 @@ function Typing({
};
}, [raceState.inProgress, raceState.startTime, raceState.completed, typingState.correctChars]); // Include typingState.correctChars
- // Handle typing input with word locking
+ // Handle typing input with word locking (snippet) or free-typing (timed)
const handleComponentInput = (e) => {
const newInput = e.target.value;
const text = raceState.snippet?.text || '';
+ const isTimedMode = !!(raceState.snippet?.is_timed_test);
// Check if new character is correct
const isMovingForward = newInput.length > input.length;
@@ -635,13 +653,19 @@ function Typing({
// Continue processing this first character instead of ignoring it
if (raceState.inProgress) {
- raceHandleInput(newInput);
+ const processed = raceHandleInput(newInput);
+ setInput(processed ?? newInput);
} else {
// Since raceState.inProgress hasn't updated yet in this render cycle,
// we need to directly set the input so the character appears
setInput(newInput);
- // Schedule an update after the state has changed
- setTimeout(() => raceHandleInput(newInput), 0);
+ // Schedule an update after the state has changed so startTime is initialized
+ setTimeout(() => {
+ const processed = raceHandleInput(newInput);
+ if (typeof processed === 'string') {
+ setInput(processed);
+ }
+ }, 0);
}
return;
}
@@ -660,9 +684,11 @@ function Typing({
wordCount: 15 // Request 1 more words
});
}
- // Prevent typing past the end of the snippet
- if (newInput.length >= text.length + 1) {
- return;
+ // In snippet mode, prevent typing past the end of the text. In timed mode,
+ // the server will append more words, so allow typing up to one char beyond
+ // (we early-request extra words above).
+ if (!isTimedMode) {
+ if (newInput.length >= text.length + 1) return;
}
// Check if there's a typing error (improved to check all characters)
@@ -677,7 +703,7 @@ function Typing({
}
// Only trigger shake and error message on a new error
- if (hasError && !isShaking) {
+ if (!isTimedMode && hasError && !isShaking) {
setIsShaking(true);
setShowErrorMessage(true);
@@ -692,12 +718,11 @@ function Typing({
}, 1500);
}
- // Use the handleInput function from RaceContext
- raceHandleInput(newInput);
+ // Use the handleInput function from RaceContext and capture sanitized value
+ const processed = raceHandleInput(newInput);
- // Update local input state to match what's in the typing state
- // This ensures the displayed input matches the processed input after word locking
- setInput(typingState.input);
+ // Immediately reflect the sanitized input to keep locked words intact
+ setInput(processed ?? newInput);
} else {
// Prevent typing past the end of the snippet
if (raceState.snippet && newInput.length > raceState.snippet.text.length) {
@@ -707,12 +732,12 @@ function Typing({
}
}
- // Sync input with typingState.input to ensure locked words can't be deleted
+ // Sync input with typingState.input to ensure locked words can't be deleted (snippet mode)
useEffect(() => {
- if (raceState.inProgress) {
+ if (raceState.inProgress && !raceState.snippet?.is_timed_test) {
setInput(typingState.input);
}
- }, [typingState.input, raceState.inProgress]);
+ }, [typingState.input, raceState.inProgress, raceState.snippet?.is_timed_test]);
// Prevent paste
const handlePaste = (e) => {
@@ -725,6 +750,7 @@ function Typing({
if (!raceState.snippet) return null;
const text = raceState.snippet.text;
+ const isTimedMode = !!(raceState.snippet?.is_timed_test);
// Split text by words, maintaining spaces between them
const renderNonBreakingText = () => {
@@ -740,11 +766,17 @@ function Typing({
const charPos = charIndex + i;
if (charPos < input.length) {
- if (input[charPos] === text[charPos] && !hasEncounteredError) {
- wordChars.push({word[i]});
+ if (isTimedMode) {
+ // Timed mode: per-character correctness, no cascade after first error
+ const cls = input[charPos] === text[charPos] ? 'correct' : 'incorrect';
+ wordChars.push({word[i]});
} else {
- hasEncounteredError = true;
- wordChars.push({word[i]});
+ if (input[charPos] === text[charPos] && !hasEncounteredError) {
+ wordChars.push({word[i]});
+ } else {
+ hasEncounteredError = true;
+ wordChars.push({word[i]});
+ }
}
} else if (charPos === input.length) {
wordChars.push({word[i]});
@@ -765,11 +797,16 @@ function Typing({
const spacePos = charIndex + word.length;
if (spacePos < input.length) {
- if (input[spacePos] === ' ' && !hasEncounteredError) {
- components.push( );
+ if (isTimedMode) {
+ const cls = input[spacePos] === ' ' ? 'correct' : 'incorrect';
+ components.push( );
} else {
- hasEncounteredError = true;
- components.push( );
+ if (input[spacePos] === ' ' && !hasEncounteredError) {
+ components.push( );
+ } else {
+ hasEncounteredError = true;
+ components.push( );
+ }
}
} else if (spacePos === input.length) {
components.push( );
@@ -807,8 +844,9 @@ function Typing({
const scrollY = container.scrollTop || 0;
// Visible delta within container + scroll offset -> content-relative coords
- const x = Math.round((rect.left - containerRect.left) + scrollX);
- const y = Math.round((rect.top - containerRect.top) + scrollY);
+ // Use sub-pixel precision for smoother glide on high‑DPI displays
+ const x = (rect.left - containerRect.left) + scrollX;
+ const y = (rect.top - containerRect.top) + scrollY;
// Determine caret vs block based on Settings-managed CSS var
const useCaret = (cursorStyleRef.current === 'caret');
@@ -829,8 +867,10 @@ function Typing({
overlay.classList.remove('typing-active');
}
- // Cursor-specific duration (caret snappier)
- overlay.style.setProperty('--cursor-glide-duration', useCaret ? '85ms' : '110ms');
+ // Cursor-specific duration
+ // - Caret: smooth glide
+ // - Block: short glide; snaps only for big jumps to avoid trailing
+ overlay.style.setProperty('--cursor-glide-duration', useCaret ? '90ms' : '65ms');
// First placement should not animate from origin
if (!initialCursorSetRef.current) {
@@ -842,9 +882,11 @@ function Typing({
overlay.style.transition = prev || '';
initialCursorSetRef.current = true;
} else {
- // For large vertical jumps (e.g., auto-scroll to next line), place instantly to avoid trailing
+ // For large vertical jumps (e.g., line wrap / scroll), place instantly.
+ // Horizontal moves (including spaces) should always glide when enabled.
const prevY = overlay.__prevY ?? y;
- const largeJump = Math.abs(prevY - y) > height * 1.2;
+ const dy = Math.abs(prevY - y);
+ const largeJump = dy > height * 1.2;
if (largeJump) {
const prev = overlay.style.transition;
overlay.style.transition = 'none';
diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx
index e0786241..de13d98c 100644
--- a/client/src/context/RaceContext.jsx
+++ b/client/src/context/RaceContext.jsx
@@ -618,11 +618,12 @@ export const RaceProvider = ({ children }) => {
});
};
- // Handle text input, enforce word locking
- const handleInput = (newInput) => {
+ // Handle text input, enforce word locking (snippet mode) or free-flow (timed mode)
+ const handleInput = (incomingInput) => {
+ let newInput = incomingInput;
// Disable input handling for non-practice races before countdown begins
if (raceState.type !== 'practice' && !raceState.inProgress && raceState.countdown === null) {
- return;
+ return newInput;
}
// --- Start Practice Race on First Input ---
@@ -645,14 +646,15 @@ export const RaceProvider = ({ children }) => {
input: newInput,
position: newInput.length
}));
- return;
+ return newInput;
}
// If we are in progress (or just started practice), proceed with full update
const currentInput = typingState.input;
const lockedPosition = typingState.lockedPosition;
const text = raceState.snippet?.text || '';
-
+ const isTimedMode = !!(raceState.snippet?.is_timed_test || raceState.timedTest?.enabled || raceState.settings?.testMode === 'timed');
+
// Find the position of the first error in the current input
let firstErrorPosition = text.length; // Default to end of text (no errors)
for (let i = 0; i < Math.min(text.length, currentInput.length); i++) {
@@ -661,17 +663,17 @@ export const RaceProvider = ({ children }) => {
break;
}
}
-
+
// If trying to delete locked text, only preserve correctly typed text before the first error
if (newInput.length < currentInput.length && lockedPosition > 0) {
// Only preserve text up to the last complete word before the first error
const lastWordBreakBeforeError = currentInput.lastIndexOf(' ', Math.max(0, firstErrorPosition - 1)) + 1;
-
+
// Only enforce locking if trying to delete before the locked position
if (newInput.length < lastWordBreakBeforeError) {
const preservedPart = currentInput.substring(0, lastWordBreakBeforeError);
let newPart = '';
-
+
// Keep the user's input after the preserved part
if (newInput.length >= preservedPart.length) {
newPart = newInput.substring(preservedPart.length);
@@ -679,14 +681,15 @@ export const RaceProvider = ({ children }) => {
// Deletion is attempting to erase preserved text
newPart = currentInput.substring(preservedPart.length);
}
-
+
// This enforces that only correctly typed words before any error cannot be deleted
newInput = preservedPart + newPart;
}
}
-
+
// Update progress with the potentially modified input
updateProgress(newInput);
+ return newInput;
};
const updateProgress = (input) => {
@@ -695,6 +698,7 @@ export const RaceProvider = ({ children }) => {
// Calculate current position in the snippet
const text = raceState.snippet?.text || '';
+ const isTimedMode = !!(raceState.snippet?.is_timed_test || raceState.timedTest?.enabled || raceState.settings?.testMode === 'timed');
let correctChars = 0;
let currentErrors = 0;
let hasError = false;
@@ -709,56 +713,68 @@ export const RaceProvider = ({ children }) => {
}
}
- // Count only the contiguous correct characters from the start of the snippet.
- // As soon as an error is encountered we stop counting further characters.
- if (!hasError) {
- // No error – all typed characters are correct up to input length (bounded by snippet length)
- correctChars = Math.min(input.length, text.length);
+ if (isTimedMode) {
+ // Timed mode (Monkeytype-style): allow continuing past mistakes.
+ // Count correct characters across the whole typed span, and treat
+ // mismatches as current (net) errors that can be reduced by fixing.
+ const span = Math.min(input.length, text.length);
+ for (let i = 0; i < span; i++) {
+ if (input[i] === text[i]) correctChars++;
+ }
+ currentErrors = input.length - correctChars; // net errors
+ hasError = currentErrors > 0;
+ const mismatchIndex = input.split('').findIndex((ch, idx) => idx >= text.length || ch !== text[idx]);
+ firstErrorPosition = hasError ? (mismatchIndex === -1 ? Math.min(input.length, text.length) : mismatchIndex) : text.length;
} else {
- // Error present – only count chars before the first error index
- correctChars = firstErrorPosition;
- }
-
- // Count current error characters (everything typed after first error is considered incorrect)
- if (hasError) {
- currentErrors = input.length - firstErrorPosition;
- }
-
- // Get previous total errors (persist even after fixes)
- let totalErrors = typingState.errors;
-
- // If there's a new error (wasn't there in previous input)
- const previousInput = typingState.input;
- let isNewError = false;
-
- // Check if we have a new error that wasn't in the previous input
- if (hasError && (previousInput.length <= firstErrorPosition ||
- (previousInput.length > firstErrorPosition && previousInput[firstErrorPosition] === text[firstErrorPosition]))) {
- isNewError = true;
+ // Snippet mode: contiguous correctness until first error
+ if (!hasError) {
+ // No error – all typed characters are correct up to input length (bounded by snippet length)
+ correctChars = Math.min(input.length, text.length);
+ } else {
+ // Error present – only count chars before the first error index
+ correctChars = firstErrorPosition;
+ }
+ // Count current error characters (everything typed after first error is considered incorrect)
+ if (hasError) {
+ currentErrors = input.length - firstErrorPosition;
+ }
}
- // Only increment error count if this is a new error
- if (isNewError) {
- totalErrors += 1;
+ // Error model differs by mode
+ let totalErrors;
+ let accuracy;
+ let wpm;
+ if (isTimedMode) {
+ // Net errors at the moment (can decrease if user fixes)
+ totalErrors = currentErrors;
+ const typedChars = input.length;
+ const minutes = elapsedSeconds > 0 ? (elapsedSeconds / 60) : 0;
+ const netChars = Math.max(0, typedChars - totalErrors); // equals correctChars, but keep intent explicit
+ wpm = minutes > 0 ? (netChars / 5) / minutes : 0; // net WPM
+ accuracy = typedChars > 0 ? (correctChars / typedChars) * 100 : 100;
+ } else {
+ // Snippet mode retains cumulative error count
+ totalErrors = typingState.errors;
+ // If there's a new error (wasn't there in previous input)
+ const previousInput = typingState.input;
+ let isNewError = false;
+ if (hasError && (previousInput.length <= firstErrorPosition ||
+ (previousInput.length > firstErrorPosition && previousInput[firstErrorPosition] === text[firstErrorPosition]))) {
+ isNewError = true;
+ }
+ if (isNewError) totalErrors += 1;
+ // Accuracy based on contiguous correctness + cumulative errors
+ const totalCharsForAccuracy = Math.min(firstErrorPosition, input.length) + totalErrors;
+ const accuracyCorrectChars = Math.min(firstErrorPosition, input.length);
+ const words = correctChars / 5; // contiguous
+ wpm = elapsedSeconds > 0 ? (words / elapsedSeconds) * 60 : 0;
+ accuracy = totalCharsForAccuracy > 0 ? (accuracyCorrectChars / totalCharsForAccuracy) * 100 : 100;
}
- // For accuracy calculation:
- // - The denominator is (all correct characters typed + all errors made)
- // - The numerator is all correct characters typed
- const totalCharsForAccuracy = Math.min(firstErrorPosition, input.length) + totalErrors;
- const accuracyCorrectChars = Math.min(firstErrorPosition, input.length);
-
- // Calculate WPM using only correctly typed characters (prevents inflation)
- const words = correctChars / 5; // Standard definition: 1 word = 5 correct chars
- const wpm = elapsedSeconds > 0 ? (words / elapsedSeconds) * 60 : 0;
-
- // Calculate accuracy using only valid characters (before first error) plus cumulative errors
- const accuracy = totalCharsForAccuracy > 0 ? (accuracyCorrectChars / totalCharsForAccuracy) * 100 : 100;
-
// Check if all characters are typed correctly for completion
const isCompleted = input.length === text.length && !hasError;
- // Find the last completely correct word boundary before any error
+ // Find the last completely correct word boundary before any error (snippet mode only)
let newLockedPosition = 0;
// Only process word locking if there are characters
@@ -803,7 +819,7 @@ export const RaceProvider = ({ children }) => {
position: input.length, // Use actual input length instead of correct chars
correctChars,
errors: totalErrors,
- completed: isCompleted, // Only completed when all characters match exactly
+ completed: isTimedMode ? false : isCompleted, // timed ends by timer
wpm,
accuracy,
lockedPosition: newLockedPosition
diff --git a/client/src/pages/AboutUs.css b/client/src/pages/AboutUs.css
index 812fded3..bc1641c3 100644
--- a/client/src/pages/AboutUs.css
+++ b/client/src/pages/AboutUs.css
@@ -221,6 +221,56 @@
margin-top: 2rem;
}
+/* Subtle feedback note under Learn More */
+.feedback-note {
+ margin-top: 1.5rem;
+ padding: 1.2rem 1.5rem;
+ font-size: 0.95rem;
+ color: var(--text-color);
+ text-align: center;
+ background-color: rgba(var(--primary-color-rgb), 0.08);
+ border-radius: 10px;
+ border: 1px solid rgba(var(--primary-color-rgb), 0.15);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+ transition: all 0.3s ease;
+}
+
+.feedback-note:hover {
+ background-color: rgba(var(--primary-color-rgb), 0.12);
+ border-color: rgba(var(--primary-color-rgb), 0.25);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ transform: translateY(-2px);
+}
+
+.feedback-note a {
+ color: var(--primary-color);
+ text-decoration: none;
+ font-weight: 600;
+ position: relative;
+ transition: color 0.2s ease;
+ padding: 0 2px;
+}
+
+.feedback-note a::after {
+ content: '';
+ position: absolute;
+ width: 0;
+ height: 2px;
+ bottom: -1px;
+ left: 0;
+ background-color: var(--primary-color);
+ transition: width 0.3s ease;
+ border-radius: 1px;
+}
+
+.feedback-note a:hover {
+ color: var(--primary-color-dark);
+}
+
+.feedback-note a:hover::after {
+ width: 100%;
+}
+
.collapsible-section {
background-color: var(--background-color);
border-radius: 10px;
diff --git a/client/src/pages/AboutUs.jsx b/client/src/pages/AboutUs.jsx
index 5c00ca64..6ca95417 100644
--- a/client/src/pages/AboutUs.jsx
+++ b/client/src/pages/AboutUs.jsx
@@ -115,7 +115,7 @@ function AboutUs() {
- Real-time Feedback: Immediate visual cues highlight correct and incorrect keystrokes
- - Error Handling: Option to require error correction or continue typing despite mistakes
+ - Error Handling: In Timed mode you do not need to go back and fix mistakes — you can keep typing; mistakes still reduce accuracy and adjusted WPM. In Snippet mode you must correct errors to continue progressing through the text.
- Audio Feedback: Customizable keyboard sounds enhance the typing experience
- Visible Progress: Clear indication of your position within the current text
@@ -137,6 +137,7 @@ function AboutUs() {
Inactivity Warning: If you are the only player who has not clicked "Ready" for 60 seconds, you will receive an inactivity warning.
Inactivity Kick: If you remain inactive for an additional 15 seconds after the warning, you will be automatically removed from the lobby to prevent holding up other players.
Anti-Cheat Measures: Pasting text is disabled during races to maintain fair competition.
+ Public Lobby Capacity: Public lobbies have no player cap. Private lobbies are limited to 10 players.
Stats Calculation
@@ -190,7 +191,6 @@ function AboutUs() {
Difficulty Levels: Content is categorized by complexity and length, allowing progressive skill development
-
Platform Statistics
@@ -262,6 +262,13 @@ function AboutUs() {
+ {/* Feedback note positioned at the very bottom of Learn More */}
+
+ Have suggestions or found an issue? Email us at
+ {' '}it.admin@tigerapps.org
+ {' '}or open an issue on
+ {' '}GitHub.
+
);
diff --git a/client/src/pages/Lobby.css b/client/src/pages/Lobby.css
index 4c8f4114..4df0e9c8 100644
--- a/client/src/pages/Lobby.css
+++ b/client/src/pages/Lobby.css
@@ -129,12 +129,12 @@
.lobby-left-column {
flex: 1;
min-width: 300px;
- padding: 1.5rem;
+ padding: 1.2rem;
background-color: var(--type-container-color);
border-radius: 6px;
border: 1px solid var(--border-color);
position: relative;
- min-height: 23vh;
+ min-height: 0; /* reduce dead space */
}
.lobby-settings {
@@ -146,11 +146,11 @@
}
.lobby-settings h2 {
- margin: 0 0 1.2rem 0;
+ margin: 0 0 0.8rem 0;
color: var(--mode-text-color);
font-size: 1.3rem;
border-bottom: 1px solid var(--border-color);
- padding-bottom: 0.6rem;
+ padding-bottom: 0.4rem;
}
/* --- TestConfigurator overrides for vertical lobby layout --- */
@@ -162,7 +162,7 @@
width: 100%;
display: flex;
flex-direction: column;
- gap: 14px;
+ gap: 10px; /* tighten spacing */
overflow: visible;
}
@@ -176,7 +176,7 @@
display: none;
}
-/* NEW RULE: Ensure the container for conditional options behaves correctly in vertical layout */
+/* Ensure the container for conditional options behaves correctly in vertical layout */
.lobby-settings .test-configurator .conditional-options-container {
width: 100%;
height: auto;
@@ -199,14 +199,14 @@
/* Visible state when React adds .visible */
.lobby-settings .test-configurator .options-wrapper.visible {
- margin-top: 25px;
+ margin-top: 12px;
max-height: 600px;
opacity: 1;
pointer-events: auto;
visibility: visible;
}
-/* Inner rows - now stacking vertically */
+/* Inner rows - stacking vertically */
.lobby-settings .test-configurator .snippet-filters-inner,
.lobby-settings .test-configurator .duration-selection-inner {
display: flex;
@@ -230,7 +230,6 @@
vertical-align: middle;
}
-/* Fine‑tune dropdown pills */
.lobby-settings .test-configurator .config-select {
min-width: 0;
flex-grow: 1;
diff --git a/client/src/pages/Lobby.jsx b/client/src/pages/Lobby.jsx
index 36c6cffc..aa9986cf 100644
--- a/client/src/pages/Lobby.jsx
+++ b/client/src/pages/Lobby.jsx
@@ -272,6 +272,8 @@ function Lobby() {
setRaceState={setRaceState}
loadNewSnippet={loadNewSnippet}
snippetError={snippetError}
+ isLobby
+ allowTimed={false}
onShowLeaderboard={() => {}} // Disable leaderboard button in lobby
/>
) : (
diff --git a/client/src/tutorial/tutorialSteps.js b/client/src/tutorial/tutorialSteps.js
index 127760c7..85d754e2 100644
--- a/client/src/tutorial/tutorialSteps.js
+++ b/client/src/tutorial/tutorialSteps.js
@@ -6,7 +6,7 @@ export const tutorialSteps = {
{ id: 'home-start', anchorId: 'body', content: "Welcome to TigerType, a Princeton-based typing game for all students! Click Next to start the tutorial; you can also close this and come back to the tutorial at anytime by pressing the '?' button in the top left." },
{ id: 'home-practice', anchorId: 'mode-practice', content: 'Solo practice is where you can practice typing by yourself; you can choose and filter between different modes and options that we\'ll discuss later.' },
{ id: 'home-quick', anchorId: 'mode-quick', content: 'This is the Quick Match button. It will put you into the public matchmaking queue, where you are able to play against anyone else also in the queue.' },
- { id: 'home-quick', anchorId: 'mode-quick', content: 'A minimum of 2 players are required to start a game and a max of 10 players can be in a lobby.' },
+ { id: 'home-quick', anchorId: 'mode-quick', content: 'A minimum of 2 players are required to start a game. Public lobbies have no cap; private lobbies are limited to 10 players.' },
{ id: 'home-create-private', anchorId: 'mode-create-private', content: 'You can also create private lobbies to race against just your friends. This allows you to select specific modes and options for your race (just like in solo practice).' },
{ id: 'home-join-private', anchorId: 'mode-join-private', content: 'You can join a private lobby by entering the lobby code or link that your friends can share with you or entering their NetID. You can also join private lobby by directly entering the URL.' },
{ id: 'home-activate-practice', anchorId: 'mode-practice', content: 'Click Solo Practice to continue the tutorial for practice mode, or click the \'X\' button to close the tutorial and come back later.', spotlightClicks: true, event: 'click', placement: 'top' }
@@ -16,8 +16,10 @@ export const tutorialSteps = {
{ id: 'practice-configurator', anchorId: 'configurator', content: 'This is the Test Configurator where you have access to all the modes and their options.' },
{ id: 'practice-mode-timed', anchorId: 'mode-timed', content: 'Let\'s check out TIMED mode.', spotlightClicks: true, event: 'click' },
{ id: 'practice-mode-timed-desc', anchorId: 'mode-timed', content: 'TIMED mode is similar to MonkeyType, where you are given a random selection of the 1,000 most common english words. You have a set amount of time to type as many words as possible.' },
+ { id: 'practice-mode-timed-errors', anchorId: 'mode-timed', content: 'In TIMED mode you do not need to go back and fix mistakes — you can keep typing. Mistakes still lower your accuracy and adjusted WPM.' },
{ id: 'practice-timed-options', anchorId: 'timed-options', content: 'You can select any duration, which is the length of time you will be typing; each duration has its own leaderboard entry.' },
{ id: 'practice-mode-snippet', anchorId: 'mode-snippet', content: 'Let\'s switch back to Snippet Mode.', spotlightClicks: true },
+ { id: 'practice-mode-snippet-errors', anchorId: 'typing-input', content: 'In Snippet mode, you must correct mistakes to progress through the text. This is also the mode used in public matchmaking.' },
{ id: 'practice-race-content', anchorId: 'race-content', content: 'Here is the text that you will type. As you type, you will see your stats and progress update in real time.' },
{ id: 'practice-typing-start', anchorId: 'typing-input', content: 'Practice mode will automatically begin when you type the first letter.' },
{ id: 'practice-typing-action', anchorId: 'typing-input', content: 'Start typing now; feel free to make a mistake to see the mistake highlighting in action. You can press TAB for a new excerpt and ESC to restart.' },
@@ -28,4 +30,4 @@ export const tutorialSteps = {
further: [
{ id: 'further-end', anchorId: 'body', content: 'That concludes the tutorial. Happy typing!' }
]
-};
\ No newline at end of file
+};
diff --git a/server/models/race.js b/server/models/race.js
index 258b5ac1..8da2e3f8 100644
--- a/server/models/race.js
+++ b/server/models/race.js
@@ -300,12 +300,25 @@ const Race = {
}
},
- // Add a player to a lobby, checking capacity
+ // Add a player to a lobby
+ // - Private lobbies: enforce 10-player cap
+ // - Public lobbies: no cap
async addPlayerToLobby(lobbyId, userId, isReady = false) {
const client = await db.getClient(); // Use a client for transaction
try { // Start try block immediately after acquisition
await client.query('BEGIN');
+ // Determine lobby type for capacity rules
+ const lobbyTypeRes = await client.query(
+ `SELECT type FROM lobbies WHERE id = $1`,
+ [lobbyId]
+ );
+ if (lobbyTypeRes.rowCount === 0) {
+ await client.query('ROLLBACK');
+ throw new Error('Lobby not found.');
+ }
+ const lobbyType = lobbyTypeRes.rows[0].type;
+
// Check current player count
const countResult = await client.query(
`SELECT COUNT(*) FROM lobby_players WHERE lobby_id = $1`,
@@ -313,8 +326,8 @@ const Race = {
);
const playerCount = parseInt(countResult.rows[0].count, 10);
- // Check if lobby is full (max 10 players)
- if (playerCount >= 10) {
+ // Enforce capacity only for PRIVATE lobbies (max 10 players)
+ if (lobbyType === 'private' && playerCount >= 10) {
// Rollback before throwing ensures transaction state is clean
await client.query('ROLLBACK');
throw new Error('Lobby is full.'); // Throw specific error