Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 59 additions & 3 deletions client/src/components/Typing.css
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,29 @@
animation: blink 1s infinite;
}

:root[data-cursor='caret'] .snippet-display .current {
background-color: transparent !important;
color: inherit !important;
font-weight: inherit;
animation: none !important;
text-shadow: none;
transition: color 120ms ease;
}

:root[data-cursor='caret'] .snippet-display .current.error {
color: var(--error-color);
text-shadow: none;
}

:root[data-cursor='caret'][data-caret-blink='blink'] .snippet-display .current::before {
animation: caretBlink 1.2s ease-in-out infinite;
}

:root[data-cursor='caret'][data-caret-blink='solid'] .snippet-display .current::before {
animation: none !important;
opacity: 1;
}

/* Default caret rendering (line cursor) when block cursor is disabled */
.current::before {
visibility: var(--line-cursor);
Expand Down Expand Up @@ -369,6 +392,16 @@
display: none !important;
}

:root[data-cursor='caret'] .snippet-display.glide-on .current {
color: inherit !important;
background-color: transparent !important;
animation: none !important;
}

:root[data-cursor='caret'] .snippet-display.glide-on .current::before {
display: none !important;
}

.snippet-display {
position: relative;
}
Expand All @@ -382,9 +415,9 @@
pointer-events: none;
z-index: 0; /* behind the text */
transition:
transform var(--cursor-glide-duration, 95ms) cubic-bezier(0.2, 0.8, 0.2, 1),
width var(--cursor-glide-duration, 95ms) cubic-bezier(0.2, 0.8, 0.2, 1),
height var(--cursor-glide-duration, 95ms) cubic-bezier(0.2, 0.8, 0.2, 1);
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);
opacity: calc(var(--glide-cursor-enabled, 0)); /* 0 or 1 set by Settings */
border-radius: 2px;
backface-visibility: hidden;
Expand All @@ -402,6 +435,29 @@
z-index: 2; /* above text so thin caret remains visible */
animation: caretBlink 1.2s ease-in-out infinite;
}
:root[data-cursor='caret'][data-caret-blink='solid'] .cursor-overlay.caret {
animation: none !important;
opacity: 1 !important;
}
.cursor-overlay.caret.typing-active {
animation: none !important;
opacity: 1 !important;
}

.snippet-display.caret-solid .cursor-overlay.caret {
animation: none !important;
opacity: 1 !important;
}

.snippet-display.caret-solid .current,
.snippet-display.caret-solid .current::before {
animation: none !important;
opacity: 1 !important;
}

.snippet-display.caret-solid .current::before {
background: var(--caret-color) !important;
}

