Sub-word rhyme highlighting: dim word fill + bright tail underline

Whole rhyming word fills dimly; a bright underline marks just the
rhyming part (tonight -> under 'ight'), so the precise interlock shows
without the fill looking ragged when the phoneme->letter guess is a
letter off. Removed 'scheme' from the toolbar readout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 03:12:20 -04:00
parent b4df695f57
commit 42d48b2c42
3 changed files with 46 additions and 16 deletions
+29 -6
View File
@@ -282,6 +282,25 @@ def _grapheme_tail(raw: str) -> str | None:
return tail
def rhyme_char_start(word: str) -> int:
"""Best-guess character index where a word's rhyming tail begins:
take as many vowel-letter groups from the end as the pronunciation's
rhyming part has vowels (tonight -> 'ight', creation -> 'ation').
Spelling can't map phonemes exactly, so this is an approximation."""
ph = phones_for(word)
if not ph:
return 0
nv = len(_tail_vowels(ph)) or 1
groups = [m.start() for m in re.finditer(r"[aeiouyAEIOUY]+", word)]
# drop a silent trailing 'e' (write, time, fire) so it doesn't eat a slot
if (len(groups) > 1 and word[-1] in "eE"
and groups[-1] == len(word) - 1):
groups.pop()
if len(groups) < nv:
return 0
return groups[len(groups) - nv]
def rime_keys(word: str) -> tuple[str, ...]:
"""Perfect-rhyme keys, one per candidate pronunciation."""
cands = phones_candidates(word)
@@ -1295,12 +1314,16 @@ def analyze(draft: Draft):
"legend": legend,
})
toks_out = [
{"l": t["line"], "s": t["start"], "e": t["end"], "g": t["gid"],
"end": t["is_end"], "ph": "vowels" in t,
"slant": t["slant"] or groups_out[t["gid"]]["slant"]}
for t in [*tokens, *phrases] if t["gid"] is not None
]
toks_out = []
for t in [*tokens, *phrases]:
if t["gid"] is None:
continue
d = {"l": t["line"], "s": t["start"], "e": t["end"], "g": t["gid"],
"end": t["is_end"], "ph": "vowels" in t,
"slant": t["slant"] or groups_out[t["gid"]]["slant"]}
if "vowels" not in t: # single word: where its rhyming tail starts
d["rs"] = t["start"] + rhyme_char_start(t["word"])
toks_out.append(d)
meter, meter_by_line = [], {}
for i, line in enumerate(lines):
if sids[i] is None:
+11 -10
View File
@@ -609,7 +609,7 @@ function render(){
h = esc(line);
}else{
const cuts = new Set([0, line.length]);
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); if(t.rs != null) cuts.add(t.rs); });
als.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
opens.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
const pts = [...cuts].sort((a,b)=>a-b);
@@ -622,25 +622,31 @@ function render(){
const al = als.find(t=>t.s <= a && b <= t.e);
const op = opens.find(t=>t.s <= a && b <= t.e);
if(!w && !p && !al && !op){ h += text; continue; }
// fills = tail sound (rhyme); underline = head sound (alliteration);
// whole word fills dimly; the rhyming part gets a bright underline.
// gray = an ending still waiting for its answer
let style = '';
const shadows = [];
if(w || p){
const t = (w && (focusGid === null || w.g === focusGid)) ? w
: (p && (focusGid === null || p.g === focusGid)) ? p
: (w || p);
let alpha = !t.ph ? (t.end ? 34 : 19) : (t.end ? 24 : 14);
let alpha = !t.ph ? (t.end ? 28 : 16) : (t.end ? 20 : 12);
if(t.slant) alpha = Math.round(alpha * 0.62); // slant sits back
// the caret's family brightens; everything else stays itself
if(focusGid !== null && t.g === focusGid){
alpha = Math.min(85, Math.round(alpha * 2.8));
style += 'color:#1a120c;font-weight:600;'; // dark ink on the lit chip
}
style += `background:color-mix(in srgb, ${colorOf(t)} ${alpha}%, transparent);`;
// bright underline on the rhyming tail (rs..e) of a word
if(w && (w.rs == null || a >= w.rs)){
const u = w.slant ? 55 : 80;
shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, ${colorOf(w)} ${u}%, transparent)`);
}
}else if(op){
style += `background:color-mix(in srgb, var(--ink-dim) 13%, transparent);`;
}
if(al) style += `box-shadow:inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent);`;
if(al) shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent)`);
if(shadows.length) style += `box-shadow:${shadows.join(',')};`;
h += `<span class="hseg" style="${style}">${text}</span>`;
}
}
@@ -798,11 +804,6 @@ function buildReadout(){
const bi = st.lines.indexOf(ln);
if(bi >= 0) parts.unshift(`bar ${bi + 1}/${st.lines.length}`);
}
if(st && st.scheme){
const sch = st.scheme.toUpperCase();
const shown = sch.slice(0, 16).split('').join(' ') + (sch.length > 16 ? ' …' : '');
parts.push(`scheme <b>${shown}</b>`);
}
if(focusGid !== null && analysis){
const g = analysis.groups.find(x=>x.id === focusGid);
const fam = [...new Set(analysis.tokens.filter(t=>t.g===focusGid)
+6
View File
@@ -740,3 +740,9 @@ def test_no_matching_across_stanzas():
# but within each stanza they still rhyme
assert "night" in s1
assert any({"bright", "sight"} <= s for s in bg.values())
def test_rhyme_char_start():
from app import rhyme_char_start
assert rhyme_char_start("tonight") == 4 # 'ight'
assert rhyme_char_start("write") == 2 # 'ite' (silent-e skipped)