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>
This commit is contained in:
2026-04-30 03:58:06 +00:00
parent 0f7eb19626
commit 8232e75fa9
3 changed files with 65 additions and 101 deletions
+1 -6
View File
@@ -101,7 +101,7 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
type="text"
value={phrase}
onChange={e => { setPhrase(e.target.value); setError(null) }}
placeholder={`e.g. "${examplePhrase(pattern)}"`}
placeholder={suggestions.length > 0 ? `e.g. "${suggestions[0]}"` : ''}
disabled={saving}
style={{ marginBottom: '0.5rem' }}
/>
@@ -197,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 === '.' ? 'run' : 'WALK').join(' ')
}
+32 -27
View File
@@ -3,8 +3,7 @@ import {
MORSE,
NOVICE_LETTERS,
OPERATOR_WORDS,
DOT_WORDS,
DASH_WORDS,
MNEMONIC_SUGGESTIONS,
wpmToTiming,
DEFAULT_WPM,
DEFAULT_TIMING,
@@ -74,15 +73,36 @@ 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))
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 DASH_WORDS are 4 letters or more', () => {
DASH_WORDS.forEach(w => expect(w.length).toBeGreaterThanOrEqual(4))
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)
}
}
})
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', () => {
@@ -90,24 +110,9 @@ describe('suggestPhrases', () => {
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('first word starts with the target letter', () => {
for (const letter of ['A','E','T','S','N','H','R','D','L','I','U','O']) {
const s = suggestPhrases(letter, 5)
s.forEach(phrase => {
expect(phrase[0].toUpperCase()).toBe(letter)
})
}
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([])
+32 -68
View File
@@ -46,78 +46,42 @@ export interface ValidationResult {
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',
'up','urn','us','ore','our','oar','oh','ow','ox','nod','nap','nil',
'nor','dip','dig','dug','den','did','dam','hub','hug','hum','hen',
]
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',
'oath','once','only','open','ours','over','oven',
'unit','upon','urge','used',
]
function pickRandom<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
// 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 pattern = MORSE[upper]
if (!pattern) return []
const elements = pattern.split('')
// First word should start with the letter
const firstPool = elements[0] === '.' ? DOT_WORDS : DASH_WORDS
const anchorWords = firstPool.filter(w => w[0].toUpperCase() === upper)
const results: string[] = []
const seen = new Set<string>()
for (let attempt = 0; attempt < count * 30 && results.length < count; attempt++) {
const words = elements.map((el, i) => {
if (i === 0 && anchorWords.length > 0) return pickRandom(anchorWords)
const pool = el === '.' ? DOT_WORDS : DASH_WORDS
return pickRandom(pool)
})
const phrase = words.join(' ')
if (!seen.has(phrase)) { seen.add(phrase); results.push(phrase) }
}
return results
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 {