diff --git a/app.py b/app.py
index 516e73a..2ab4744 100644
--- a/app.py
+++ b/app.py
@@ -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:
diff --git a/static/index.html b/static/index.html
index 807e0f0..cca2b0b 100644
--- a/static/index.html
+++ b/static/index.html
@@ -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 += `${text}`;
}
}
@@ -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 ${shown}`);
- }
if(focusGid !== null && analysis){
const g = analysis.groups.find(x=>x.id === focusGid);
const fam = [...new Set(analysis.tokens.filter(t=>t.g===focusGid)
diff --git a/tests/test_rhymes.py b/tests/test_rhymes.py
index fc53cff..cc13c3f 100644
--- a/tests/test_rhymes.py
+++ b/tests/test_rhymes.py
@@ -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)