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:
@@ -101,7 +101,7 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
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={`e.g. "${examplePhrase(pattern)}"`}
|
placeholder={suggestions.length > 0 ? `e.g. "${suggestions[0]}"` : ''}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
style={{ marginBottom: '0.5rem' }}
|
style={{ marginBottom: '0.5rem' }}
|
||||||
/>
|
/>
|
||||||
@@ -197,8 +197,3 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a simple example phrase for the placeholder
|
|
||||||
function examplePhrase(pattern: string): string {
|
|
||||||
return pattern.split('').map(c => c === '.' ? 'run' : 'WALK').join(' ')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import {
|
|||||||
MORSE,
|
MORSE,
|
||||||
NOVICE_LETTERS,
|
NOVICE_LETTERS,
|
||||||
OPERATOR_WORDS,
|
OPERATOR_WORDS,
|
||||||
DOT_WORDS,
|
MNEMONIC_SUGGESTIONS,
|
||||||
DASH_WORDS,
|
|
||||||
wpmToTiming,
|
wpmToTiming,
|
||||||
DEFAULT_WPM,
|
DEFAULT_WPM,
|
||||||
DEFAULT_TIMING,
|
DEFAULT_TIMING,
|
||||||
@@ -74,15 +73,36 @@ describe('wpmToTiming', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('DOT_WORDS and DASH_WORDS', () => {
|
describe('MNEMONIC_SUGGESTIONS', () => {
|
||||||
it('all DOT_WORDS are 3 letters or less', () => {
|
it('has suggestions for all 12 novice letters', () => {
|
||||||
DOT_WORDS.forEach(w => expect(w.length).toBeLessThanOrEqual(3))
|
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('has at least 30 DOT_WORDS', () => expect(DOT_WORDS.length).toBeGreaterThanOrEqual(30))
|
it('all suggestions follow the dot/dash word length rules', () => {
|
||||||
it('has at least 30 DASH_WORDS', () => expect(DASH_WORDS.length).toBeGreaterThanOrEqual(30))
|
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', () => {
|
describe('suggestPhrases', () => {
|
||||||
@@ -90,24 +110,9 @@ describe('suggestPhrases', () => {
|
|||||||
const s = suggestPhrases('A', 5)
|
const s = suggestPhrases('A', 5)
|
||||||
expect(s).toHaveLength(5)
|
expect(s).toHaveLength(5)
|
||||||
})
|
})
|
||||||
it('each suggestion has correct word count', () => {
|
it('returns all curated suggestions when count exceeds available', () => {
|
||||||
const s = suggestPhrases('S', 5) // S = ... (3 words)
|
const s = suggestPhrases('I', 100)
|
||||||
s.forEach(phrase => expect(phrase.split(' ')).toHaveLength(3))
|
expect(s).toHaveLength(MNEMONIC_SUGGESTIONS['I'].length)
|
||||||
})
|
|
||||||
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 empty array for unknown letter', () => {
|
it('returns empty array for unknown letter', () => {
|
||||||
expect(suggestPhrases('$', 5)).toEqual([])
|
expect(suggestPhrases('$', 5)).toEqual([])
|
||||||
|
|||||||
+32
-68
@@ -46,78 +46,42 @@ export interface ValidationResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Words for mnemonic suggestions
|
// Curated mnemonic phrases per letter.
|
||||||
export const DOT_WORDS = [
|
// Every phrase follows the dot/dash length rule and starts words with the target letter.
|
||||||
'go','do','be','see','run','fly','set','try','can','the','win','get',
|
// Pattern key: ≤3 letters = dot, 4+ letters = dash
|
||||||
'top','sky','sun','sea','war','key','age','all','now','one','big','far',
|
export const MNEMONIC_SUGGESTIONS: Record<string, string[]> = {
|
||||||
'low','red','old','hot','new','day','eat','ice','joy','leg','man','out',
|
// A (.-): ≤3 4+
|
||||||
'pay','put','raw','sad','shy','son','tap','tin','toe','toy','two','use',
|
A: ['an APPLE','an ARROW','an ACORN','an ATLAS','an AWARD','aim AFAR','an ANGEL','ace ARMY'],
|
||||||
'way','you','aim','air','ask','bit','cut','dry','fun','god','had','hit',
|
// E (.): ≤3
|
||||||
'its','law','log','lot','mad','mix','net','not','odd','pet','row','she',
|
E: ['egg','elk','ear','eye','end','era','eel','elm'],
|
||||||
'up','urn','us','ore','our','oar','oh','ow','ox','nod','nap','nil',
|
// T (-): 4+
|
||||||
'nor','dip','dig','dug','den','did','dam','hub','hug','hum','hen',
|
T: ['TREE','TALL','TANK','TIME','TUNE','TOAD','TIDE','TRAIL'],
|
||||||
]
|
// I (..): ≤3 ≤3
|
||||||
|
I: ['it is','in ice','in ink','is icy','I irk'],
|
||||||
export const DASH_WORDS = [
|
// N (-.): 4+ ≤3
|
||||||
'walk','sail','camp','hunt','fire','bold','fast','tall','dark','wide',
|
N: ['noon nap','nine nil','nice nod','navy net','near now','note now','nest no'],
|
||||||
'long','deep','hard','warm','cool','loud','slow','sure','true','real',
|
// S (...): ≤3 ≤3 ≤3
|
||||||
'free','gold','star','wave','wind','lion','hawk','rock','iron','rise',
|
S: ['sun set sky','sky sea sun','she sat shy','sir sat sad','see six spy','sip sip sip','say so sir'],
|
||||||
'lead','dare','race','grow','find','make','keep','know','call','glow',
|
// H (....): ≤3 ≤3 ≤3 ≤3
|
||||||
'best','blue','book','burn','calm','care','cave','city','coal','code',
|
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'],
|
||||||
'cold','cook','cost','cure','dawn','deal','deck','dive','door','down',
|
// R (.-.): ≤3 4+ ≤3
|
||||||
'drum','dust','earn','east','edge','epic','face','fact','fair','fall',
|
R: ['red ROSE row','rob RICH rat','run RACE run','rip ROPE raw','ram ROAD run','rub RUST raw'],
|
||||||
'fame','farm','fate','feel','feet','fill','fine','firm','fish','flag',
|
// D (-..): 4+ ≤3 ≤3
|
||||||
'flow','food','form','fort','game','gate','gear','give','goal','good',
|
D: ['dark dim den','deep dry dip','dawn dew dry','dusk dim den','dare dig dry','done did dry'],
|
||||||
'gray','grin','grow','gulf','gust','hail','hair','hall','hand','harm',
|
// L (.-..): ≤3 4+ ≤3 ≤3
|
||||||
'hawk','head','heal','heat','help','high','hike','hill','hold','hole',
|
L: ['lit LAMP lay low','let LUCK lay low','lad LEAP log low','led LION lay low','let LIFE lay low'],
|
||||||
'home','hook','hope','horn','host','huge','idea','iron','jest','join',
|
// U (..-): ≤3 ≤3 4+
|
||||||
'jump','just','kill','kind','king','kiss','lake','lamp','land','lane',
|
U: ['us up UPON','use up URGE','us up UNDER','us up UNTIL','us up UNITY'],
|
||||||
'last','late','leaf','leap','lend','lift','like','line','link','live',
|
// O (---): 4+ 4+ 4+
|
||||||
'lock','lone','look','love','lure','main','make','many','mark','mast',
|
O: ['over only once','open oven oath','obey ours only','opal orbs omen','orca over only'],
|
||||||
'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)]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function suggestPhrases(letter: string, count: number): string[] {
|
export function suggestPhrases(letter: string, count: number): string[] {
|
||||||
const upper = letter.toUpperCase()
|
const upper = letter.toUpperCase()
|
||||||
const pattern = MORSE[upper]
|
const phrases = MNEMONIC_SUGGESTIONS[upper]
|
||||||
if (!pattern) return []
|
if (!phrases || phrases.length === 0) return []
|
||||||
const elements = pattern.split('')
|
const shuffled = [...phrases].sort(() => Math.random() - 0.5)
|
||||||
// First word should start with the letter
|
return shuffled.slice(0, Math.min(count, shuffled.length))
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validatePhrase(phrase: string, letter: string): ValidationResult {
|
export function validatePhrase(phrase: string, letter: string): ValidationResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user