From e865fc17e9ad0a0a1122ff4f861db43991876034 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 7 Jun 2026 12:36:39 -0400 Subject: [PATCH] Sidebar rebuilt as a word page; phrase lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One lookup renders the whole entry — phonetic readout as a plain header (card chrome and dismiss gone), describes, rhymes by syllable (+near), synonyms always fully expanded — with collapsed sections behind "more…", a clickable history trail, and lookups firing as you type (Go button and mode seg removed). Multi-word lookups work: phrases read straight through for the phonetic readout, rhyme on their final word (labeled), and hit WordNet compounds for synonyms (new york -> Empire State). Co-Authored-By: Claude Opus 4.8 (1M context) --- app.py | 27 +++-- static/index.html | 283 ++++++++++++++++++++-------------------------- 2 files changed, 138 insertions(+), 172 deletions(-) diff --git a/app.py b/app.py index 3e17a25..e3c149c 100644 --- a/app.py +++ b/app.py @@ -1209,9 +1209,9 @@ def synonyms_for(w: str, limit: int) -> list[dict]: broader terms, and related words. Input is lemmatized first so 'keys' and 'feeling' resolve to 'key' and 'feel'.""" wn = get_wordnet() - base = w + base = w.replace(" ", "_") for pos in ("n", "v", "a", "r"): - m = wn.morphy(w, pos) + m = wn.morphy(base, pos) if m: base = m break @@ -1298,9 +1298,13 @@ def get_homophones(w: str, phones: str) -> list[str]: @app.get("/api/word") def word_info(word: str): - """Phonetic anatomy of a word: phones, syllables, stress, rime.""" - w = word.strip().lower()[:64] - phones = phones_for(w) + """Phonetic anatomy of a word — or a phrase, read straight through.""" + w = " ".join(word.strip().lower().split()[:4])[:64] + if " " in w: + parts = [phones_for(p) for p in w.split()] + phones = " ".join(p for p in parts if p) if all(parts) else None + else: + phones = phones_for(w) if not phones: return {"word": w, "known": False} pl = phones.split() @@ -1310,9 +1314,9 @@ def word_info(word: str): senses = None try: wn = get_wordnet() - base = w + base = w.replace(" ", "_") for pos in ("n", "v", "a", "r"): - m = wn.morphy(w, pos) + m = wn.morphy(base, pos) if m: base = m break @@ -1322,7 +1326,7 @@ def word_info(word: str): return {"word": w, "known": True, "phones": DIGITS.sub("", phones), "syl": len(stress), "stress": stress, "rime": rime, "senses": senses, - "homophones": get_homophones(w, phones), + "homophones": [] if " " in w else get_homophones(w, phones), "zipf": round(zipf_frequency(w, "en"), 1)} @@ -1330,6 +1334,10 @@ def word_info(word: str): def lookup(word: str, mode: str = "rhyme", limit: int = 60): w = word.strip().lower()[:64] limit = min(limit, 200) + rhyme_on = None + if " " in w and mode != "syn": + rhyme_on = w.split()[-1] # a phrase rhymes on its final word + w = rhyme_on if mode == "syn": sections = synonyms_for(w, limit) return {"word": w, "mode": mode, "known": bool(sections), @@ -1346,7 +1354,8 @@ def lookup(word: str, mode: str = "rhyme", limit: int = 60): # rhyme mode carries both: perfect in "words", slant in "near" words = _ranked(perfect, {w}, limit) near = _ranked(near_cands, {w}, limit // 2) - return {"word": w, "mode": mode, "known": True, "words": words, "near": near} + return {"word": w, "mode": mode, "known": True, "words": words, + "near": near, "rhyme_on": rhyme_on} app.mount("/", StaticFiles(directory=Path(__file__).parent / "static", diff --git a/static/index.html b/static/index.html index 5b95d1b..5de2ff7 100644 --- a/static/index.html +++ b/static/index.html @@ -187,6 +187,11 @@ .tab:hover { color: var(--ink); } .panel-body { padding: 16px; overflow-y: auto; flex: 1; } .lookup-row { display: flex; gap: 8px; } + #histBar { color: var(--ink-dim); font-size: 12px; margin: 8px 2px 0; min-height: 0; } + #histBar .hist { cursor: pointer; } + #histBar .hist:hover { color: var(--accent); } + .more { color: var(--accent); cursor: pointer; font-size: 11px; letter-spacing: 0; text-transform: lowercase; } + .res-label.sub { color: #6a5f52; margin: 8px 0 2px; } .lookup-row input { flex: 1; font-family: inherit; font-size: 14px; background: var(--panel-2); border: 1px solid var(--line); @@ -201,14 +206,8 @@ } .seg button.active { color: var(--accent); border-color: var(--accent); } .results { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 6px; } - .defcard { - background: var(--panel-2); border: 1px solid var(--line); - border-radius: 10px; padding: 12px 14px; margin: 10px 0 4px; - } - .defhead { display: flex; align-items: baseline; gap: 8px; font-size: 14px; } - .defx { margin-left: auto; cursor: pointer; color: var(--ink-dim); font-size: 15px; } - .defx:hover { color: var(--r6); } - .defphon { font-size: 12px; margin: 8px 0 10px; line-height: 1.6; } + .defhead { margin-top: 14px; font-size: 15px; } + .defphon { font-size: 12px; margin: 4px 0 2px; line-height: 1.6; } .defphon i { color: var(--accent-2); font-style: normal; } .chip { font-size: 13px; background: var(--panel-2); border: 1px solid var(--line); @@ -304,17 +303,12 @@ Double-click any word to look it up on the right.">
- - -
-
- - - +
+
-

Type a word and hit Go, or double-click a word in your draft. A phonetic readout pins on top; the buttons switch rhymes, synonyms, and describing words. Tip: end a word with “?” to jump to synonyms.

+

Type a word — or double-click one in your draft — and its whole entry appears: how it sounds, what describes it, what rhymes, what could replace it.

@@ -697,9 +691,8 @@ function lookupSelection(){ const sel = editor.value.slice(editor.selectionStart, editor.selectionEnd) .trim().replace(/[^A-Za-z']/g,''); if(sel && !sel.includes(' ')){ - document.getElementById('lookupInput').value = sel; tab('lookup'); - setMode('desc'); + lookupFor(sel); } } editor.addEventListener('dblclick', lookupSelection); @@ -713,97 +706,79 @@ if(window.matchMedia('(pointer: coarse)').matches){ /* ---------- insert at cursor ---------- */ /* ============================================================ - LOOKUP — rhymes & near rhymes come from our backend (CMU dict, - frequency-ranked). Synonyms come from the free Datamuse API. + WORD ENTRIES — one lookup renders the whole entry: phonetic + readout, describes, rhymes (+near), synonyms. Rhymes & sounds + come from our engine; describes comes from Datamuse. ============================================================ */ -const modeSeg = document.getElementById('modeSeg'); -let mode = 'desc'; -function setMode(m){ - mode = m; - [...modeSeg.children].forEach(c=>c.classList.toggle('active', c.dataset.mode === m)); +const lookupInput = document.getElementById('lookupInput'); +const resultsBox = document.getElementById('lookupResults'); +const histBar = document.getElementById('histBar'); +const defBox = document.getElementById('defBox'); +let curWord = null, lookupSeq = 0, entry = null; +const wordHistory = []; + +lookupInput.addEventListener('focus', ()=> lookupInput.select()); +lookupInput.addEventListener('keydown', e=>{ if(e.key === 'Enter') doLookup(); }); +lookupInput.addEventListener('input', debounce(()=>{ + const w = lookupInput.value.trim(); + if(w.length >= 2 && /^[A-Za-z' -]+\??$/.test(w)) doLookup(); +}, 400)); + +function lookupFor(word){ + lookupInput.value = word; doLookup(); } -modeSeg.addEventListener('click', e=>{ - const b = e.target.closest('button'); if(!b) return; - setMode(b.dataset.mode); -}); -document.getElementById('lookupBtn').addEventListener('click', doLookup); -document.getElementById('lookupInput').addEventListener('keydown', e=>{ if(e.key==='Enter') doLookup(); }); -const lookupInput = document.getElementById('lookupInput'); -lookupInput.addEventListener('focus', ()=> lookupInput.select()); -const resultsBox = document.getElementById('lookupResults'); +function renderHistory(){ + const past = wordHistory.filter(w=>w !== curWord).slice(-5).reverse(); + histBar.innerHTML = past.length + ? '↩ ' + past.map(w=>`${esc(w)}`).join(' · ') + : ''; + histBar.querySelectorAll('.hist').forEach(el=> + el.addEventListener('click', ()=> lookupFor(el.dataset.w))); +} + async function doLookup(){ - let word = document.getElementById('lookupInput').value.trim(); - // RhymeZone-style: a trailing ? jumps to synonyms - if(word.endsWith('?')){ - word = word.slice(0, -1).trim(); - document.getElementById('lookupInput').value = word; - if(word) setMode('syn'); - return; - } - if(!word) return; + const word = lookupInput.value.trim().replace(/\?$/, '').toLowerCase(); + if(!word || word === curWord) return; document.getElementById('tab-lookup').scrollTop = 0; - // the card belongs to the word, not the submode: rebuild only when - // the word changes, and respect a dismissal until then - if(word.toLowerCase() !== defWord){ - defWord = word.toLowerCase(); - defDismissed = false; - showDefinition(word); + if(curWord){ + const i = wordHistory.indexOf(curWord); + if(i >= 0) wordHistory.splice(i, 1); + wordHistory.push(curWord); + if(wordHistory.length > 8) wordHistory.shift(); } - resultsBox.innerHTML = '

Searching…

'; - try{ - const r = await fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=${mode}`); - const data = await r.json(); - if(mode === 'desc'){ - renderDescribes(word); - return; - } - if(mode === 'syn'){ - if(!data.known){ - resultsBox.innerHTML = `

No synonyms found for “${esc(word)}”.

`; - return; - } - renderSections(word, data.sections); + curWord = word; + renderHistory(); + const seq = ++lookupSeq; + defBox.innerHTML = `
${esc(word)}
` + + `
`; + resultsBox.innerHTML = '

gathering…

'; + const [info, rhyme, syn, desc] = await Promise.all([ + fetch(`/api/word?word=${encodeURIComponent(word)}`).then(r=>r.json()).catch(()=>null), + fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=rhyme`).then(r=>r.json()).catch(()=>null), + fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=syn`).then(r=>r.json()).catch(()=>null), + fetch(`https://api.datamuse.com/words?rel_jjb=${encodeURIComponent(word)}&max=40`).then(r=>r.json()).catch(()=>null), + ]); + if(seq !== lookupSeq) return; + const el = defBox.querySelector('#defPhon'); + if(el){ + if(info && info.known){ + const dots = [...info.stress].map(s=>s==='1' ? '●' : '○').join(''); + const mates = draftMates(word); + el.innerHTML = `/${esc(info.phones.toLowerCase())}/ · ${info.syl} syl ${dots}` + + ` · rhymes on ${esc(info.rime.toLowerCase())}` + + (info.senses >= 3 ? ` · ${info.senses} senses` : '') + + ((info.homophones || []).length ? `
sounds like: ${info.homophones.map(esc).join(', ')}` : '') + + (mates.length ? `
in your draft: ${mates.map(esc).join(', ')}` : ''); }else{ - if(!data.known){ - resultsBox.innerHTML = `

“${esc(word)}” isn't in the pronunciation dictionary.

`; - return; - } - renderRhymes(word, data); + el.textContent = 'not in the pronunciation dictionary'; } - }catch(err){ - resultsBox.innerHTML = `

Lookup failed — is the backend running? (${esc(String(err))})

`; } + entry = {word, rhyme, syn, desc, expanded: {}}; + paintSections(); } -function rarity(z){ - if(z == null) return ''; - return z >= 4.6 ? ' common' : (z < 3.4 ? ' rare' : ''); -} -function chipHtml(items, cls){ - // items: strings or {word, z}; words already in the draft get a tick - const inDraft = new Set((editor.value.toLowerCase().match(/[a-z']+/g)) || []); - return '
' + - items.map(d=>{ - const w = d.word || d, r = rarity(d.z); - const used = inDraft.has(w.toLowerCase()) ? ' indraft' : ''; - return `${esc(w)}`; - }).join('') + '
'; -} -function wireChips(){ - // clicking a result navigates to that word's dictionary entry - resultsBox.querySelectorAll('.chip').forEach(c=>{ - c.addEventListener('click', ()=>{ - document.getElementById('lookupInput').value = c.dataset.w; - doLookup(); - }); - }); -} - -/* ---------- word card: the engine's own read on a word ---------- */ -const defBox = document.getElementById('defBox'); -let defWord = null, defDismissed = false; function draftMates(word){ if(!analysis) return []; const lw = word.toLowerCase(); @@ -820,80 +795,62 @@ function draftMates(word){ return [...mates].slice(0, 8); } -async function showDefinition(word){ - defBox.innerHTML = `
${esc(word)}` + - `×
` + - `
`; - defBox.querySelector('.defx').addEventListener('click', ()=>{ defBox.innerHTML=''; defDismissed = true; }); - try{ - const info = await (await fetch(`/api/word?word=${encodeURIComponent(word)}`)).json(); - const el = defBox.querySelector('#defPhon'); - if(!el) return; - if(!info.known){ el.textContent = "not in the pronunciation dictionary"; return; } - const dots = [...info.stress].map(s=>s==='1' ? '●' : '○').join(''); - const mates = draftMates(word); - el.innerHTML = `/${esc(info.phones.toLowerCase())}/ · ${info.syl} syl ${dots}` + - ` · rhymes on ${esc(info.rime.toLowerCase())}` + - (info.senses >= 3 ? ` · ${info.senses} senses` : '') + - ((info.homophones || []).length ? `
sounds like: ${info.homophones.map(esc).join(', ')}` : '') + - (mates.length ? `
in your draft: ${mates.map(esc).join(', ')}` : ''); - }catch(e){} +function rarity(z){ + if(z == null) return ''; + return z >= 4.6 ? ' common' : (z < 3.4 ? ' rare' : ''); } -async function renderDescribes(word){ - resultsBox.innerHTML = '

Searching…

'; - try{ - const r = await fetch(`https://api.datamuse.com/words?rel_jjb=${encodeURIComponent(word)}&max=60`); - const data = await r.json(); - const words = data.map(d=>d.word); - if(!words.length){ - resultsBox.innerHTML = `

No describing words for “${esc(word)}”.

`; - return; - } - resultsBox.innerHTML = `
words that describe “${esc(word)}”
` + chipHtml(words); - wireChips(); - }catch(e){ - resultsBox.innerHTML = '

Couldn\'t reach the describing-words service.

'; - } +function chipHtml(items, cls){ + // items: strings or {word, z}; words already in the draft get a tick + const inDraft = new Set((editor.value.toLowerCase().match(/[a-z']+/g)) || []); + return '
' + + items.map(d=>{ + const w = d.word || d, r = rarity(d.z); + const used = inDraft.has(w.toLowerCase()) ? ' indraft' : ''; + return `${esc(w)}`; + }).join('') + '
'; } -function renderChips(label, words){ - if(!words.length){ resultsBox.innerHTML = '

No results.

'; return; } - resultsBox.innerHTML = `
${label}
` + chipHtml(words.slice(0,50)); - wireChips(); -} -function renderSections(word, sections){ - if(!sections.length){ resultsBox.innerHTML = `

No synonyms for “${esc(word)}”.

`; return; } - let h = ''; - sections.forEach(s=>{ - h += `
${esc(s.label)}
` + - chipHtml(s.words.map(d=>d.word), s.label === 'synonyms' ? '' : 'near'); - }); - resultsBox.innerHTML = h; - wireChips(); +function secLabel(id, label, truncated){ + return `
${label}` + + (truncated && !entry.expanded[id] ? ` more…` : '') + + '
'; } -function syllableSections(items, cls){ - const bySyl = {}; - items.forEach(d=>{ (bySyl[d.syl || 0] ||= []).push(d); }); - return Object.keys(bySyl).sort((a,b)=>a-b).map(k=> - `
${k == 0 ? '?' : k} syllable${k == 1 ? '' : 's'}
` + chipHtml(bySyl[k], cls) - ).join(''); -} -function renderRhymes(word, data){ - const near = data.near || []; - if(!data.words.length && !near.length){ - resultsBox.innerHTML = `

No rhymes for “${esc(word)}”.

`; - return; - } +function paintSections(){ + const e = entry; + if(!e) return; let h = ''; - if(data.words.length){ - h += `
Rhymes for “${esc(word)}”
` + syllableSections(data.words); + if(e.desc && e.desc.length){ + const items = e.desc.map(d=>d.word); + const lim = e.expanded.desc ? items.length : 12; + h += secLabel('desc', 'describes', items.length > 12) + chipHtml(items.slice(0, lim)); } - if(near.length){ - h += `
Near rhymes
` + chipHtml(near.map(d=>d.word), 'near'); + if(e.rhyme && e.rhyme.known && (e.rhyme.words.length || (e.rhyme.near || []).length)){ + const lim = e.expanded.rhyme ? 999 : 8; + const bySyl = {}; + e.rhyme.words.forEach(d=>{ (bySyl[d.syl || 0] ||= []).push(d); }); + const truncated = e.rhyme.words.length > 16 || (e.rhyme.near || []).length > 8; + const onWord = e.rhyme.rhyme_on ? ` — on “${esc(e.rhyme.rhyme_on)}”` : ''; + h += secLabel('rhyme', 'rhymes' + onWord, truncated); + Object.keys(bySyl).sort((a,b)=>a-b).forEach(k=>{ + h += `
${k == 0 ? '?' : k} syl
` + chipHtml(bySyl[k].slice(0, lim)); + }); + const near = (e.rhyme.near || []).slice(0, e.expanded.rhyme ? 999 : 8); + if(near.length) h += `
near
` + chipHtml(near, 'near'); } - resultsBox.innerHTML = h; - wireChips(); + if(e.syn && e.syn.known && e.syn.sections.length){ + // bottom section: always fully expanded — nothing below to crowd + h += secLabel('syn', 'synonyms', false); + e.syn.sections.forEach(s=>{ + if(s.label !== 'synonyms') h += `
${esc(s.label)}
`; + h += chipHtml(s.words.map(d=>d.word), s.label === 'synonyms' ? '' : 'near'); + }); + } + resultsBox.innerHTML = h || `

nothing found for “${esc(e.word)}”.

`; + resultsBox.querySelectorAll('.chip').forEach(c=> + c.addEventListener('click', ()=> lookupFor(c.dataset.w))); + resultsBox.querySelectorAll('.more').forEach(m=> + m.addEventListener('click', ()=>{ entry.expanded[m.dataset.sec] = true; paintSections(); })); } /* ============================================================