Cadence families and unanswered endings

Two starred ideas, zero new chrome:
- Rhythm dots take a shared color when 2+ lines have the identical
  stress contour (5+ syllables) — flow rhymes, visible
- Line-endings with no rhyme partner get a faint gray fill in the
  rhyme layer: the open loops that show where to strike next

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 05:39:24 -04:00
parent 57710fd001
commit fcf18f627c
3 changed files with 49 additions and 5 deletions
+12 -1
View File
@@ -1035,9 +1035,20 @@ def analyze(draft: Draft):
cluster.append(t)
flush(cluster)
# unanswered endings: line-ends still waiting for a rhyme partner —
# the open loops that tell a writer where to strike next
open_out = []
last_tok = {}
for t in tokens:
if t["is_end"]:
last_tok[t["line"]] = t
for i, t in last_tok.items():
if i not in end_gid:
open_out.append({"l": i, "s": t["start"], "e": t["end"]})
return {"lines": lines, "tokens": toks_out, "groups": groups_out,
"stanzas": stanzas, "meter": meter, "stress": stress_out,
"allit": allit_out}
"allit": allit_out, "open": open_out}
# --------------------------------------------------------------------------
+30 -4
View File
@@ -510,11 +510,13 @@ function render(){
const lines = editor.value.split('\n');
const tokByLine = {};
const allitByLine = {};
const openByLine = {};
const groupInfo = {};
if(analysis){
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
if(analysis.allit) analysis.allit.forEach(t=>{ (allitByLine[t.l] ||= []).push(t); });
if(analysis.open) analysis.open.forEach(t=>{ (openByLine[t.l] ||= []).push(t); });
}
const colorOf = t => `var(--r${groupInfo[t.g].color % COLORS})`;
let html = '';
@@ -537,13 +539,15 @@ function render(){
const words = toks.filter(t=>!t.ph);
const phrases = toks.filter(t=>t.ph);
const als = (allitToggle.checked && fresh) ? (allitByLine[i] || []) : [];
const opens = (rhymeToggle.checked && fresh) ? (openByLine[i] || []) : [];
let h = '';
if(!toks.length && !als.length){
if(!toks.length && !als.length && !opens.length){
h = esc(line);
}else{
const cuts = new Set([0, line.length]);
toks.forEach(t=>{ cuts.add(t.s); cuts.add(t.e); });
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);
for(let k = 0; k < pts.length - 1; k++){
const a = pts[k], b = pts[k+1];
@@ -552,13 +556,17 @@ function render(){
const w = words.find(t=>t.s <= a && b <= t.e);
const p = phrases.find(t=>t.s <= a && b <= t.e);
const al = als.find(t=>t.s <= a && b <= t.e);
if(!w && !p && !al){ h += text; continue; }
// fills = tail sound (rhyme); underline = head sound (alliteration)
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);
// gray = an ending still waiting for its answer
let style = '';
if(w || p){
const alpha = w ? (w.end ? 34 : 14) : (p.end ? 24 : 10);
const color = w ? colorOf(w) : colorOf(p);
style += `background:color-mix(in srgb, ${color} ${alpha}%, 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);`;
h += `<span class="hseg" style="${style}">${text}</span>`;
@@ -573,10 +581,27 @@ function render(){
buildReadout();
}
function cadenceColors(){
// exact stress-contour matches (5+ syllables) form a flow family
const map = {};
if(!analysis || !analysis.meter) return map;
const byPat = {};
analysis.meter.forEach(m=>{
if(m.stress && m.stress.length >= 5) (byPat[m.stress] ||= []).push(m.l);
});
let fid = 0;
Object.keys(byPat).sort().forEach(pat=>{
const lns = byPat[pat];
if(lns.length >= 2){ lns.forEach(l=>{ map[l] = fid % COLORS; }); fid++; }
});
return map;
}
function renderStress(lines){
if(!stressToggle.checked){ stresslayer.innerHTML = ''; return; }
const byLine = {};
if(analysis && analysis.stress) analysis.stress.forEach(s=>{ (byLine[s.l] ||= []).push(s); });
const cmap = cadenceColors();
let html = '';
lines.forEach((line, i)=>{
const fresh = analysis && analysis.lines[i] === line;
@@ -586,7 +611,8 @@ function renderStress(lines){
if(s.s < pos) return;
h2 += esc(line.slice(pos, s.s));
const dots = [...s.st].map(c=> c === '0' ? '○' : '●').join('');
h2 += `<span class="sw">${esc(line.slice(s.s, s.e))}<span class="sd">${dots}</span></span>`;
const col = cmap[i] != null ? ` style="color:var(--r${cmap[i]})"` : '';
h2 += `<span class="sw">${esc(line.slice(s.s, s.e))}<span class="sd"${col}>${dots}</span></span>`;
pos = s.e;
});
h2 += esc(line.slice(pos));
+7
View File
@@ -560,3 +560,10 @@ def test_alliteration_needs_three_and_locality():
def test_word_senses():
from app import word_info
assert word_info(word="light")["senses"] >= 10
def test_unanswered_endings_reported():
res = analyze(Draft(text="the cat\nso blue\na hat\nthe end"))
opens = {res["lines"][o["l"]][o["s"]:o["e"]] for o in res["open"]}
assert "blue" in opens and "end" in opens
assert "hat" not in opens and "cat" not in opens