feat(onboarding): mnemonic codebook with print/PDF option

After saving all 12 mnemonics, players see their full codebook —
each letter with its Morse pattern (· —) and mnemonic phrase in
a grid layout. Two actions: Print/Save PDF (window.print) and
Start Playing. Operator name appears on the printed page.

Print CSS hides UI chrome and renders a clean 3-column grid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 04:40:04 +00:00
parent fe5cbaec2b
commit 222e4e45ce
4 changed files with 170 additions and 4 deletions
+5 -1
View File
@@ -61,7 +61,11 @@ export default function App() {
</div> </div>
)} )}
{route === 'onboarding' && token && ( {route === 'onboarding' && token && (
<OnboardingPage token={token} onComplete={() => setRoute('game')} /> <OnboardingPage
token={token}
operatorName={profile?.display_name ?? null}
onComplete={() => setRoute('game')}
/>
)} )}
{route === 'game' && token && ( {route === 'game' && token && (
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} /> <GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
+123
View File
@@ -0,0 +1,123 @@
import { NOVICE_LETTERS, MORSE } from '../lib/morse'
type Props = {
mnemonics: Record<string, string>
operatorName: string | null
onStartPlaying: () => void
}
function morseDisplay(pattern: string): string {
return pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
}
export function MnemonicCodebook({ mnemonics, operatorName, onStartPlaying }: Props) {
return (
<div style={{ maxWidth: 680, margin: '0 auto' }}>
{/* Header — hidden when printing, replaced by print-only title */}
<div className="no-print" style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontFamily: 'var(--font-header)', fontSize: '1.8rem', marginBottom: '0.25rem' }}>
Your Codebook is Ready!
</h1>
<p style={{ color: 'var(--color-brown)' }}>
Print it out or save as PDF to keep with you during practice.
</p>
</div>
{/* Print-only title */}
<div style={{ display: 'none' }} className="print-title">
<h1 style={{ fontFamily: 'var(--font-header)', textAlign: 'center', marginBottom: '0.25rem' }}>
Morse Code Codebook
</h1>
{operatorName && (
<p style={{ textAlign: 'center', marginBottom: '1rem', fontStyle: 'italic' }}>
Operator: {operatorName}
</p>
)}
</div>
{/* Letter grid */}
<div
className="codebook-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: '0.75rem',
marginBottom: '1.5rem',
}}
>
{NOVICE_LETTERS.map(letter => {
const pattern = MORSE[letter] ?? ''
const phrase = mnemonics[letter]
return (
<div
key={letter}
className="codebook-card"
style={{
background: 'white',
border: '2px solid var(--color-gold)',
borderRadius: '0.4rem',
padding: '0.75rem',
}}
>
{/* Letter + pattern */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.6rem', marginBottom: '0.4rem' }}>
<span style={{
fontFamily: 'var(--font-header)',
fontSize: '2rem',
fontWeight: 700,
color: 'var(--color-forest)',
lineHeight: 1,
}}>
{letter}
</span>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: '1.1rem',
color: 'var(--color-gold)',
letterSpacing: '0.2em',
}}>
{morseDisplay(pattern)}
</span>
</div>
{/* Mnemonic phrase */}
<p style={{
fontSize: '0.9rem',
color: phrase ? 'var(--color-forest)' : '#aaa',
fontStyle: phrase ? 'normal' : 'italic',
lineHeight: 1.3,
wordBreak: 'break-word',
}}>
{phrase ?? 'skipped'}
</p>
</div>
)
})}
</div>
{/* Actions */}
<div className="no-print" style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<button
className="btn btn-ghost"
onClick={() => window.print()}
>
🖨 Print / Save PDF
</button>
<button
className="btn"
onClick={onStartPlaying}
style={{ fontSize: '1.05rem' }}
>
Start Playing
</button>
</div>
<style>{`
@media print {
.print-title { display: block !important; }
}
`}</style>
</div>
)
}
+18
View File
@@ -61,3 +61,21 @@ input:focus { outline: 2px solid var(--color-gold); outline-offset: 1px; }
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
/* Print styles */
@media print {
.no-print { display: none !important; }
body { background: white; }
.app-shell { max-width: 100%; padding: 0; }
.codebook-grid {
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
gap: 0.5rem !important;
}
.codebook-card {
border: 1px solid #999 !important;
background: white !important;
break-inside: avoid;
page-break-inside: avoid;
}
}
+24 -3
View File
@@ -1,20 +1,41 @@
import { useState, useCallback } from 'react'
import { MnemonicBuilder } from '../components/MnemonicBuilder' import { MnemonicBuilder } from '../components/MnemonicBuilder'
import { MnemonicCodebook } from '../components/MnemonicCodebook'
type Props = { type Props = {
token: string token: string
operatorName: string | null
onComplete: () => void onComplete: () => void
} }
export function OnboardingPage({ token, onComplete }: Props) { export function OnboardingPage({ token, operatorName, onComplete }: Props) {
const [mnemonics, setMnemonics] = useState<Record<string, string> | null>(null)
const handleBuilderComplete = useCallback(async () => {
try {
const r = await fetch('/api/mnemonics', {
headers: { Authorization: `Bearer ${token}` },
})
const data = await r.json()
setMnemonics(data)
} catch {
onComplete()
}
}, [token, onComplete])
if (mnemonics) {
return <MnemonicCodebook mnemonics={mnemonics} operatorName={operatorName} onStartPlaying={onComplete} />
}
return ( return (
<div> <div>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}> <h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>
Build Your Mnemonics Build Your Codebook
</h1> </h1>
<p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}> <p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}>
Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes. Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes.
</p> </p>
<MnemonicBuilder token={token} onComplete={onComplete} /> <MnemonicBuilder token={token} onComplete={handleBuilderComplete} />
</div> </div>
) )
} }