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:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user