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>
This commit is contained in:
2026-04-30 03:48:08 +00:00
parent 972e11718c
commit 0f7eb19626
3 changed files with 51 additions and 13 deletions
+2 -1
View File
@@ -94,7 +94,8 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
</label>
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
{pattern.length} word{pattern.length !== 1 ? 's' : ''}
{' '}3 letters for dots · (short), 4+ letters for dashes (long).
{' '}3 letters for dots ·, 4+ letters for dashes .
{' '}Start a word with "{letter}" to anchor it!
</p>
<input
type="text"
+30 -10
View File
@@ -101,6 +101,14 @@ describe('suggestPhrases', () => {
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', () => {
expect(suggestPhrases('$', 5)).toEqual([])
})
@@ -108,40 +116,52 @@ describe('suggestPhrases', () => {
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('run', 'E')).toEqual({ valid: true })
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 go go', 'S')
const r = validatePhrase('walk sun go', 'S')
expect(r.valid).toBe(false)
})
it('accepts 3-letter word in dot position', () => {
expect(validatePhrase('run', 'E')).toEqual({ valid: true })
expect(validatePhrase('eat', '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
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('go walk', 'A')).toEqual({ valid: true })
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 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' })
})
+19 -2
View File
@@ -54,6 +54,8 @@ export const DOT_WORDS = [
'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 = [
@@ -88,19 +90,29 @@ export const DASH_WORDS = [
'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[] {
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 => {
const words = elements.map((el, i) => {
if (i === 0 && anchorWords.length > 0) return pickRandom(anchorWords)
const pool = el === '.' ? DOT_WORDS : DASH_WORDS
return pool[Math.floor(Math.random() * pool.length)]
return pickRandom(pool)
})
const phrase = words.join(' ')
if (!seen.has(phrase)) { seen.add(phrase); results.push(phrase) }
@@ -134,6 +146,11 @@ export function validatePhrase(phrase: string, letter: string): ValidationResult
}
}
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 }
}