8 Commits

Author SHA1 Message Date
kitadmin 20841ca517 ci: add Gitea Actions CI workflow
CI / test (push) Has been cancelled
Runs server tests (node:test), client tests (vitest), and build verification on every push and PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-31 01:53:58 +00:00
kitadmin 9394d583b7 infra: add .lab/environments.json for environment assignments 2026-05-31 00:04:22 +00:00
kitadmin 7ab8cde0ad docs: add CLAUDE.md for AI session context 2026-05-30 23:58:49 +00:00
kitadmin 222e4e45ce 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>
2026-04-30 04:40:04 +00:00
kitadmin fe5cbaec2b feat(auth): guest login with random telegraph operator name
No email required — players pick male/female/mystery, get assigned
a humorous period-appropriate operator name (e.g. "Dusty Clicksworth",
"Ada Dottsworth", "The Phantom Sender"), can reroll, then hit
"Begin Transmitting!" for instant play. Email/magic link moved to
a collapsible section for cross-device access only.

- server: POST /api/auth/guest creates guest profile + session
- server/db: profiles.email now nullable, is_guest column added,
  migration recreates table for existing installs
- client: names.ts with 15 names per gender category
- client: LoginPage redesigned — gender picker → name display → play
- client: useAuth gains setAuth() for direct session injection
- 55 server + 44 client tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 04:22:05 +00:00
kitadmin 8232e75fa9 feat(onboarding): curated meaningful mnemonic phrases
Replace random word banks with hand-crafted mnemonic suggestions
for each letter. All phrases are meaningful (e.g. "he has his hat"
for H, "sun set sky" for S, "over only once" for O) and every word
starts with the target letter. Placeholder shows first suggestion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 03:58:06 +00:00
kitadmin 0f7eb19626 feat(onboarding): anchor mnemonic to target letter
First word of each suggestion starts with the letter being learned
(e.g. "age WALK" for A). Validation requires at least one word to
start with the target letter. Added O/U/N/D/H words to fill gaps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 03:48:08 +00:00
kitadmin 972e11718c feat(onboarding): 5 clickable mnemonic suggestions per letter
- Dot words: ≤3 letters (was ≤2) — two-letter words like "go/it" were too limiting
- Dash words: ≥4 letters (unchanged in practice)
- Added DOT_WORDS and DASH_WORDS banks (~60 and ~200 words)
- suggestPhrases() generates 5 random valid suggestions per letter
- MnemonicBuilder shows clickable suggestion pills; "↻ new" refreshes them
- Selected suggestion highlights in gold; user can still type their own

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 03:42:32 +00:00
16 changed files with 830 additions and 65 deletions
+16
View File
@@ -0,0 +1,16 @@
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run test:server
- run: npm run test:client
- run: npx vite build
+33
View File
@@ -0,0 +1,33 @@
{
"production": {
"kind": "kitvm3_vm",
"name": "GamerComp",
"state": 2,
"services": [
{
"name": "morsequest",
"port": 3004,
"restart": "docker rm -f morsequest && docker run -d --name morsequest -p 127.0.0.1:3004:8080 -e PORT=8080 -e NODE_ENV=production --restart unless-stopped morsequest"
}
],
"urls": {
"game": "https://morsequest.gamercomp.com"
},
"deploy_notes": "Docker on port 3004. nginx server block at /etc/nginx/sites-enabled/morsequest. Deploy: tar + scp + docker build/run via ssh -i ~/.ssh/claude_gamercomp -p 2135 ubuntu@keylinkit.vpnplus.to."
},
"development": {
"kind": "kitvm3_vm",
"name": "GamerComp",
"state": 2,
"services": [
{
"name": "morsequest",
"port": 3004,
"restart": "docker rm -f morsequest && docker run -d --name morsequest -p 127.0.0.1:3004:8080 -e PORT=8080 -e NODE_ENV=production --restart unless-stopped morsequest"
}
],
"urls": {
"game": "https://morsequest.gamercomp.com"
}
}
}
+76
View File
@@ -0,0 +1,76 @@
# MorseQuest — CLAUDE.md
> AI context file for Claude Code sessions.
## Project Identity
**MorseQuest** is an adventure-based Morse code learning game. Players progress through 6 levels with personalized mnemonics, multi-modal audio-visual learning, and achievements. Targets amateur radio enthusiasts, Scout programs, and STEM education.
- **Production**: https://morsequest.gamercomp.com (port 3004 on GamerComp VM)
- **Repository**: https://git.keylinkit.net/allen/morsequest
- **Game Platform**: Follows `/home/node/workspace/games/GAME_REFERENCE.md` patterns
## Technology Stack
| Component | Technology |
|-----------|-----------|
| Frontend | React 18.2, TypeScript, Vite 5.1 |
| Backend | Node.js HTTP server (CommonJS, no framework) |
| Database | better-sqlite3 (WAL mode, closure pattern) |
| Auth | Magic link email + guest login (nodemailer) |
| PWA | Service worker, manifest.json, install prompt |
| Testing | Vitest (client), node:test (server) |
## Project Structure
```
morsequest/
├── client/ # React frontend (Vite root: 'client')
│ ├── src/ # Components, hooks, pages, lib
│ └── public/ # sw.js, manifest.json, icon.svg
├── server/
│ ├── server.js # HTTP routing, static serving
│ ├── db.js # All DB queries as closures inside initDb()
│ ├── mailer.js # Magic link emails
│ └── tests/ # db.test.js, auth.test.js, progress.test.js, admin.test.js
├── data/ # .db files (gitignored)
├── Dockerfile # Multi-stage, node:20-alpine, port 8080
└── docker-compose.yml
```
## Development
```bash
npm install
npm run dev # Starts both client (Vite) and server (concurrently)
```
## Testing
```bash
# Server tests (node:test with real HTTP, :memory: DB)
node --test server/tests/db.test.js server/tests/auth.test.js server/tests/progress.test.js server/tests/admin.test.js
# Client tests (vitest)
npx vitest run
```
## Deployment
Docker on GamerComp VM (port 3004 → 8080 internal):
```bash
# Build and deploy
npx vite build
docker build -t morsequest .
docker run -d --name morsequest -p 127.0.0.1:3004:8080 \
-e ADMIN_PASSWORD=<secret> \
-e SMTP_HOST=mail.keylinkit.net -e SMTP_PORT=2525 \
-e SMTP_USER=info@gamercomp.com -e SMTP_PASS='kitPLANE1!!' \
-e APP_URL=https://morsequest.gamercomp.com \
-v /home/ubuntu/game-arcade/games/morsequest/data:/app/data \
--restart unless-stopped morsequest
```
SSH: `ssh -i ~/.ssh/claude_gamercomp -p 2135 ubuntu@keylinkit.vpnplus.to`
App dir: `/home/ubuntu/game-arcade/games/morsequest/`
Nginx block: `/etc/nginx/sites-enabled/morsequest`
+10 -3
View File
@@ -9,7 +9,7 @@ import './index.css'
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
export default function App() {
const { profile, token, loading, logout } = useAuth()
const { profile, token, loading, logout, setAuth } = useAuth()
const [route, setRoute] = useState<AppRoute>('login')
const [checkingOnboarding, setCheckingOnboarding] = useState(false)
@@ -49,7 +49,10 @@ export default function App() {
return (
<div className="app-shell">
{route === 'login' && (
<LoginPage onLoginSent={() => setRoute('sent')} />
<LoginPage
onLoginSent={() => setRoute('sent')}
onGuestLogin={(token, profile) => setAuth(token, profile)}
/>
)}
{route === 'sent' && (
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
@@ -58,7 +61,11 @@ export default function App() {
</div>
)}
{route === 'onboarding' && token && (
<OnboardingPage token={token} onComplete={() => setRoute('game')} />
<OnboardingPage
token={token}
operatorName={profile?.display_name ?? null}
onComplete={() => setRoute('game')}
/>
)}
{route === 'game' && token && (
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
+68 -12
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { NOVICE_LETTERS, MORSE, validatePhrase } from '../lib/morse'
import { useState, useEffect } from 'react'
import { NOVICE_LETTERS, MORSE, validatePhrase, suggestPhrases } from '../lib/morse'
type Props = {
token: string
@@ -13,6 +13,7 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
const [phrase, setPhrase] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [suggestions, setSuggestions] = useState<string[]>([])
const letter = LETTERS[index]
const pattern = MORSE[letter] ?? ''
@@ -20,6 +21,12 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
useEffect(() => {
setSuggestions(suggestPhrases(letter, 5))
setPhrase('')
setError(null)
}, [letter])
async function save() {
if (!validation?.valid) return
setSaving(true)
@@ -47,8 +54,6 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
onComplete()
} else {
setIndex(i => i + 1)
setPhrase('')
setError(null)
}
}
@@ -88,14 +93,15 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
Create your mnemonic phrase:
</label>
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
{pattern.length} word{pattern.length !== 1 ? 's' : ''} short words (1-2 letters) for dots,
long words (3+ letters) for dashes.
{pattern.length} word{pattern.length !== 1 ? 's' : ''}
{' '}3 letters for dots ·, 4+ letters for dashes .
{' '}Start a word with "{letter}" to anchor it!
</p>
<input
type="text"
value={phrase}
onChange={e => { setPhrase(e.target.value); setError(null) }}
placeholder={`${pattern.length} word${pattern.length !== 1 ? 's' : ''}, e.g. "${examplePhrase(pattern)}"`}
placeholder={suggestions.length > 0 ? `e.g. "${suggestions[0]}"` : ''}
disabled={saving}
style={{ marginBottom: '0.5rem' }}
/>
@@ -115,6 +121,61 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>{error}</p>
)}
{/* Suggestions */}
<div style={{ marginTop: '0.75rem', marginBottom: '1rem' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.4rem',
}}>
<span style={{ fontSize: '0.85rem', color: 'var(--color-brown)' }}>
Suggestions click to use:
</span>
<button
type="button"
onClick={() => setSuggestions(suggestPhrases(letter, 5))}
disabled={saving}
style={{
padding: '0.1rem 0.4rem',
fontSize: '0.8rem',
background: 'none',
border: '1px solid var(--color-brown)',
borderRadius: '0.25rem',
cursor: 'pointer',
color: 'var(--color-brown)',
lineHeight: 1.4,
}}
>
new
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{suggestions.map(s => (
<button
key={s}
type="button"
onClick={() => { setPhrase(s); setError(null) }}
disabled={saving}
style={{
padding: '0.35rem 0.7rem',
fontSize: '0.9rem',
fontFamily: 'var(--font-mono)',
background: phrase === s ? 'var(--color-gold)' : 'var(--color-parchment)',
border: `1px solid ${phrase === s ? 'var(--color-gold)' : '#c8a96e'}`,
borderRadius: '0.25rem',
cursor: 'pointer',
color: 'var(--color-forest)',
textAlign: 'left',
transition: 'background 0.1s',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
<button
@@ -136,8 +197,3 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
</div>
)
}
// Generate a simple example phrase for the placeholder
function examplePhrase(pattern: string): string {
return pattern.split('').map(c => c === '.' ? 'go' : 'WALKING').join(' ')
}
+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>
)
}
+13 -2
View File
@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
type Profile = { id: number; email: string; display_name: string | null }
export type Profile = {
id: number
email: string | null
display_name: string | null
is_guest: boolean
}
type AuthState = { profile: Profile | null; token: string | null; loading: boolean }
export function useAuth() {
@@ -50,5 +55,11 @@ export function useAuth() {
setState({ profile: null, token: null, loading: false })
}, [])
return { ...state, logout }
// Used by guest login to inject a session without a round-trip
const setAuth = useCallback((token: string, profile: Profile) => {
localStorage.setItem('session', token)
setState({ profile, token, loading: false })
}, [])
return { ...state, logout, setAuth }
}
+18
View File
@@ -61,3 +61,21 @@ input:focus { outline: 2px solid var(--color-gold); outline-offset: 1px; }
margin: 0 auto;
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;
}
}
+80 -7
View File
@@ -3,10 +3,12 @@ import {
MORSE,
NOVICE_LETTERS,
OPERATOR_WORDS,
MNEMONIC_SUGGESTIONS,
wpmToTiming,
DEFAULT_WPM,
DEFAULT_TIMING,
validatePhrase,
suggestPhrases,
pickChallenge,
} from './morse'
@@ -71,28 +73,99 @@ describe('wpmToTiming', () => {
})
})
describe('MNEMONIC_SUGGESTIONS', () => {
it('has suggestions for all 12 novice letters', () => {
NOVICE_LETTERS.forEach(l => {
expect(MNEMONIC_SUGGESTIONS[l]).toBeDefined()
expect(MNEMONIC_SUGGESTIONS[l].length).toBeGreaterThanOrEqual(5)
})
})
it('all suggestions follow the dot/dash word length rules', () => {
for (const letter of NOVICE_LETTERS) {
const pattern = MORSE[letter]
const elements = pattern.split('')
for (const phrase of MNEMONIC_SUGGESTIONS[letter]) {
const words = phrase.split(' ')
expect(words).toHaveLength(elements.length)
words.forEach((w, i) => {
if (elements[i] === '.') expect(w.length).toBeLessThanOrEqual(3)
else expect(w.length).toBeGreaterThanOrEqual(4)
})
}
}
})
it('every suggestion has at least one word starting with the target letter', () => {
for (const letter of NOVICE_LETTERS) {
for (const phrase of MNEMONIC_SUGGESTIONS[letter]) {
const words = phrase.split(' ')
const hasAnchor = words.some(w => w[0].toUpperCase() === letter)
expect(hasAnchor).toBe(true)
}
}
})
})
describe('suggestPhrases', () => {
it('returns 5 suggestions for A (.-)', () => {
const s = suggestPhrases('A', 5)
expect(s).toHaveLength(5)
})
it('returns all curated suggestions when count exceeds available', () => {
const s = suggestPhrases('I', 100)
expect(s).toHaveLength(MNEMONIC_SUGGESTIONS['I'].length)
})
it('returns empty array for unknown letter', () => {
expect(suggestPhrases('$', 5)).toEqual([])
})
})
describe('validatePhrase', () => {
it('accepts valid phrase for E (.)', () => {
expect(validatePhrase('go', 'E')).toEqual({ valid: true })
expect(validatePhrase('eat', 'E')).toEqual({ valid: true })
})
it('accepts 3-letter dot word', () => {
expect(validatePhrase('eat', 'E')).toEqual({ valid: true })
})
it('accepts valid phrase for A (.-)', () => {
expect(validatePhrase('go WALKING', 'A')).toEqual({ valid: true })
// A = .- → dot + dash; "age" starts with A, "WALKING" is 4+
expect(validatePhrase('age WALKING', 'A')).toEqual({ valid: true })
})
it('accepts valid phrase for S (...)', () => {
expect(validatePhrase('go go go', 'S')).toEqual({ valid: true })
// S = ... → three dot words; "sun" starts with S
expect(validatePhrase('sun go go', 'S')).toEqual({ valid: true })
})
it('rejects 4-letter word in dot position', () => {
const r = validatePhrase('walk sun go', 'S')
expect(r.valid).toBe(false)
})
it('accepts 3-letter word in dot position', () => {
expect(validatePhrase('eat', 'E')).toEqual({ valid: true })
})
it('rejects 3-letter word in dash position', () => {
const r = validatePhrase('age go', 'A') // A=.- so second word must be 4+ letters
expect(r.valid).toBe(false)
})
it('accepts 4-letter word in dash position', () => {
expect(validatePhrase('aim also', 'A')).toEqual({ valid: true })
})
it('rejects wrong word count', () => {
const r = validatePhrase('go go', 'S')
const r = validatePhrase('sun go', 'S')
expect(r.valid).toBe(false)
expect(r.message).toContain('3')
})
it('rejects long word for dot position', () => {
const r = validatePhrase('WALKING go go', 'S')
const r = validatePhrase('SAILING go go', 'S')
expect(r.valid).toBe(false)
})
it('rejects short word for dash position', () => {
const r = validatePhrase('go go', 'A')
it('rejects phrase without anchor letter', () => {
// A = .- but no word starts with A
const r = validatePhrase('go walk', 'A')
expect(r.valid).toBe(false)
expect(r.message).toContain('"A"')
})
it('accepts anchor letter in any position', () => {
// A = .- → "go ALSO" — "ALSO" starts with A
expect(validatePhrase('go ALSO', 'A')).toEqual({ valid: true })
})
it('rejects unknown letter', () => {
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
+46 -3
View File
@@ -46,6 +46,44 @@ export interface ValidationResult {
message?: string
}
// Curated mnemonic phrases per letter.
// Every phrase follows the dot/dash length rule and starts words with the target letter.
// Pattern key: ≤3 letters = dot, 4+ letters = dash
export const MNEMONIC_SUGGESTIONS: Record<string, string[]> = {
// A (.-): ≤3 4+
A: ['an APPLE','an ARROW','an ACORN','an ATLAS','an AWARD','aim AFAR','an ANGEL','ace ARMY'],
// E (.): ≤3
E: ['egg','elk','ear','eye','end','era','eel','elm'],
// T (-): 4+
T: ['TREE','TALL','TANK','TIME','TUNE','TOAD','TIDE','TRAIL'],
// I (..): ≤3 ≤3
I: ['it is','in ice','in ink','is icy','I irk'],
// N (-.): 4+ ≤3
N: ['noon nap','nine nil','nice nod','navy net','near now','note now','nest no'],
// S (...): ≤3 ≤3 ≤3
S: ['sun set sky','sky sea sun','she sat shy','sir sat sad','see six spy','sip sip sip','say so sir'],
// H (....): ≤3 ≤3 ≤3 ≤3
H: ['he has his hat','he hid his hog','he hit his hip','ho hum ho hum','he had his ham','he hug his hen'],
// R (.-.): ≤3 4+ ≤3
R: ['red ROSE row','rob RICH rat','run RACE run','rip ROPE raw','ram ROAD run','rub RUST raw'],
// D (-..): 4+ ≤3 ≤3
D: ['dark dim den','deep dry dip','dawn dew dry','dusk dim den','dare dig dry','done did dry'],
// L (.-..): ≤3 4+ ≤3 ≤3
L: ['lit LAMP lay low','let LUCK lay low','lad LEAP log low','led LION lay low','let LIFE lay low'],
// U (..-): ≤3 ≤3 4+
U: ['us up UPON','use up URGE','us up UNDER','us up UNTIL','us up UNITY'],
// O (---): 4+ 4+ 4+
O: ['over only once','open oven oath','obey ours only','opal orbs omen','orca over only'],
}
export function suggestPhrases(letter: string, count: number): string[] {
const upper = letter.toUpperCase()
const phrases = MNEMONIC_SUGGESTIONS[upper]
if (!phrases || phrases.length === 0) return []
const shuffled = [...phrases].sort(() => Math.random() - 0.5)
return shuffled.slice(0, Math.min(count, shuffled.length))
}
export function validatePhrase(phrase: string, letter: string): ValidationResult {
const upper = letter.toUpperCase()
const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined
@@ -63,15 +101,20 @@ export function validatePhrase(phrase: string, letter: string): ValidationResult
for (let i = 0; i < words.length; i++) {
const isDot = elements[i] === '.'
const isShort = words[i].length <= 2
const isShort = words[i].length <= 3
if (isDot && !isShort) {
return { valid: false, message: `Word "${words[i]}" should be short (1-2 chars) for a dot` }
return { valid: false, message: `"${words[i]}" is too long for a dot — use 3 letters or less` }
}
if (!isDot && isShort) {
return { valid: false, message: `Word "${words[i]}" should be longer (3+ chars) for a dash` }
return { valid: false, message: `"${words[i]}" is too short for a dash — use 4+ letters` }
}
}
const hasAnchor = words.some(w => w[0].toUpperCase() === upper)
if (!hasAnchor) {
return { valid: false, message: `At least one word should start with "${upper}"` }
}
return { valid: true }
}
+60
View File
@@ -0,0 +1,60 @@
export type Gender = 'male' | 'female' | 'mystery'
export const OPERATOR_NAMES: Record<Gender, string[]> = {
male: [
'Dusty Clicksworth',
'Buck Dittley',
'Flash Henderson',
'Smokey McBeep',
'Knuckles McGee',
'Leadfoot Larry Tapsworth',
'Jittery Jack Clicker',
"Two-Finger Tex",
'Wild Bill Keyer',
'Rusty Tapper',
'Cactus Pete Signalton',
'Ol\' Sparky Johnson',
'Slick Fingersby',
'Gabby Dan Dashmore',
'Young Buck Beepsworth',
],
female: [
'Ada Dottsworth',
'Clementine Dashford',
'Pearl Signalton',
'Nellie Tapsworth',
'Hattie Clickmore',
'Violet Waverley',
'Mabel Keymoor',
'Belle Dashmore',
'Ruby Signalbright',
'Clara Quick-Keys',
'Ethel Clicksworth',
'Dot O\'Dashes',
'Hazel Transmitter',
'Iris Tapford',
'Goldie Wavemore',
],
mystery: [
'The Phantom Sender',
'Agent Dot-Dash',
'X. Marconi',
'The Ghost Operator',
'Shadow Keyer',
'The Unknown Transmitter',
'Agent Zero-Zero',
'The Masked Tapper',
'Signal Unknown',
'The Wandering Wave',
'Cipher McGee',
'The Midnight Sender',
'Static McFrequency',
'Professor Dash',
'The Lone Transmitter',
],
}
export function randomName(gender: Gender): string {
const list = OPERATOR_NAMES[gender]
return list[Math.floor(Math.random() * list.length)]
}
+184 -34
View File
@@ -1,16 +1,60 @@
import { useState } from 'react'
import type { Profile } from '../hooks/useAuth'
import { type Gender, randomName } from '../lib/names'
type LoginPageProps = { onLoginSent: () => void }
type Props = {
onLoginSent: () => void
onGuestLogin: (token: string, profile: Profile) => void
}
export function LoginPage({ onLoginSent }: LoginPageProps) {
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: '♂' },
{ value: 'female', label: 'Female', icon: '♀' },
{ value: 'mystery', label: 'Mystery', icon: '✦' },
]
export function LoginPage({ onLoginSent, onGuestLogin }: Props) {
const [gender, setGender] = useState<Gender | null>(null)
const [name, setName] = useState('')
const [joining, setJoining] = useState(false)
const [showEmail, setShowEmail] = useState(false)
const [email, setEmail] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
function pickGender(g: Gender) {
setGender(g)
setName(randomName(g))
setError(null)
}
function reroll() {
if (gender) setName(randomName(gender))
}
async function startPlaying() {
if (!name) return
setJoining(true)
setError(null)
try {
const r = await fetch('/api/auth/guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: name }),
})
if (!r.ok) throw new Error('Failed to create session')
const { token, profile } = await r.json()
onGuestLogin(token, profile)
} catch {
setError('Something went wrong. Try again.')
setJoining(false)
}
}
async function sendMagicLink(e: React.FormEvent) {
e.preventDefault()
if (!email.includes('@') || !email.includes('.')) {
setError('Please enter a valid email address.')
setError('Enter a valid email address.')
return
}
setSubmitting(true)
@@ -21,44 +65,150 @@ export function LoginPage({ onLoginSent }: LoginPageProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
if (!r.ok) {
const body = await r.json().catch(() => ({}))
throw new Error(body.error || 'Something went wrong')
}
if (!r.ok) throw new Error('Failed to send link')
onLoginSent()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} catch {
setError('Failed to send magic link. Try again.')
} finally {
setSubmitting(false)
}
}
return (
<div style={{ maxWidth: 420, margin: '4rem auto 0' }}>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>MorseQuest</h1>
<p style={{ marginBottom: '2rem', color: 'var(--color-brown)' }}>
Learn Morse code, adventurer. Enter your email to begin your quest.
</p>
<form onSubmit={handleSubmit}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
Email address
</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
disabled={submitting}
required
style={{ marginBottom: '1rem' }}
/>
{error && (
<p style={{ color: 'crimson', marginBottom: '1rem', fontSize: '0.9rem' }}>{error}</p>
)}
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
{submitting ? 'Sending...' : 'Send Magic Link'}
<div style={{ maxWidth: 440, margin: '3rem auto 0', padding: '0 1rem' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<h1 style={{ fontFamily: 'var(--font-header)', fontSize: '2rem', color: 'var(--color-forest)', marginBottom: '0.25rem' }}>
Join the Telegraph Corps
</h1>
<p style={{ color: 'var(--color-brown)', fontSize: '0.95rem' }}>
No sign-up needed pick your operator and start transmitting!
</p>
</div>
{/* Step 1: Gender picker */}
<div style={{ marginBottom: '1.5rem' }}>
<p style={{ fontWeight: 600, marginBottom: '0.6rem', textAlign: 'center' }}>
Choose your operator type:
</p>
<div style={{ display: 'flex', gap: '0.75rem' }}>
{GENDER_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => pickGender(opt.value)}
style={{
flex: 1,
padding: '0.7rem 0.5rem',
fontFamily: 'var(--font-header)',
fontSize: '1rem',
background: gender === opt.value ? 'var(--color-gold)' : 'var(--color-parchment)',
border: `2px solid ${gender === opt.value ? 'var(--color-gold)' : '#c8a96e'}`,
borderRadius: '0.4rem',
cursor: 'pointer',
color: 'var(--color-forest)',
fontWeight: gender === opt.value ? 700 : 400,
transition: 'all 0.15s',
}}
>
<div style={{ fontSize: '1.4rem' }}>{opt.icon}</div>
{opt.label}
</button>
))}
</div>
</div>
{/* Step 2: Name + reroll + start */}
{gender && (
<div style={{ marginBottom: '1.5rem' }}>
<p style={{ fontWeight: 600, marginBottom: '0.5rem', textAlign: 'center' }}>
Your operator name:
</p>
<div style={{
background: 'var(--color-parchment)',
border: '2px solid var(--color-gold)',
borderRadius: '0.4rem',
padding: '0.9rem 1rem',
textAlign: 'center',
marginBottom: '0.75rem',
}}>
<span style={{
fontFamily: 'var(--font-header)',
fontSize: '1.3rem',
color: 'var(--color-forest)',
fontWeight: 700,
}}>
{name}
</span>
</div>
<div style={{ display: 'flex', gap: '0.6rem' }}>
<button
type="button"
onClick={reroll}
disabled={joining}
className="btn btn-ghost"
style={{ whiteSpace: 'nowrap' }}
>
Different name
</button>
<button
type="button"
onClick={startPlaying}
disabled={joining}
className="btn"
style={{ flex: 1, fontSize: '1.05rem' }}
>
{joining ? 'Joining...' : 'Begin Transmitting!'}
</button>
</div>
</div>
)}
{error && (
<p style={{ color: 'crimson', fontSize: '0.9rem', textAlign: 'center', marginBottom: '1rem' }}>
{error}
</p>
)}
{/* Optional email section */}
<div style={{ borderTop: '1px solid #c8a96e', paddingTop: '1rem', marginTop: '0.5rem' }}>
<button
type="button"
onClick={() => { setShowEmail(v => !v); setError(null) }}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-brown)',
fontSize: '0.9rem',
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
margin: '0 auto',
}}
>
<span>{showEmail ? '▾' : '▸'}</span>
Already have an account or want cross-device access?
</button>
</form>
{showEmail && (
<form onSubmit={sendMagicLink} style={{ marginTop: '0.75rem' }}>
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem', textAlign: 'center' }}>
Enter your email to receive a magic link.
</p>
<input
type="email"
value={email}
onChange={e => { setEmail(e.target.value); setError(null) }}
placeholder="telegraph@example.com"
disabled={submitting}
style={{ marginBottom: '0.5rem' }}
/>
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
{submitting ? 'Sending...' : 'Send Magic Link'}
</button>
</form>
)}
</div>
</div>
)
}
+24 -3
View File
@@ -1,20 +1,41 @@
import { useState, useCallback } from 'react'
import { MnemonicBuilder } from '../components/MnemonicBuilder'
import { MnemonicCodebook } from '../components/MnemonicCodebook'
type Props = {
token: string
operatorName: string | null
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 (
<div>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>
Build Your Mnemonics
Build Your Codebook
</h1>
<p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}>
Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes.
</p>
<MnemonicBuilder token={token} onComplete={onComplete} />
<MnemonicBuilder token={token} onComplete={handleBuilderComplete} />
</div>
)
}
+31 -1
View File
@@ -8,11 +8,33 @@ function initDb(dbPath) {
instance.pragma('journal_mode = WAL')
instance.pragma('foreign_keys = ON')
// Migration: add is_guest + make email nullable on existing installs
const profileCols = instance.pragma('table_info(profiles)').map(c => c.name)
if (profileCols.length > 0 && !profileCols.includes('is_guest')) {
instance.pragma('foreign_keys = OFF')
instance.exec(`
CREATE TABLE profiles_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE,
display_name TEXT,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
INSERT INTO profiles_v2 (id, email, display_name, is_guest, created_at, last_seen)
SELECT id, email, display_name, 0, created_at, last_seen FROM profiles;
DROP TABLE profiles;
ALTER TABLE profiles_v2 RENAME TO profiles;
`)
instance.pragma('foreign_keys = ON')
}
instance.exec(`
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
display_name TEXT,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
@@ -56,6 +78,13 @@ function initDb(dbPath) {
);
`)
function createGuestProfile(displayName) {
const now = Date.now()
return instance.prepare(
'INSERT INTO profiles (email, display_name, is_guest, created_at, last_seen) VALUES (NULL,?,1,?,?) RETURNING *'
).get(displayName, now, now)
}
function createProfile(email, displayName) {
const now = Date.now()
const stmt = instance.prepare(
@@ -230,6 +259,7 @@ function initDb(dbPath) {
return {
_db: instance,
createGuestProfile,
createProfile,
findProfileByEmail,
findProfileById,
+17
View File
@@ -62,6 +62,22 @@ function createServer(db) {
// ── Auth routes ──────────────────────────────────────────────────────────
if (pathname === '/api/auth/guest' && method === 'POST') {
const body = await readBody(req)
const name = body.display_name
if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 80) {
return send(res, 400, { error: 'display_name required (2-80 chars)' })
}
const profile = db.createGuestProfile(name.trim())
db.getOrCreateProgress(profile.id)
const sessionToken = crypto.randomBytes(32).toString('hex')
db.createSession(sessionToken, profile.id)
return send(res, 200, {
token: sessionToken,
profile: { id: profile.id, email: null, display_name: profile.display_name, is_guest: true },
})
}
if (pathname === '/api/auth/request' && method === 'POST') {
const body = await readBody(req)
const email = body.email
@@ -102,6 +118,7 @@ function createServer(db) {
id: profile.id,
email: profile.email,
display_name: profile.display_name,
is_guest: profile.is_guest === 1,
})
}
+31
View File
@@ -43,6 +43,37 @@ after(() => {
return new Promise(resolve => server.close(resolve))
})
describe('POST /api/auth/guest', () => {
test('creates guest profile and returns token + profile', async () => {
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'Dusty Clicksworth' })
assert.equal(r.status, 200)
assert.ok(r.body.token)
assert.equal(r.body.profile.display_name, 'Dusty Clicksworth')
assert.equal(r.body.profile.is_guest, true)
assert.equal(r.body.profile.email, null)
})
test('session token from guest login works with /api/auth/me', async () => {
const g = await request(server, 'POST', '/api/auth/guest', { display_name: 'Flash Henderson' })
const me = await request(server, 'GET', '/api/auth/me', null, {
Authorization: `Bearer ${g.body.token}`,
})
assert.equal(me.status, 200)
assert.equal(me.body.display_name, 'Flash Henderson')
assert.equal(me.body.is_guest, true)
})
test('returns 400 if display_name missing', async () => {
const r = await request(server, 'POST', '/api/auth/guest', {})
assert.equal(r.status, 400)
})
test('returns 400 if display_name too short', async () => {
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'X' })
assert.equal(r.status, 400)
})
})
describe('POST /api/auth/request', () => {
test('returns 200 ok for valid email', async () => {
const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })