Caret spotlight: brighten the family + margin brackets

Clicking into a colored word brightens its whole rhyme family (dark
ink on lit chips) and draws a soft rounded bracket in the left gutter
spanning the family's lines — rhyme and alliteration each get their
own bracket, gated to their toggle. No dimming of the rest; additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:08:34 -04:00
parent c77d24fb83
commit c86f87f707
+83 -10
View File
@@ -108,13 +108,24 @@
}
.editor-shell.rhythm #highlight,
.editor-shell.rhythm #stresslayer,
.editor-shell.rhythm #editor { line-height: 2.35; }
.editor-shell.rhythm #editor { line-height: 3.2; }
#highlight {
pointer-events: none;
color: var(--ink);
z-index: 1;
}
#highlight .anno { color: #6a5f52; }
#gutter { position: absolute; inset: 0; pointer-events: none; z-index: 3; overflow: hidden; }
#gutter .bracket {
position: absolute; left: 7px; width: 7px;
border: 2.5px solid currentColor; border-right: 0;
border-top-left-radius: 7px; border-bottom-left-radius: 7px;
}
#gutter .bracket.allit { left: 15px; width: 5px; border-width: 2px; }
#gutter .tick {
position: absolute; left: 7px; width: 7px; height: 2.5px; border-radius: 999px;
}
#gutter .tick.allit { left: 15px; width: 5px; }
#editor::selection { background: rgba(232,129,74,0.22); color: transparent; }
#stresslayer {
pointer-events: none;
@@ -277,6 +288,7 @@
<div class="drafts-bar" id="draftsBar"></div>
<div class="editor-shell">
<div id="stresslayer"></div>
<div id="gutter"></div>
<div id="highlight"></div>
<textarea id="editor" spellcheck="false" placeholder="Start writing. Leave a blank line between stanzas.
Rhymes are detected phonetically — same color = same sound.
@@ -354,6 +366,8 @@ Double-click any word to look it up on the right."></textarea>
const editor = document.getElementById('editor');
const highlight = document.getElementById('highlight');
const stresslayer = document.getElementById('stresslayer');
const gutter = document.getElementById('gutter');
const stressToggle = document.getElementById('stressToggle');
stressToggle.checked = false;
stressToggle.addEventListener('change', ()=>{
@@ -375,6 +389,7 @@ function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout
let analysis = null; // last server response
let backendOk = true;
let focusGid = null; // spotlight: the family under the caret
let focusAllit = null; // alliteration group under the caret
function caretGid(){
if(!analysis || editor.selectionStart !== editor.selectionEnd) return null;
@@ -393,9 +408,22 @@ function caretGid(){
return best ? best.g : null;
}
function caretAllit(){
if(!analysis || !analysis.allit || !allitToggle.checked) return null;
if(editor.selectionStart !== editor.selectionEnd) return null;
const pos = editor.selectionStart;
const before = editor.value.slice(0, pos);
const ln = before.split('\n').length - 1;
const col = pos - (before.lastIndexOf('\n') + 1);
const line = editor.value.split('\n')[ln];
if(!analysis.lines || analysis.lines[ln] !== line) return null;
const hit = analysis.allit.find(t=>t.l===ln && t.s<=col && col<t.e);
return hit ? hit.g : null;
}
function updateSpotlight(){
const g = caretGid();
if(g !== focusGid){ focusGid = g; render(); }
const g = caretGid(), a = caretAllit();
if(g !== focusGid || a !== focusAllit){ focusGid = g; focusAllit = a; render(); }
}
let analyzeSeq = 0; // guards against out-of-order responses
@@ -620,11 +648,58 @@ function render(){
highlight.innerHTML = html;
renderStress(lines);
syncLayerHeights();
renderGutter();
highlight.scrollTop = editor.scrollTop;
highlight.scrollLeft = editor.scrollLeft;
buildReadout();
}
function spineFor(memberLines, color, cls){
const marks = [...highlight.querySelectorAll('.lmark')]
.filter(m=>memberLines.has(+m.dataset.l));
if(marks.length < 2) return;
const off = editor.scrollTop;
let top = Infinity, bot = -Infinity;
const centers = [];
marks.forEach(m=>{
const t = m.offsetTop - off, h2 = m.offsetHeight;
top = Math.min(top, t); bot = Math.max(bot, t + h2);
centers.push(t + h2 / 2);
});
const br = document.createElement('div');
br.className = 'bracket' + (cls ? ' ' + cls : '');
br.style.top = (top + 5) + 'px';
br.style.height = (bot - top - 10) + 'px';
br.style.color = `color-mix(in srgb, ${color} 32%, transparent)`;
gutter.appendChild(br);
centers.forEach(c=>{
const tk = document.createElement('div');
tk.className = 'tick' + (cls ? ' ' + cls : '');
tk.style.top = (c - 1.25) + 'px';
tk.style.background = `color-mix(in srgb, ${color} 45%, transparent)`;
gutter.appendChild(tk);
});
}
function renderGutter(){
gutter.innerHTML = '';
if(!analysis) return;
if(focusGid !== null && rhymeToggle.checked){
const g = analysis.groups.find(x=>x.id === focusGid);
if(g){
const ls = new Set();
analysis.tokens.forEach(t=>{ if(t.g === focusGid) ls.add(t.l); });
spineFor(ls, `var(--r${g.color % COLORS})`, '');
}
}
if(focusAllit !== null){
const ls = new Set();
analysis.allit.forEach(t=>{ if(t.g === focusAllit) ls.add(t.l); });
spineFor(ls, `var(--r${focusAllit % COLORS})`, 'allit');
}
}
function syncLayerHeights(){
[highlight, stresslayer].forEach(l=>{
let sp = l.querySelector('.lspacer');
@@ -668,11 +743,9 @@ function renderStress(lines){
lines.forEach((line, i)=>{
const fresh = analysis && analysis.lines[i] === line;
let raw = [];
if(fresh){
raw = byLine[i] || [];
}else if(analysis && typeof analysis.lines[i] === 'string'){
const old = analysis.lines[i];
let cp = 0;
if(fresh){ raw = byLine[i] || []; }
else if(analysis && typeof analysis.lines[i] === 'string'){
const old = analysis.lines[i]; let cp = 0;
const n = Math.min(old.length, line.length);
while(cp < n && old[cp] === line[cp]) cp++;
raw = (byLine[i] || []).filter(s=>s.e <= cp);
@@ -682,7 +755,7 @@ function renderStress(lines){
spans.forEach(s=>{
if(s.s < pos) return;
h2 += esc(line.slice(pos, s.s));
const dots = [...s.st].map(c=> c === '0' ? '' : '').join('');
const dots = [...s.st].map(c=> c === '0' ? '\u25CB' : '\u25CF').join('');
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;
@@ -732,7 +805,7 @@ function buildReadout(){
}
editor.addEventListener('input', ()=>{ focusGid = caretGid(); render(); analyzeSoon(); });
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = stresslayer.scrollTop = editor.scrollTop; highlight.scrollLeft = stresslayer.scrollLeft = editor.scrollLeft; });
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = stresslayer.scrollTop = editor.scrollTop; highlight.scrollLeft = stresslayer.scrollLeft = editor.scrollLeft; renderGutter(); });
editor.addEventListener('keyup', ()=>{ updateSpotlight(); buildReadout(); });
editor.addEventListener('click', ()=>{ updateSpotlight(); buildReadout(); });