From 0f7eb19626dfaa20cb3663fcfdb76b86fe72c10e Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 30 Apr 2026 03:48:08 +0000 Subject: [PATCH] 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) --- client/src/components/MnemonicBuilder.tsx | 3 +- client/src/lib/morse.test.ts | 40 +++++++++++++++++------ client/src/lib/morse.ts | 21 ++++++++++-- 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/client/src/components/MnemonicBuilder.tsx b/client/src/components/MnemonicBuilder.tsx index b0029e4..ddfb5fd 100644 --- a/client/src/components/MnemonicBuilder.tsx +++ b/client/src/components/MnemonicBuilder.tsx @@ -94,7 +94,8 @@ export function MnemonicBuilder({ token, onComplete }: Props) {

{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!

{ 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' }) }) diff --git a/client/src/lib/morse.ts b/client/src/lib/morse.ts index 92c2377..f495a33 100644 --- a/client/src/lib/morse.ts +++ b/client/src/lib/morse.ts @@ -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(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() 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 } }