Skip to content
Open
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
4 changes: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@
## 2025-06-25 - [Copy-to-Clipboard UX and Immediate Feedback]
**Learning:** Adding a copy-to-clipboard feature for key user identifiers (like email or IDs) significantly reduces friction in user workflows. Implementing this with `navigator.clipboard.writeText` and providing immediate visual feedback via a toast notification ('Correo copiado al portapapeles') confirms success. Using icon-only buttons with explicit `aria-label` and `title` ensures the feature is both accessible and intuitive.
**Action:** Always provide a copy-to-clipboard option for static text that users frequently need to reuse, and ensure immediate feedback is provided upon action.

## 2025-01-15 - [Keyboard Efficiency and Discoverability in Flashcards]
**Learning:** For high-repetition tasks like flashcard study, keyboard shortcuts (Space/Enter for flip, 1-4 for rating) dramatically improve user flow and "flow state" immersion. However, shortcuts must be discoverable; adding bracketed visual hints like "[Espacio]" or "[1]" directly to button labels, along with descriptive "aria-label" and "title" attributes, ensures accessibility and lowers the learning curve for power users.
**Action:** Always include keyboard shortcuts for core repetitive loops and provide integrated visual hints for those shortcuts to ensure they aren't "hidden" features.
48 changes: 41 additions & 7 deletions src/v2/pages/V2FlashcardStudy.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,10 @@ const Flashcard = ({ card, isFlipped, onFlip }) => (
// Quality rating buttons (SM-2 mapped to UI)
const QualityButtons = ({ onRate, disabled }) => {
const buttons = [
{ quality: 1, label: 'Otra vez', sublabel: '< 1 día', color: '#ba1a1a', icon: 'replay' },
{ quality: 3, label: 'Difícil', sublabel: '2-3 días', color: '#9c4247', icon: 'sentiment_dissatisfied' },
{ quality: 4, label: 'Bien', sublabel: '4-6 días', color: '#0fa397', icon: 'sentiment_satisfied' },
{ quality: 5, label: 'Fácil', sublabel: '7+ días', color: '#4a6360', icon: 'sentiment_very_satisfied' }
{ quality: 1, label: 'Otra vez', shortcut: '1', sublabel: '< 1 día', color: '#ba1a1a', icon: 'replay' },
{ quality: 3, label: 'Difícil', shortcut: '2', sublabel: '2-3 días', color: '#9c4247', icon: 'sentiment_dissatisfied' },
{ quality: 4, label: 'Bien', shortcut: '3 / Espacio', sublabel: '4-6 días', color: '#0fa397', icon: 'sentiment_satisfied' },
{ quality: 5, label: 'Fácil', shortcut: '4', sublabel: '7+ días', color: '#4a6360', icon: 'sentiment_very_satisfied' }
];

return (
Expand All @@ -255,13 +255,14 @@ const QualityButtons = ({ onRate, disabled }) => {
opacity: disabled ? 0.5 : 1,
border: 'none'
}}
aria-label={`Calificar como ${btn.label}`}
aria-label={`Calificar como ${btn.label} (atajo: tecla ${btn.shortcut})`}
title={`Atajo: ${btn.shortcut}`}
>
<i className='material-icons' style={{ fontSize: '28px', color: btn.color }} aria-hidden='true'>
{btn.icon}
</i>
<span className='v2-label-large v2-text-semibold' style={{ color: btn.color }}>
{btn.label}
{btn.label} <span className='v2-opacity-50' style={{ fontSize: '0.8em' }}>[{btn.quality === 4 ? '3' : btn.shortcut}]</span>
</span>
<span className='v2-label-small v2-opacity-70'>
{btn.sublabel}
Expand Down Expand Up @@ -400,6 +401,37 @@ const V2FlashcardStudy = () => {
const handleGoHome = useCallback(() => {
history.push('/dashboard');
}, [history]);

// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
// Don't trigger if any overlay is active or if user is typing (though no inputs here yet)
if (loading || isSubmitting || isSessionComplete || !currentCard) return;

const { key } = e;

if (!isFlipped) {
if (key === ' ' || key === 'Enter') {
e.preventDefault();
handleFlip();
}
} else {
if (key === '1') {
handleRate(1);
} else if (key === '2') {
handleRate(3);
} else if (key === '3' || key === ' ' || key === 'Enter') {
e.preventDefault();
handleRate(4);
} else if (key === '4') {
handleRate(5);
}
}
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [loading, isSubmitting, isSessionComplete, currentCard, isFlipped, handleFlip, handleRate]);

// Loading state
if (loading) {
Expand Down Expand Up @@ -501,9 +533,11 @@ const V2FlashcardStudy = () => {
<button
className='v2-btn-tonal'
onClick={handleFlip}
aria-label='Mostrar Respuesta (atajo: Espacio)'
title='Atajo: Espacio'
>
<i className='material-icons' aria-hidden='true'>visibility</i>
Mostrar Respuesta
Mostrar Respuesta <span className='v2-opacity-50' style={{ fontSize: '0.8em', marginLeft: '4px' }}>[Espacio]</span>
</button>
</div>
)}
Expand Down
33 changes: 33 additions & 0 deletions src/v2/pages/V2FlashcardStudy.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,37 @@ describe('V2FlashcardStudy', () => {
expect(screen.getByText('¡Todo al día!')).toBeTruthy();
});
});

it('handles keyboard shortcuts for flipping and rating', async () => {
render(
<MemoryRouter>
<V2FlashcardStudy />
</MemoryRouter>
);

await waitFor(() => screen.getByText('¿Cuál es la tríada de Virchow?'));

// Press Space to flip
fireEvent.keyDown(window, { key: ' ' });
await waitFor(() => {
expect(screen.getByText(/Estasis venosa/)).toBeTruthy();
});

// Press '4' to rate as Easy (Quality 5)
fireEvent.keyDown(window, { key: '4' });
await waitFor(() => {
expect(screen.getByText('Agente causal más común de epiglotitis')).toBeTruthy();
});

// Press ' ' to rate as Good (Quality 4) - after flipping
fireEvent.keyDown(window, { key: ' ' }); // Flip second card
await waitFor(() => {
expect(screen.getByText(/Haemophilus influenzae/)).toBeTruthy();
});

fireEvent.keyDown(window, { key: ' ' }); // Rate as Good
await waitFor(() => {
expect(screen.getByText('Signo de Murphy positivo indica...')).toBeTruthy();
});
});
});
Loading