/* Non-glide caret (pseudo) also blinks smoothly */
.current::before {
Expand Down
81 changes: 76 additions & 5 deletions client/src/components/Typing.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// [AI DISCLAIMER: AI was used to help debug socket emit for timed tests; lines 394-408]

import { useState, useEffect, useRef, useLayoutEffect } from 'react';
import { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react';
import { useRace } from '../context/RaceContext';
import { useSocket } from '../context/SocketContext';
import playKeySound from './Sound.jsx';
Expand Down Expand Up @@ -65,6 +65,57 @@ function Typing({
return (getComputedStyle(document.documentElement).getPropertyValue('--glide-cursor-enabled') || '0').trim() === '1';
});
const initialCursorSetRef = useRef(false);
const initialCursorStyle = typeof document !== 'undefined'
? (document.documentElement.getAttribute('data-cursor') || 'block')
: 'block';
const cursorStyleRef = useRef(initialCursorStyle);
const [cursorStyle, setCursorStyle] = useState(initialCursorStyle);
const typingActiveRef = useRef(false);
const [typingActive, setTypingActive] = useState(false);

const setTypingActiveState = useCallback((active) => {
if (typingActiveRef.current === active) return;
typingActiveRef.current = active;
setTypingActive(active);
if (typeof document !== 'undefined') {
const overlay = cursorRef.current;
if (overlay) {
if (cursorStyleRef.current === 'caret') {
overlay.classList.toggle('typing-active', active);
} else {
overlay.classList.remove('typing-active');
}
}
const mode = cursorStyleRef.current === 'caret' && active ? 'solid' : 'blink';
document.documentElement.setAttribute('data-caret-blink', mode);
}
}, []);

useEffect(() => {
if (typeof document === 'undefined') return undefined;
return () => {
document.documentElement.removeAttribute('data-caret-blink');
};
}, []);

useEffect(() => {
if (typeof document === 'undefined') return undefined;
const root = document.documentElement;
const syncCursorStyle = () => {
const next = root.getAttribute('data-cursor') || 'block';
cursorStyleRef.current = next;
setCursorStyle(next);
if (next !== 'caret') {
setTypingActiveState(false);
} else if (!typingActiveRef.current) {
document.documentElement.setAttribute('data-caret-blink', 'blink');
}
};
syncCursorStyle();
const observer = new MutationObserver(syncCursorStyle);
observer.observe(root, { attributes: true, attributeFilter: ['data-cursor'] });
return () => observer.disconnect();
}, [setTypingActiveState]);

// Use testMode and testDuration for timed tests if provided
useEffect(() => {
Expand Down Expand Up @@ -200,6 +251,15 @@ function Typing({
}, [typingState.position]);

// Track snippet changes to reset input
useEffect(() => {
const active = ((raceState.inProgress || raceState.type === 'practice') && input.length > 0 && !typingState.completed);
setTypingActiveState(active);
}, [input.length, raceState.inProgress, raceState.type, typingState.completed, setTypingActiveState]);

useEffect(() => {
return () => setTypingActiveState(false);
}, [setTypingActiveState]);

useEffect(() => {
if (raceState.snippet && raceState.snippet.id !== snippetId) {
setSnippetId(raceState.snippet.id);
Expand Down Expand Up @@ -751,17 +811,26 @@ function Typing({
const y = Math.round((rect.top - containerRect.top) + scrollY);

// Determine caret vs block based on Settings-managed CSS var
const useCaret = (document.documentElement.getAttribute('data-cursor') === 'caret');
const useCaret = (cursorStyleRef.current === 'caret');

// Size overlay to target element
const height = rect.height;
const width = useCaret ? 0 : rect.width; // caret drawn via border-left for crispness
overlay.style.height = `${height}px`;
overlay.style.width = `${width}px`;
overlay.className = `cursor-overlay ${useCaret ? 'caret' : 'block'}`;
overlay.className = 'cursor-overlay';
if (useCaret) {
overlay.classList.add('caret');
overlay.classList.remove('block');
overlay.classList.toggle('typing-active', typingActiveRef.current);
} else {
overlay.classList.add('block');
overlay.classList.remove('caret');
overlay.classList.remove('typing-active');
}

// Cursor-specific duration (caret snappier)
overlay.style.setProperty('--cursor-glide-duration', useCaret ? '95ms' : '95ms');
overlay.style.setProperty('--cursor-glide-duration', useCaret ? '85ms' : '110ms');
Copy link

Copilot AI Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic numbers '85ms' and '110ms' should be extracted as named constants or CSS custom properties to improve maintainability and ensure consistency with values defined elsewhere (e.g., line 16 in styles.css defines '--cursor-glide-duration: 95ms').

Copilot uses AI. Check for mistakes.

// First placement should not animate from origin
if (!initialCursorSetRef.current) {
Expand Down Expand Up @@ -1019,14 +1088,16 @@ function Typing({
};
}, []);

const caretSolid = typingActive && cursorStyle === 'caret';

return (
<>
<div className="stats-container">{getStatsContent()}</div>

{/* Only show typing area (snippet + input) if race is NOT completed */}
{!raceState.completed && (
<div className="typing-area">
<div className={`snippet-display ${isShaking ? 'shake-animation' : ''} ${glideEnabled ? 'glide-on' : ''}`}>
<div className={`snippet-display ${isShaking ? 'shake-animation' : ''} ${glideEnabled ? 'glide-on' : ''} ${caretSolid ? 'caret-solid' : ''}`}>
{/* Smooth-glide overlay cursor (rendered behind text) */}
<div ref={cursorRef} className="cursor-overlay block" aria-hidden="true" />
{/* Error message moved OUTSIDE snippet-display */}
Expand Down
62 changes: 61 additions & 1 deletion public/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
--error-color: #f44336;
--current-color: #3a506b;
--cursor-color: #F58025;
--caret-width: 3px;
--cursor-glide-duration: 95ms;
--font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}

Expand Down Expand Up @@ -287,6 +289,64 @@ h1 {
color: white;
}

#text-display,
#practice-text-display,
#snippet-display {
position: relative;
}

.cursor-overlay {
position: absolute;
top: 0;
left: 0;
transform: translate3d(0, 0, 0);
will-change: transform, width, height, opacity;
pointer-events: none;
z-index: 2;
border-radius: 2px;
opacity: 0;
transition:
transform var(--cursor-glide-duration) cubic-bezier(0.2, 0.8, 0.2, 1),
width var(--cursor-glide-duration) cubic-bezier(0.2, 0.8, 0.2, 1),
height var(--cursor-glide-duration) cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 160ms ease;
}

.cursor-overlay.block {
background-color: var(--current-color);
box-shadow: 0 0 0.01px var(--current-color);
z-index: 0;
}

.cursor-overlay.caret {
background: transparent;
border-left: var(--caret-width) solid var(--cursor-color);
box-shadow: 0 0 0.01px var(--cursor-color);
}

.cursor-overlay.hidden {
opacity: 0 !important;
}

:root[data-cursor="caret"] #snippet-display .current,
:root[data-cursor="caret"] .snippet-display .current,
:root[data-cursor="caret"] #text-display .current,
:root[data-cursor="caret"] #practice-text-display .current {
background-color: transparent;
color: var(--cursor-color);
text-shadow: 0 0 0.6px rgba(245, 128, 37, 0.35);
font-weight: 500;
transition: color 120ms ease;
}

:root[data-cursor="caret"] #snippet-display .current.error,
:root[data-cursor="caret"] .snippet-display .current.error,
:root[data-cursor="caret"] #text-display .current.error,
:root[data-cursor="caret"] #practice-text-display .current.error {
color: var(--error-color);
text-shadow: 0 0 0.6px rgba(244, 67, 54, 0.25);
}

@media (max-width: 768px) {
.modes {
flex-direction: column;
Expand Down Expand Up @@ -333,4 +393,4 @@ h1 {
.stat-value {
font-size: 1.2rem;
color: var(--princeton-orange);
}
}
Loading