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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NOVICE_LETTERS, MORSE, validatePhrase } from '../lib/morse'
|
import { NOVICE_LETTERS, MORSE, validatePhrase, suggestPhrases } from '../lib/morse'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
token: string
|
token: string
|
||||||
@@ -13,6 +13,7 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
const [phrase, setPhrase] = useState('')
|
const [phrase, setPhrase] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||||
|
|
||||||
const letter = LETTERS[index]
|
const letter = LETTERS[index]
|
||||||
const pattern = MORSE[letter] ?? ''
|
const pattern = MORSE[letter] ?? ''
|
||||||
@@ -20,6 +21,12 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
|
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
|
||||||
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
|
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSuggestions(suggestPhrases(letter, 5))
|
||||||
|
setPhrase('')
|
||||||
|
setError(null)
|
||||||
|
}, [letter])
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!validation?.valid) return
|
if (!validation?.valid) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -47,8 +54,6 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
onComplete()
|
onComplete()
|
||||||
} else {
|
} else {
|
||||||
setIndex(i => i + 1)
|
setIndex(i => i + 1)
|
||||||
setPhrase('')
|
|
||||||
setError(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +93,14 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
Create your mnemonic phrase:
|
Create your mnemonic phrase:
|
||||||
</label>
|
</label>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
|
<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,
|
{pattern.length} word{pattern.length !== 1 ? 's' : ''} —
|
||||||
long words (3+ letters) for dashes.
|
{' '}≤3 letters for dots · (short), 4+ letters for dashes — (long).
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={phrase}
|
value={phrase}
|
||||||
onChange={e => { setPhrase(e.target.value); setError(null) }}
|
onChange={e => { setPhrase(e.target.value); setError(null) }}
|
||||||
placeholder={`${pattern.length} word${pattern.length !== 1 ? 's' : ''}, e.g. "${examplePhrase(pattern)}"`}
|
placeholder={`e.g. "${examplePhrase(pattern)}"`}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
style={{ marginBottom: '0.5rem' }}
|
style={{ marginBottom: '0.5rem' }}
|
||||||
/>
|
/>
|
||||||
@@ -115,6 +120,61 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>{error}</p>
|
<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 */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
<button
|
<button
|
||||||
@@ -139,5 +199,5 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
|
|
||||||
// Generate a simple example phrase for the placeholder
|
// Generate a simple example phrase for the placeholder
|
||||||
function examplePhrase(pattern: string): string {
|
function examplePhrase(pattern: string): string {
|
||||||
return pattern.split('').map(c => c === '.' ? 'go' : 'WALKING').join(' ')
|
return pattern.split('').map(c => c === '.' ? 'run' : 'WALK').join(' ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import {
|
|||||||
MORSE,
|
MORSE,
|
||||||
NOVICE_LETTERS,
|
NOVICE_LETTERS,
|
||||||
OPERATOR_WORDS,
|
OPERATOR_WORDS,
|
||||||
|
DOT_WORDS,
|
||||||
|
DASH_WORDS,
|
||||||
wpmToTiming,
|
wpmToTiming,
|
||||||
DEFAULT_WPM,
|
DEFAULT_WPM,
|
||||||
DEFAULT_TIMING,
|
DEFAULT_TIMING,
|
||||||
validatePhrase,
|
validatePhrase,
|
||||||
|
suggestPhrases,
|
||||||
pickChallenge,
|
pickChallenge,
|
||||||
} from './morse'
|
} from './morse'
|
||||||
|
|
||||||
@@ -71,16 +74,65 @@ describe('wpmToTiming', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DOT_WORDS and DASH_WORDS', () => {
|
||||||
|
it('all DOT_WORDS are 3 letters or less', () => {
|
||||||
|
DOT_WORDS.forEach(w => expect(w.length).toBeLessThanOrEqual(3))
|
||||||
|
})
|
||||||
|
it('all DASH_WORDS are 4 letters or more', () => {
|
||||||
|
DASH_WORDS.forEach(w => expect(w.length).toBeGreaterThanOrEqual(4))
|
||||||
|
})
|
||||||
|
it('has at least 30 DOT_WORDS', () => expect(DOT_WORDS.length).toBeGreaterThanOrEqual(30))
|
||||||
|
it('has at least 30 DASH_WORDS', () => expect(DASH_WORDS.length).toBeGreaterThanOrEqual(30))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('suggestPhrases', () => {
|
||||||
|
it('returns 5 suggestions for A (.-)', () => {
|
||||||
|
const s = suggestPhrases('A', 5)
|
||||||
|
expect(s).toHaveLength(5)
|
||||||
|
})
|
||||||
|
it('each suggestion has correct word count', () => {
|
||||||
|
const s = suggestPhrases('S', 5) // S = ... (3 words)
|
||||||
|
s.forEach(phrase => expect(phrase.split(' ')).toHaveLength(3))
|
||||||
|
})
|
||||||
|
it('suggestions for E are single words ≤3 letters', () => {
|
||||||
|
const s = suggestPhrases('E', 5)
|
||||||
|
s.forEach(phrase => {
|
||||||
|
expect(phrase.split(' ')).toHaveLength(1)
|
||||||
|
expect(phrase.length).toBeLessThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('returns empty array for unknown letter', () => {
|
||||||
|
expect(suggestPhrases('$', 5)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('validatePhrase', () => {
|
describe('validatePhrase', () => {
|
||||||
it('accepts valid phrase for E (.)', () => {
|
it('accepts valid phrase for E (.)', () => {
|
||||||
expect(validatePhrase('go', 'E')).toEqual({ valid: true })
|
expect(validatePhrase('go', 'E')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
|
it('accepts 3-letter dot word', () => {
|
||||||
|
expect(validatePhrase('run', 'E')).toEqual({ valid: true })
|
||||||
|
})
|
||||||
it('accepts valid phrase for A (.-)', () => {
|
it('accepts valid phrase for A (.-)', () => {
|
||||||
expect(validatePhrase('go WALKING', 'A')).toEqual({ valid: true })
|
expect(validatePhrase('go WALKING', 'A')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
it('accepts valid phrase for S (...)', () => {
|
it('accepts valid phrase for S (...)', () => {
|
||||||
expect(validatePhrase('go go go', 'S')).toEqual({ valid: true })
|
expect(validatePhrase('go go go', 'S')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
|
it('rejects 4-letter word in dot position', () => {
|
||||||
|
const r = validatePhrase('walk go go', 'S')
|
||||||
|
expect(r.valid).toBe(false)
|
||||||
|
})
|
||||||
|
it('accepts 3-letter word in dot position', () => {
|
||||||
|
expect(validatePhrase('run', 'E')).toEqual({ valid: true })
|
||||||
|
})
|
||||||
|
it('rejects 3-letter word in dash position', () => {
|
||||||
|
const r = validatePhrase('go 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('go walk', 'A')).toEqual({ valid: true })
|
||||||
|
})
|
||||||
it('rejects wrong word count', () => {
|
it('rejects wrong word count', () => {
|
||||||
const r = validatePhrase('go go', 'S')
|
const r = validatePhrase('go go', 'S')
|
||||||
expect(r.valid).toBe(false)
|
expect(r.valid).toBe(false)
|
||||||
@@ -90,10 +142,6 @@ describe('validatePhrase', () => {
|
|||||||
const r = validatePhrase('WALKING go go', 'S')
|
const r = validatePhrase('WALKING go go', 'S')
|
||||||
expect(r.valid).toBe(false)
|
expect(r.valid).toBe(false)
|
||||||
})
|
})
|
||||||
it('rejects short word for dash position', () => {
|
|
||||||
const r = validatePhrase('go go', 'A')
|
|
||||||
expect(r.valid).toBe(false)
|
|
||||||
})
|
|
||||||
it('rejects unknown letter', () => {
|
it('rejects unknown letter', () => {
|
||||||
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
|
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
|
||||||
})
|
})
|
||||||
|
|||||||
+65
-3
@@ -46,6 +46,68 @@ export interface ValidationResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Words for mnemonic suggestions
|
||||||
|
export const DOT_WORDS = [
|
||||||
|
'go','do','be','see','run','fly','set','try','can','the','win','get',
|
||||||
|
'top','sky','sun','sea','war','key','age','all','now','one','big','far',
|
||||||
|
'low','red','old','hot','new','day','eat','ice','joy','leg','man','out',
|
||||||
|
'pay','put','raw','sad','shy','son','tap','tin','toe','toy','two','use',
|
||||||
|
'way','you','aim','air','ask','bit','cut','dry','fun','god','had','hit',
|
||||||
|
'its','law','log','lot','mad','mix','net','not','odd','pet','row','she',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DASH_WORDS = [
|
||||||
|
'walk','sail','camp','hunt','fire','bold','fast','tall','dark','wide',
|
||||||
|
'long','deep','hard','warm','cool','loud','slow','sure','true','real',
|
||||||
|
'free','gold','star','wave','wind','lion','hawk','rock','iron','rise',
|
||||||
|
'lead','dare','race','grow','find','make','keep','know','call','glow',
|
||||||
|
'best','blue','book','burn','calm','care','cave','city','coal','code',
|
||||||
|
'cold','cook','cost','cure','dawn','deal','deck','dive','door','down',
|
||||||
|
'drum','dust','earn','east','edge','epic','face','fact','fair','fall',
|
||||||
|
'fame','farm','fate','feel','feet','fill','fine','firm','fish','flag',
|
||||||
|
'flow','food','form','fort','game','gate','gear','give','goal','good',
|
||||||
|
'gray','grin','grow','gulf','gust','hail','hair','hall','hand','harm',
|
||||||
|
'hawk','head','heal','heat','help','high','hike','hill','hold','hole',
|
||||||
|
'home','hook','hope','horn','host','huge','idea','iron','jest','join',
|
||||||
|
'jump','just','kill','kind','king','kiss','lake','lamp','land','lane',
|
||||||
|
'last','late','leaf','leap','lend','lift','like','line','link','live',
|
||||||
|
'lock','lone','look','love','lure','main','make','many','mark','mast',
|
||||||
|
'meal','mean','meet','mind','mine','miss','mist','more','most','move',
|
||||||
|
'much','must','name','need','next','none','noon','note','pain','pale',
|
||||||
|
'park','part','pass','path','peak','pick','pile','plan','play','plot',
|
||||||
|
'plow','pole','pond','pool','port','pour','pull','push','race','rain',
|
||||||
|
'rank','read','reed','rest','ride','ring','rise','risk','road','roam',
|
||||||
|
'roar','rock','roll','roof','room','rope','rose','ruin','rule','rush',
|
||||||
|
'rust','sage','sale','sand','save','seal','seat','sell','send','ship',
|
||||||
|
'shop','shot','shut','side','silk','sing','sink','site','skip','soak',
|
||||||
|
'soar','soul','soup','span','spin','spot','spur','star','stay','stem',
|
||||||
|
'step','stir','stop','take','tale','talk','tame','tank','task','tell',
|
||||||
|
'tent','term','test','than','that','them','they','tide','time','toll',
|
||||||
|
'tone','tool','toss','tour','town','trek','trim','trip','tune','turn',
|
||||||
|
'veil','vine','vote','wail','wait','wake','wall','warn','wash','wave',
|
||||||
|
'weed','week','well','went','wide','wife','wild','will','wind','wine',
|
||||||
|
'wing','wire','wise','wish','wolf','wood','wool','word','work','worn',
|
||||||
|
'yarn','yell','zone','zoom',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function suggestPhrases(letter: string, count: number): string[] {
|
||||||
|
const upper = letter.toUpperCase()
|
||||||
|
const pattern = MORSE[upper]
|
||||||
|
if (!pattern) return []
|
||||||
|
const elements = pattern.split('')
|
||||||
|
const results: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (let attempt = 0; attempt < count * 30 && results.length < count; attempt++) {
|
||||||
|
const words = elements.map(el => {
|
||||||
|
const pool = el === '.' ? DOT_WORDS : DASH_WORDS
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)]
|
||||||
|
})
|
||||||
|
const phrase = words.join(' ')
|
||||||
|
if (!seen.has(phrase)) { seen.add(phrase); results.push(phrase) }
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
export function validatePhrase(phrase: string, letter: string): ValidationResult {
|
export function validatePhrase(phrase: string, letter: string): ValidationResult {
|
||||||
const upper = letter.toUpperCase()
|
const upper = letter.toUpperCase()
|
||||||
const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined
|
const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined
|
||||||
@@ -63,12 +125,12 @@ export function validatePhrase(phrase: string, letter: string): ValidationResult
|
|||||||
|
|
||||||
for (let i = 0; i < words.length; i++) {
|
for (let i = 0; i < words.length; i++) {
|
||||||
const isDot = elements[i] === '.'
|
const isDot = elements[i] === '.'
|
||||||
const isShort = words[i].length <= 2
|
const isShort = words[i].length <= 3
|
||||||
if (isDot && !isShort) {
|
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) {
|
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` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user