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:
@@ -94,7 +94,8 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
</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' : ''} —
|
{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>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ describe('suggestPhrases', () => {
|
|||||||
expect(phrase.length).toBeLessThanOrEqual(3)
|
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([])
|
||||||
})
|
})
|
||||||
@@ -108,40 +116,52 @@ describe('suggestPhrases', () => {
|
|||||||
|
|
||||||
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('eat', 'E')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
it('accepts 3-letter dot word', () => {
|
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 (.-)', () => {
|
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 (...)', () => {
|
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', () => {
|
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)
|
expect(r.valid).toBe(false)
|
||||||
})
|
})
|
||||||
it('accepts 3-letter word in dot position', () => {
|
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', () => {
|
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)
|
expect(r.valid).toBe(false)
|
||||||
})
|
})
|
||||||
it('accepts 4-letter word in dash position', () => {
|
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', () => {
|
it('rejects wrong word count', () => {
|
||||||
const r = validatePhrase('go go', 'S')
|
const r = validatePhrase('sun go', 'S')
|
||||||
expect(r.valid).toBe(false)
|
expect(r.valid).toBe(false)
|
||||||
expect(r.message).toContain('3')
|
expect(r.message).toContain('3')
|
||||||
})
|
})
|
||||||
it('rejects long word for dot position', () => {
|
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)
|
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', () => {
|
it('rejects unknown letter', () => {
|
||||||
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
|
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
|
||||||
})
|
})
|
||||||
|
|||||||
+19
-2
@@ -54,6 +54,8 @@ export const DOT_WORDS = [
|
|||||||
'pay','put','raw','sad','shy','son','tap','tin','toe','toy','two','use',
|
'pay','put','raw','sad','shy','son','tap','tin','toe','toy','two','use',
|
||||||
'way','you','aim','air','ask','bit','cut','dry','fun','god','had','hit',
|
'way','you','aim','air','ask','bit','cut','dry','fun','god','had','hit',
|
||||||
'its','law','log','lot','mad','mix','net','not','odd','pet','row','she',
|
'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 = [
|
export const DASH_WORDS = [
|
||||||
@@ -88,19 +90,29 @@ export const DASH_WORDS = [
|
|||||||
'weed','week','well','went','wide','wife','wild','will','wind','wine',
|
'weed','week','well','went','wide','wife','wild','will','wind','wine',
|
||||||
'wing','wire','wise','wish','wolf','wood','wool','word','work','worn',
|
'wing','wire','wise','wish','wolf','wood','wool','word','work','worn',
|
||||||
'yarn','yell','zone','zoom',
|
'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 pattern = MORSE[upper]
|
||||||
if (!pattern) return []
|
if (!pattern) return []
|
||||||
const elements = pattern.split('')
|
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 results: string[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (let attempt = 0; attempt < count * 30 && results.length < count; attempt++) {
|
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
|
const pool = el === '.' ? DOT_WORDS : DASH_WORDS
|
||||||
return pool[Math.floor(Math.random() * pool.length)]
|
return pickRandom(pool)
|
||||||
})
|
})
|
||||||
const phrase = words.join(' ')
|
const phrase = words.join(' ')
|
||||||
if (!seen.has(phrase)) { seen.add(phrase); results.push(phrase) }
|
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 }
|
return { valid: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user