Files
rhymepad.org/static/index.html
T
kennethreitz 87a6fa5ec5 Constellation weave: glowing threads through the spotlit family
Click a rhyme and a soft thread of light weaves under the text through
every member of its family — a spine following the line-ending rhymes
with internal rhymes forking off, drawn-in on click, nodes sized by
each word's strength, anchored to the rhyming tail. Renders beneath
the glyphs (z-index 0) with a gentle gaussian glow so it reads as
light from underneath, not a stroke on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 03:52:11 -04:00

1567 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RhymePad — a scratchpad for poets &amp; rappers</title>
<meta name="description" content="Write lyrics and poems with live phonetic rhyme detection — internal rhymes, multisyllabics, multi-word mosaics — plus rhyme & synonym lookup, beats, and per-line meter.">
<meta property="og:title" content="RhymePad">
<meta property="og:description" content="A scratchpad that color-codes your rhyme schemes as you write. Real phonetic analysis: internal rhymes, slant rhymes, multi-word mosaics. Yes, it knows orange rhymes with door hinge.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://rhymepad.org">
<meta name="theme-color" content="#14110f">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍊</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,900&family=Spline+Sans+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #14110f;
--panel: #1c1916;
--panel-2: #221e1a;
--ink: #f2e9dd;
--ink-dim: #a79a89;
--line: #322c26;
--accent: #e8814a;
--accent-2: #d4a843;
/* rhyme group colors — distinct, warm-leaning, readable on dark */
--r0:#e8814a; --r1:#4ea3e8; --r2:#6fd08c; --r3:#d46fb8;
--r4:#e8c54a; --r5:#9b7ce8; --r6:#e85a5a; --r7:#46cabf;
--r8:#c0d44e; --r9:#ee5d8f; --r10:#6f8bf2; --r11:#8fe85a;
--r12:#5ad8d8; --r13:#e0985a; --r14:#b88ce8; --r15:#56c878;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background:
radial-gradient(1200px 600px at 85% -10%, rgba(232,129,74,0.10), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(78,163,232,0.07), transparent 55%),
var(--bg);
color: var(--ink);
font-family: 'Spline Sans Mono', monospace;
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
.wrap {
display: grid;
grid-template-columns: 1fr 340px;
grid-template-rows: auto 1fr auto;
gap: 14px;
height: 100vh;
padding: 16px;
}
header {
grid-column: 1 / -1;
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid var(--line);
padding-bottom: 10px;
}
.brand {
font-family: 'Fraunces', serif;
font-weight: 900;
font-size: 30px;
letter-spacing: -0.02em;
color: var(--ink);
}
.brand span { color: var(--accent); }
.tagline { color: var(--ink-dim); font-size: 12px; letter-spacing: 0.04em; }
/* ---- editor side ---- */
.editor-col { display: flex; flex-direction: column; min-height: 0; gap: 10px; }
.drafts-bar { display: flex; flex-wrap: wrap; gap: 6px; flex: none; padding-bottom: 2px; }
.dtab {
display: flex; align-items: center; gap: 8px;
font-size: 12px; padding: 6px 12px; max-width: 170px;
background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
color: var(--ink-dim); cursor: pointer; white-space: nowrap;
transition: all .15s; user-select: none; flex: none;
}
.dtab .dtitle { overflow: hidden; text-overflow: ellipsis; }
.dtab:hover { color: var(--ink); }
.dtab.active { color: var(--ink); border-color: var(--accent); background: rgba(232,129,74,0.08); }
.dtab .x { color: var(--ink-dim); font-size: 13px; line-height: 1; }
.dtab .x:hover { color: var(--r6); }
.dtab.new { color: var(--accent); font-weight: 600; }
.editor-shell {
position: relative;
flex: 1;
min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
/* highlight layer + textarea share the exact same box metrics */
#highlight, #stresslayer, #editor {
position: absolute; inset: 0;
margin: 0; border: 0;
padding: 22px 24px;
font: inherit;
line-height: 1.9;
white-space: pre-wrap;
word-wrap: break-word;
overflow-y: auto;
letter-spacing: 0;
tab-size: 4;
}
.editor-shell.rhythm #highlight,
.editor-shell.rhythm #stresslayer,
.editor-shell.rhythm #editor { line-height: 3.2; }
#highlight {
pointer-events: none;
color: var(--ink);
z-index: 1;
}
#highlight .anno { color: #6a5f52; }
#highlight .hdr { color: #8a7d6c; font-weight: 700; }
#gutter { position: absolute; inset: 0; pointer-events: none; z-index: 3; overflow: hidden; }
#weave { position: absolute; inset: 0; pointer-events: none; z-index: 0; width: 100%; height: 100%; }
@keyframes weavedraw { to { stroke-dashoffset: 0; } }
@keyframes weavepop { 0% { opacity: 0; transform: scale(0); } 100% { opacity: .95; } }
#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;
color: transparent;
z-index: 0;
}
.sw { position: relative; }
.sd {
position: absolute; left: 0; right: 0; top: 2.2em;
text-align: center; font-size: 8px; letter-spacing: 2px;
line-height: 1; color: var(--ink); white-space: nowrap;
}
#editor {
background: transparent;
color: transparent;
caret-color: var(--accent);
resize: none;
z-index: 2;
outline: none;
}
#editor::placeholder { color: #5a5249; }
/* colored rhyme segments — background tints only, so the textarea
text on top stays crisp and box metrics stay identical */
.hseg { border-radius: 4px; }
.toolbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.btn {
font-family: inherit; font-size: 13px;
background: var(--panel-2);
color: var(--ink);
border: 1px solid var(--line);
padding: 8px 14px;
border-radius: 8px;
cursor: pointer;
transition: all .15s;
}
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.primary { background: var(--accent); color: #1a120c; border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.08); color: #1a120c; }
.scheme-readout {
margin-left: auto; color: var(--ink-dim); font-size: 12px;
letter-spacing: 0.15em;
}
.scheme-readout b { color: var(--accent-2); letter-spacing: 0.15em; }
.scheme-readout .offline { color: var(--r6); letter-spacing: 0.02em; }
.mtoggle {
display: flex; align-items: center; gap: 6px;
color: var(--ink-dim); font-size: 12px; cursor: pointer; user-select: none;
}
.mtoggle input { accent-color: var(--accent); }
.mtoggle:hover { color: var(--ink); }
/* ---- side panel ---- */
aside {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
display: flex; flex-direction: column;
min-height: 0;
overflow: hidden;
}
.tabs { display: flex; border-bottom: 1px solid var(--line); }
.tab {
flex: 1; padding: 12px 0; text-align: center; cursor: pointer;
font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--ink-dim); background: transparent; border: 0;
font-family: inherit; transition: color .15s;
}
.tab.active { color: var(--accent); box-shadow: inset 0 -2px 0 var(--accent); }
.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); }
.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);
color: var(--ink); padding: 9px 12px; border-radius: 8px; outline: none;
}
.lookup-row input:focus { border-color: var(--accent); }
.seg { display: flex; gap: 4px; margin: 14px 0 10px; }
.seg button {
flex: 1; font-family: inherit; font-size: 11px; letter-spacing: .04em;
background: var(--panel-2); color: var(--ink-dim); border: 1px solid var(--line);
padding: 7px 0; border-radius: 7px; cursor: pointer; text-transform: uppercase;
}
.seg button.active { color: var(--accent); border-color: var(--accent); }
.results { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 6px; }
.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);
color: var(--ink); padding: 5px 10px; border-radius: 999px; cursor: pointer;
transition: all .12s;
}
.chip:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); }
.chip.common { font-weight: 600; }
.chip.rare { color: var(--ink-dim); opacity: .75; }
.chip.near { border-style: dashed; color: var(--ink-dim); }
.chip.indraft::after { content: " ✓"; color: var(--accent-2); }
.chip.near:hover { color: var(--accent); }
.muted { color: var(--ink-dim); font-size: 13px; line-height: 1.6; }
.res-label { font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:var(--ink-dim); margin:16px 0 4px; }
/* beats */
.beat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.beat {
background: var(--panel-2); border: 1px solid var(--line); border-radius: 9px;
padding: 12px; cursor: pointer; transition: all .15s;
}
.beat:hover { border-color: var(--accent); }
.beat.playing { border-color: var(--accent); background: rgba(232,129,74,0.1); }
.beat .name { font-weight: 600; font-size: 13px; }
.beat .bpm { color: var(--ink-dim); font-size: 11px; margin-top: 2px; }
.transport { margin-top: 16px; }
.tempo-row { display:flex; align-items:center; gap:10px; margin-top: 8px; }
.tempo-row input[type=range] { flex:1; accent-color: var(--accent); }
.tempo-val { font-variant-numeric: tabular-nums; color: var(--accent-2); min-width: 64px; text-align:right; font-size:13px;}
.beat-controls { display:flex; gap:8px; margin-top:12px; align-items:center; }
.beat-ind { display:flex; gap:6px; margin-left:auto; }
.beat-ind span {
width:9px; height:9px; border-radius:50%;
background: var(--line); transition: background .05s;
}
.beat-ind span.on { background: var(--accent); }
footer { grid-column: 1 / -1; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #3a342d; border-radius: 6px; border: 2px solid var(--panel); }
/* ---- mobile: stack the panel under the editor ---- */
@media (max-width: 900px){
.wrap {
grid-template-columns: 1fr;
height: auto; min-height: 100dvh;
padding: 10px; gap: 10px;
}
header { flex-direction: column; align-items: flex-start; gap: 2px; padding-bottom: 8px; }
.brand { font-size: 24px; }
.editor-shell { min-height: 55dvh; }
#highlight, #stresslayer, #editor { font-size: 16px; } /* sub-16px makes iOS zoom-jump */
aside { min-height: 45dvh; }
.toolbar { gap: 8px; }
.scheme-readout { margin-left: 0; width: 100%; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="brand">Rhyme<span>Pad</span></div>
<div class="tagline">a scratchpad for poets &amp; rappers — real phonetic rhyme detection</div>
</header>
<!-- EDITOR -->
<div class="editor-col">
<div class="drafts-bar" id="draftsBar"></div>
<div class="editor-shell">
<div id="stresslayer"></div>
<div id="gutter"></div>
<svg id="weave"></svg>
<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.
Line endings glow strongest; internal rhymes sit softer.
Double-click any word to look it up on the right."></textarea>
</div>
<div class="toolbar">
<button class="btn primary" id="copyBtn">Copy to clipboard</button>
<button class="btn" id="exportBtn" title="Download this draft as a color-coded PNG">Save PNG</button>
<button class="btn" id="shareBtn" title="Share via the system sheet — Notes, Messages, anywhere" hidden>Share</button>
<label class="mtoggle" title="Color-code rhyme families"><input type="checkbox" id="rhymeToggle" checked> rhyme</label>
<label class="mtoggle" title="Underline words that share an initial sound"><input type="checkbox" id="allitToggle"> alliteration</label>
<label class="mtoggle" title="Sheet music for your flow — syllable emphasis dots under each word"><input type="checkbox" id="stressToggle"> rhythm</label>
<div class="scheme-readout" id="schemeReadout"></div>
</div>
</div>
<!-- PANEL -->
<aside>
<div class="tabs">
<button class="tab active" data-tab="lookup">Rhymes &amp; words</button>
<button class="tab" data-tab="beats">Beats</button>
</div>
<div class="panel-body" id="tab-lookup">
<div class="lookup-row">
<input id="lookupInput" placeholder="look up a word…" autocomplete="off">
</div>
<div class="seg" id="subSeg">
<button class="active" data-sub="explore">Explore</button>
<button data-sub="wordplay">Wordplay</button>
</div>
<div id="histBar"></div>
<div id="defBox"></div>
<div id="lookupResults">
<p class="muted">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.</p>
</div>
</div>
<div class="panel-body" id="tab-beats" style="display:none">
<div class="beat-grid" id="beatGrid"></div>
<div class="transport">
<div class="tempo-row">
<span class="muted">Tempo</span>
<input type="range" id="tempo" min="60" max="180" value="90">
<span class="tempo-val" id="tempoVal">90 BPM</span>
</div>
<div class="tempo-row">
<span class="muted">Volume</span>
<input type="range" id="vol" min="0" max="100" value="70">
<span class="tempo-val" id="volVal">70%</span>
</div>
<div class="beat-controls">
<button class="btn primary" id="playBeat">Play</button>
<button class="btn" id="stopBeat">Stop</button>
<button class="btn" id="tapBtn" title="Tap a few times to set the tempo">Tap</button>
<div class="beat-ind" id="beatInd"><span></span><span></span><span></span><span></span></div>
</div>
</div>
</div>
</aside>
<footer></footer>
</div>
<script>
/* ============================================================
ANALYSIS — real phonetic rhyme detection lives in the Python
backend (CMU pronouncing dictionary). We POST the draft and
get back colored token spans:
end rhymes -> strong tint
internal rhymes -> soft tint
gray tint -> an ending no rhyme answers yet
============================================================ */
const editor = document.getElementById('editor');
const highlight = document.getElementById('highlight');
const stresslayer = document.getElementById('stresslayer');
const gutter = document.getElementById('gutter');
const weave = document.getElementById('weave');
const stressToggle = document.getElementById('stressToggle');
stressToggle.checked = false;
stressToggle.addEventListener('change', ()=>{
document.querySelector('.editor-shell').classList.toggle('rhythm', stressToggle.checked);
render();
});
const allitToggle = document.getElementById('allitToggle');
allitToggle.checked = false;
allitToggle.addEventListener('change', render);
const rhymeToggle = document.getElementById('rhymeToggle');
rhymeToggle.checked = true;
rhymeToggle.addEventListener('change', render);
const schemeReadout = document.getElementById('schemeReadout');
const COLORS = 12;
function esc(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; }
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;
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;
let best = null;
analysis.tokens.forEach(t=>{
if(t.l === ln && t.s <= col && col < t.e){
if(!best || (t.e - t.s) < (best.e - best.s)) best = t;
}
});
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(), a = caretAllit();
if(g !== focusGid || a !== focusAllit){ focusGid = g; focusAllit = a; render(); }
}
let analyzeSeq = 0; // guards against out-of-order responses
const SAMPLE_TEXT =
`I keep the cadence tucked beneath my tongue tonight
the city hums in amber under fading light
I trace a melody that never quite takes flight
and let the silence answer everything I write
I put my orange
four-inch
door hinge
in storage,
and ate porridge with George`;
/* ---------- drafts — multiple docs in localStorage, tabbed ---------- */
const DOCS_KEY = 'rhymepad.docs';
const docKey = id => 'rhymepad.doc.' + id;
const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
function loadDocs(){
try{
const s = JSON.parse(localStorage.getItem(DOCS_KEY));
if(s && s.docs && s.docs.length && s.docs.some(d=>d.id===s.current)) return s;
}catch(e){}
// migrate the single-draft era; a brand-new visitor starts with the
// sample so the colors are visible before they type a word
const id = newId();
const text = localStorage.getItem('rhymepad.draft') ?? SAMPLE_TEXT;
localStorage.setItem(docKey(id), text);
const s = {docs: [{id, title: titleOf(text)}], current: id};
localStorage.setItem(DOCS_KEY, JSON.stringify(s));
return s;
}
function titleOf(text){
const l = (text || '').split('\n').find(l=>l.trim());
if(!l) return 'Untitled';
const t = l.trim();
return t.length > 26 ? t.slice(0, 25) + '…' : t;
}
function saveDocs(){
try{ localStorage.setItem(DOCS_KEY, JSON.stringify(docsState)); }catch(e){}
}
let docsState = loadDocs();
editor.value = localStorage.getItem(docKey(docsState.current)) || '';
function persist(){
try{
localStorage.setItem(docKey(docsState.current), editor.value);
const doc = docsState.docs.find(d=>d.id===docsState.current);
const title = titleOf(editor.value);
if(doc && doc.title !== title){
doc.title = title;
const el = draftsBar.querySelector('.dtab.active .dtitle');
if(el) el.textContent = title;
}
document.title = (title && title !== 'Untitled')
? title + ' · RhymePad' : 'RhymePad — a scratchpad for poets & rappers';
saveDocs();
}catch(e){ /* storage full/blocked */ }
}
const draftsBar = document.getElementById('draftsBar');
function renderTabs(){
draftsBar.innerHTML = '';
docsState.docs.forEach(doc=>{
const tab = document.createElement('div');
tab.className = 'dtab' + (doc.id === docsState.current ? ' active' : '');
const title = document.createElement('span');
title.className = 'dtitle';
title.textContent = doc.title || 'Untitled';
tab.appendChild(title);
if(doc.id === docsState.current && docsState.docs.length > 1){
const x = document.createElement('span');
x.className = 'x'; x.textContent = '×'; x.title = 'Delete this draft';
x.addEventListener('click', e=>{ e.stopPropagation(); deleteDoc(doc.id); });
tab.appendChild(x);
}
tab.addEventListener('click', ()=>{ if(doc.id !== docsState.current) openDoc(doc.id); });
draftsBar.appendChild(tab);
});
const plus = document.createElement('div');
plus.className = 'dtab new'; plus.textContent = '+'; plus.title = 'New draft';
plus.addEventListener('click', newDoc);
draftsBar.appendChild(plus);
}
function openDoc(id){
// remember where we were in the draft we're leaving
const cur = docsState.docs.find(d=>d.id===docsState.current);
if(cur){ cur.sel = editor.selectionStart; cur.scroll = editor.scrollTop; }
docsState.current = id;
saveDocs();
editor.value = localStorage.getItem(docKey(id)) || '';
analysis = null;
renderTabs();
render(); analyze(); editor.focus();
const doc = docsState.docs.find(d=>d.id===id);
if(doc && doc.sel != null){
editor.setSelectionRange(doc.sel, doc.sel);
editor.scrollTop = doc.scroll || 0;
highlight.scrollTop = editor.scrollTop;
}
}
function newDoc(){
const id = newId();
docsState.docs.push({id, title: 'Untitled'});
localStorage.setItem(docKey(id), '');
openDoc(id);
}
function deleteDoc(id){
const doc = docsState.docs.find(d=>d.id===id);
const text = localStorage.getItem(docKey(id)) || '';
if(text.trim() && !confirm(`Delete “${doc.title}”?`)) return;
try{ if(text.trim()) localStorage.setItem('rhymepad.trash', text); }catch(e){}
localStorage.removeItem(docKey(id));
docsState.docs = docsState.docs.filter(d=>d.id!==id);
if(!docsState.docs.length){
const nid = newId();
docsState.docs = [{id: nid, title: 'Untitled'}];
localStorage.setItem(docKey(nid), '');
}
openDoc(docsState.docs[0].id);
}
renderTabs();
async function analyze(){
const text = editor.value;
const seq = ++analyzeSeq;
try{
const r = await fetch('/api/analyze', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({text})
});
if(seq !== analyzeSeq) return; // a newer request superseded us
analysis = await r.json();
backendOk = true;
}catch(e){
if(seq !== analyzeSeq) return;
backendOk = false;
}
render();
}
const analyzeSoon = debounce(analyze, 180);
function render(){
persist();
const lines = editor.value.split('\n');
const tokByLine = {};
const allitByLine = {};
const openByLine = {};
const nearByLine = {};
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); });
if(analysis.near) analysis.near.forEach(t=>{ (nearByLine[t.l] ||= []).push(t); });
}
const colorOf = t => `var(--r${groupInfo[t.g].color % COLORS})`;
let html = '';
lines.forEach((line, i)=>{
// apply spans where the line still matches what the server analyzed;
// on an edited line, keep highlights over the unchanged prefix so
// typing at the end of a line doesn't blank it
const fresh = analysis && analysis.lines[i] === line;
let raw = [];
if(fresh){
raw = tokByLine[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 = (tokByLine[i] || []).filter(t=>t.e <= cp);
}
const toks = rhymeToggle.checked ? raw.filter(t=>groupInfo[t.g]) : [];
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] || []) : [];
const nears = (rhymeToggle.checked && fresh) ? (nearByLine[i] || []) : [];
let h = '';
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); 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); });
nears.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];
const text = esc(line.slice(a, b));
if(!text) continue;
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);
const op = opens.find(t=>t.s <= a && b <= t.e);
const nr = nears.find(t=>t.s <= a && b <= t.e);
if(!w && !p && !al && !op && !nr){ h += text; continue; }
// whole word fills dimly; the rhyming part gets a bright underline.
// gray = an ending still waiting for its answer
let style = '';
let tk = '';
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);
const str = t.str != null ? t.str : ((groupInfo[t.g] && groupInfo[t.g].strength) || 1);
alpha = Math.round(alpha * (0.4 + 0.6 * str)); // brightness = this word's rhyme strength
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);`;
if(w){ tk = ` data-tk="${i}:${w.s}"`; if(w.rs == null || a >= w.rs) tk += ` data-tl="${i}:${w.s}"`; }
// faint underline on the rhyming tail (rs..e) of a word — a
// whisper of precision under the full-word fill
if(w && w.rs != null && a >= w.rs){
const u = Math.round(42 * (w.str != null ? w.str : 1));
shadows.push(`inset 0 -1.5px 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) shadows.push(`inset 0 -2px 0 0 color-mix(in srgb, var(--r${al.g % COLORS}) 75%, transparent)`);
if(nr) style += 'text-decoration:underline dotted color-mix(in srgb, var(--accent-2) 60%, transparent);text-underline-offset:3px;';
if(shadows.length) style += `box-shadow:${shadows.join(',')};`;
h += `<span class="hseg"${tk} style="${style}">${text}</span>`;
}
}
const lcls = 'lmark' + (/^\s*#/.test(line) ? ' hdr' : /^\s*[([]/.test(line) ? ' anno' : '');
html += (line ? `<span class="${lcls}" data-l="${i}">${h}</span>` : '') + '\n';
});
highlight.innerHTML = html;
renderStress(lines);
syncLayerHeights();
renderGutter();
renderWeave();
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 renderWeave(){
weave.innerHTML = '';
if(focusGid === null || !analysis || !rhymeToggle.checked) return;
const g = analysis.groups.find(x=>x.id === focusGid);
if(!g) return;
const shell = highlight.getBoundingClientRect();
const pts = [];
analysis.tokens.filter(t=>t.g===focusGid && !t.ph).forEach(t=>{
const seg = highlight.querySelector(`[data-tl="${t.l}:${t.s}"]`)
|| highlight.querySelector(`[data-tk="${t.l}:${t.s}"]`);
if(!seg) return;
const r = seg.getBoundingClientRect();
pts.push({x: r.left - shell.left + r.width/2,
y: r.top - shell.top + r.height/2,
str: t.str != null ? t.str : 1, end: !!t.end});
});
if(pts.length < 2) return;
const ns='http://www.w3.org/2000/svg';
const col = getComputedStyle(document.documentElement).getPropertyValue(`--r${g.color % COLORS}`).trim();
weave.innerHTML = `<defs>`
+ `<filter id="wg" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="3"/></filter>`
+ `<filter id="wgs" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="0.6"/></filter>`
+ `</defs>`;
// the spine follows the strong rhymes — line endings — and internal
// rhymes fork off it. (no endings in the family? the whole set is the spine)
let spine = pts.filter(p=>p.end).sort((a,b)=>a.y-b.y || a.x-b.x);
let branches;
if(spine.length >= 2){ branches = pts.filter(p=>!p.end); }
else if(spine.length === 1){ branches = pts.filter(p=>!p.end); }
else { spine = pts.slice().sort((a,b)=>a.y-b.y || a.x-b.x); branches = []; }
function smooth(ps){
let d = `M ${ps[0].x.toFixed(1)} ${ps[0].y.toFixed(1)}`;
for(let i=0;i<ps.length-1;i++){
const p0=ps[i-1]||ps[i], p1=ps[i], p2=ps[i+1], p3=ps[i+2]||p2, t=0.8;
const c1x=p1.x+(p2.x-p0.x)/6*t, c1y=p1.y+(p2.y-p0.y)/6*t;
const c2x=p2.x-(p3.x-p1.x)/6*t, c2y=p2.y-(p3.y-p1.y)/6*t;
d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
return d;
}
const mkpath=(d,sw,op,filt,anim)=>{
const p=document.createElementNS(ns,'path');
p.setAttribute('d',d); p.setAttribute('fill','none'); p.setAttribute('stroke',col);
p.setAttribute('stroke-width',sw); p.setAttribute('stroke-linecap','round');
p.setAttribute('stroke-linejoin','round'); p.setAttribute('opacity',op);
if(filt === 'glow') p.setAttribute('filter','url(#wg)');
else if(filt === 'soft') p.setAttribute('filter','url(#wgs)');
if(anim){ p.setAttribute('pathLength','1'); p.style.strokeDasharray='1';
p.style.strokeDashoffset='1'; p.style.animation='weavedraw .5s ease-out forwards'; }
weave.appendChild(p);
};
// forks: each internal rhyme tendrils to its nearest spine node
branches.forEach(b=>{
let near=spine[0], best=1e9;
spine.forEach(s=>{ const dd=(s.x-b.x)**2+(s.y-b.y)**2; if(dd<best){best=dd;near=s;} });
const mx=(b.x+near.x)/2, my=(b.y+near.y)/2 - Math.min(18, Math.abs(b.x-near.x)*0.25);
const d=`M ${b.x.toFixed(1)} ${b.y.toFixed(1)} Q ${mx.toFixed(1)} ${my.toFixed(1)}, ${near.x.toFixed(1)} ${near.y.toFixed(1)}`;
mkpath(d, '1.1', '0.55', 'soft', false);
});
// the spine, glowing and drawn-in
if(spine.length >= 2){
const d=smooth(spine);
mkpath(d, '6', '0.3', 'glow', false);
mkpath(d, '2.2', '0.6', 'soft', true);
}
pts.forEach((p,i)=>{
const c=document.createElementNS(ns,'circle');
c.setAttribute('cx',p.x.toFixed(1)); c.setAttribute('cy',p.y.toFixed(1));
c.setAttribute('r',((p.end?2.0:1.3) + 2.0*p.str).toFixed(1)); c.setAttribute('fill',col);
c.setAttribute('opacity', p.end ? '0.7' : '0.5');
c.style.transformOrigin=`${p.x.toFixed(1)}px ${p.y.toFixed(1)}px`;
c.style.animation=`weavepop .35s ${(0.04*i).toFixed(2)}s ease-out both`;
weave.appendChild(c);
});
}
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');
if(!sp){
sp = document.createElement('div');
sp.className = 'lspacer';
l.appendChild(sp);
}
sp.style.height = '0px';
const diff = editor.scrollHeight - l.scrollHeight;
if(diff > 0) sp.style.height = diff + 'px';
});
}
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) return;
// flexible monosyllables (x) read as stressed in delivery, so
// "x01" and "101" are the same flow
const key = m.stress.replace(/x/g, '1');
(byPat[key] ||= []).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;
let raw = [];
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);
}
const spans = raw.slice().sort((a,b)=>a.s-b.s);
let pos = 0, h2 = '';
spans.forEach(s=>{
if(s.s < pos) return;
h2 += esc(line.slice(pos, s.s));
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;
});
h2 += esc(line.slice(pos));
html += h2 + '\n';
});
stresslayer.innerHTML = html;
stresslayer.scrollTop = editor.scrollTop;
stresslayer.scrollLeft = editor.scrollLeft;
}
function caretLine(){
return editor.value.slice(0, editor.selectionStart).split('\n').length - 1;
}
function caretStanza(){
if(!analysis || !analysis.stanzas.length) return null;
const ln = caretLine();
return analysis.stanzas.find(s=>s.lines.includes(ln))
|| analysis.stanzas.findLast(s=>s.lines[0] <= ln)
|| analysis.stanzas[0];
}
function buildReadout(){
if(!backendOk){
schemeReadout.innerHTML = '<span class="offline">backend offline — run: uv run uvicorn app:app</span>';
return;
}
const parts = [];
const ln = caretLine();
const m = analysis && analysis.meter ? analysis.meter.find(x=>x.l===ln) : null;
if(m){
let p = `${m.syl} syl` + (m.label ? ` · ${m.label}` : '');
parts.push(p);
}
const st = caretStanza();
if(st){
const bi = st.lines.indexOf(ln);
if(bi >= 0) parts.unshift(`bar ${bi + 1}/${st.lines.length}`);
}
if(focusGid !== null && analysis){
const g = analysis.groups.find(x=>x.id === focusGid);
const fam = [...new Set(analysis.tokens.filter(t=>t.g===focusGid)
.map(t=>analysis.lines[t.l].slice(t.s,t.e).toLowerCase()))];
if(g && fam.length >= 2)
parts.push(`rhymes <b>${esc(g.sound)}</b> — ${esc(fam.slice(0,6).join(', '))}`);
}
schemeReadout.innerHTML = parts.join(' &nbsp;&nbsp; ');
}
editor.addEventListener('input', ()=>{ focusGid = caretGid(); render(); analyzeSoon(); });
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = stresslayer.scrollTop = editor.scrollTop; highlight.scrollLeft = stresslayer.scrollLeft = editor.scrollLeft; renderGutter(); renderWeave(); });
editor.addEventListener('keyup', ()=>{ updateSpotlight(); buildReadout(); });
editor.addEventListener('click', ()=>{ updateSpotlight(); buildReadout(); });
/* ---------- double-click (or touch-select) a word -> look it up ---------- */
function lookupSelection(){
const sel = editor.value.slice(editor.selectionStart, editor.selectionEnd)
.trim().replace(/[^A-Za-z']/g,'');
if(sel && !sel.includes(' ')){
tab('lookup');
lookupFor(sel);
}
}
editor.addEventListener('dblclick', lookupSelection);
if(window.matchMedia('(pointer: coarse)').matches){
// no double-click on touch — long-press word selection does the job
editor.addEventListener('select', debounce(()=>{
const len = editor.selectionEnd - editor.selectionStart;
if(len > 0 && len <= 30) lookupSelection();
}, 400));
}
/* ---------- insert at cursor ---------- */
/* ============================================================
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 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;
let subMode = 'explore';
const subSeg = document.getElementById('subSeg');
subSeg.addEventListener('click', e=>{
const b = e.target.closest('button'); if(!b) return;
subMode = b.dataset.sub;
[...subSeg.children].forEach(c=>c.classList.toggle('active', c.dataset.sub === subMode));
paintSections();
});
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();
}
function renderHistory(){
const past = wordHistory.filter(w=>w !== curWord).slice(-5).reverse();
histBar.innerHTML = past.length
? '↩ ' + past.map(w=>`<span class="hist" data-w="${esc(w)}">${esc(w)}</span>`).join(' · ')
: '';
histBar.querySelectorAll('.hist').forEach(el=>
el.addEventListener('click', ()=> lookupFor(el.dataset.w)));
}
async function doLookup(){
const word = lookupInput.value.trim().replace(/\?$/, '').toLowerCase();
if(!word || word === curWord) return;
document.getElementById('tab-lookup').scrollTop = 0;
if(curWord){
const i = wordHistory.indexOf(curWord);
if(i >= 0) wordHistory.splice(i, 1);
wordHistory.push(curWord);
if(wordHistory.length > 8) wordHistory.shift();
}
curWord = word;
renderHistory();
const seq = ++lookupSeq;
defBox.innerHTML = `<div class="defhead"><b>${esc(word)}</b></div>` +
`<div class="muted defphon" id="defPhon">…</div>`;
resultsBox.innerHTML = '<p class="muted">gathering…</p>';
const [info, rhyme, syn, desc, trig] = 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),
fetch(`https://api.datamuse.com/words?rel_trg=${encodeURIComponent(word)}&max=30`).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 <i>${esc(info.rime.toLowerCase())}</i>` +
(info.senses >= 3 ? ` · ${info.senses} senses` : '') +
((info.homophones || []).length ? `<br>sounds like: ${info.homophones.map(esc).join(', ')}` : '') +
(mates.length ? `<br>in your draft: ${mates.map(esc).join(', ')}` : '');
}else{
el.textContent = 'not in the pronunciation dictionary';
}
}
entry = {word, rhyme, syn, desc, trig};
paintSections();
}
function draftMates(word){
if(!analysis) return [];
const lw = word.toLowerCase();
const byG = {};
analysis.tokens.forEach(t=>{
if(t.ph) return;
const w = analysis.lines[t.l].slice(t.s, t.e).toLowerCase();
(byG[t.g] ||= new Set()).add(w);
});
const mates = new Set();
Object.values(byG).forEach(set=>{
if(set.has(lw)) set.forEach(w=>{ if(w !== lw) mates.add(w); });
});
return [...mates].slice(0, 8);
}
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 '<div class="results">' +
items.map(d=>{
const w = d.word || d, r = rarity(d.z);
const used = inDraft.has(w.toLowerCase()) ? ' indraft' : '';
return `<span class="chip${cls ? ' ' + cls : ''}${r}${used}" data-w="${esc(w)}">${esc(w)}</span>`;
}).join('') + '</div>';
}
function paintSections(){
const e = entry;
if(!e) return;
let h = '';
if(subMode === 'explore'){
// the meaning side: what it summons, what describes it, what replaces it
if(e.trig && e.trig.length){
h += `<div class="res-label">associations</div>` + chipHtml(e.trig.map(d=>d.word));
}
if(e.desc && e.desc.length){
h += `<div class="res-label">describes</div>` + chipHtml(e.desc.map(d=>d.word));
}
if(e.syn && e.syn.known && e.syn.sections.length){
h += `<div class="res-label">synonyms</div>`;
e.syn.sections.forEach(s=>{
if(s.label !== 'synonyms') h += `<div class="res-label sub">${esc(s.label)}</div>`;
h += chipHtml(s.words.map(d=>d.word), s.label === 'synonyms' ? '' : 'near');
});
}
}else{
// the sound side: rhymes, near, multis
if(e.rhyme && e.rhyme.known && (e.rhyme.words.length || (e.rhyme.near || []).length)){
const bySyl = {};
e.rhyme.words.forEach(d=>{ (bySyl[d.syl || 0] ||= []).push(d); });
const onWord = e.rhyme.rhyme_on ? ` — on “${esc(e.rhyme.rhyme_on)}` : '';
h += `<div class="res-label">rhymes${onWord}</div>`;
Object.keys(bySyl).sort((a,b)=>a-b).forEach(k=>{
h += `<div class="res-label sub">${k == 0 ? '?' : k} syl</div>` + chipHtml(bySyl[k]);
});
const near = e.rhyme.near || [];
if(near.length) h += `<div class="res-label sub">near</div>` + chipHtml(near, 'near');
const multis = e.rhyme.multis || [];
if(multis.length) h += `<div class="res-label sub">multis</div>` + chipHtml(multis);
}
}
resultsBox.innerHTML = h || `<p class="muted">nothing here for “${esc(e.word)}”.</p>`;
resultsBox.querySelectorAll('.chip').forEach(c=>
c.addEventListener('click', ()=> lookupFor(c.dataset.w)));
}
/* ============================================================
EXPORT IMAGE — draw the draft with its rhyme colors to a PNG,
entirely client-side.
============================================================ */
document.getElementById('exportBtn').addEventListener('click', async ()=>{
if(!editor.value.trim()) return;
await document.fonts.ready;
const lines = editor.value.split('\n');
const css = getComputedStyle(document.documentElement);
const palette = Array.from({length: COLORS}, (_, i)=>css.getPropertyValue(`--r${i}`).trim());
const ink = css.getPropertyValue('--ink').trim();
const bg = css.getPropertyValue('--bg').trim();
const rhythm = stressToggle.checked && analysis && analysis.stress;
const S = 2, FS = 16, LH = FS * (rhythm ? 2.35 : 1.9), PAD = 40;
const font = FS + "px 'Spline Sans Mono', monospace";
const probe = document.createElement('canvas').getContext('2d');
probe.font = font;
const w = Math.ceil(Math.max(220, ...lines.map(l=>probe.measureText(l).width)) + PAD * 2);
const h = Math.ceil(lines.length * LH + PAD * 2 + 18);
const canvas = document.createElement('canvas');
canvas.width = w * S; canvas.height = h * S;
const x = canvas.getContext('2d');
x.scale(S, S);
x.fillStyle = bg; x.fillRect(0, 0, w, h);
x.font = font; x.textBaseline = 'middle';
const groupInfo = {}, tokByLine = {};
if(analysis){
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
}
lines.forEach((line, i)=>{
const y = PAD + i * LH + LH / 2;
const fresh = analysis && analysis.lines[i] === line;
const toks = (rhymeToggle.checked && fresh ? (tokByLine[i] || []) : []).filter(t=>groupInfo[t.g]);
const words = toks.filter(t=>!t.ph), phrases = toks.filter(t=>t.ph);
const cuts = new Set([0, line.length]);
toks.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];
const wt = words.find(t=>t.s <= a && b <= t.e);
const pt = phrases.find(t=>t.s <= a && b <= t.e);
if(!wt && !pt) continue;
const t = wt || pt;
x.globalAlpha = wt ? (wt.end ? 0.34 : 0.19) : (pt.end ? 0.24 : 0.14);
x.fillStyle = palette[groupInfo[t.g].color % COLORS];
const x0 = PAD + x.measureText(line.slice(0, a)).width;
const wpx = x.measureText(line.slice(a, b)).width;
x.beginPath();
x.roundRect(x0 - 2, y - FS * 0.72, wpx + 4, FS * 1.42, 4);
x.fill();
x.globalAlpha = 1;
}
if(allitToggle.checked && fresh){
(analysis.allit || []).filter(t=>t.l===i).forEach(t=>{
const left = PAD + x.measureText(line.slice(0, t.s)).width;
const wpx = x.measureText(line.slice(t.s, t.e)).width;
x.globalAlpha = 0.75;
x.fillStyle = palette[t.g % COLORS];
x.fillRect(left - 1, y + FS * 0.55, wpx + 2, 2);
x.globalAlpha = 1;
});
}
const isHdr = /^\s*#/.test(line);
x.font = isHdr ? '700 ' + font : font;
x.fillStyle = isHdr ? '#8a7d6c' : /^\s*[([]/.test(line) ? '#6a5f52' : ink;
x.fillText(line, PAD, y);
x.font = font;
if(rhythm && fresh){
const spans = (analysis.stress.filter(s=>s.l===i)).sort((a,b)=>a.s-b.s);
x.font = "8px 'Spline Sans Mono', monospace";
x.textAlign = 'center';
spans.forEach(s=>{
// measure with the body font for positions
x.font = font;
const left = PAD + x.measureText(line.slice(0, s.s)).width;
const wpx = x.measureText(line.slice(s.s, s.e)).width;
x.font = "9px 'Spline Sans Mono', monospace";
const dots = [...s.st].map(c=> c === '0' ? '○' : '●').join(' ');
x.fillText(dots, left + wpx / 2, y + FS * 0.95);
});
x.textAlign = 'left';
x.font = font;
}
});
x.fillStyle = 'rgba(167,154,137,0.55)';
x.font = "11px 'Spline Sans Mono', monospace";
x.textAlign = 'right';
x.fillText('rhymepad.org', w - 16, h - 16);
canvas.toBlob(blob=>{
const doc = docsState.docs.find(d=>d.id===docsState.current);
const name = ((doc && doc.title && doc.title !== 'Untitled') ? doc.title : 'rhymepad')
.replace(/[^\w\- ]+/g, '').trim() || 'rhymepad';
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name + '.png';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href), 5000);
flash('exportBtn', 'Exported \u2713');
});
});
/* ============================================================
SMART PASTE — typographic quotes break phonetic tokenization
(dont -> don + t), and lyrics sites ship cruft lines. Pasted
text gets normalized and de-crufted on the way in.
============================================================ */
const SECTION_RE = /^\[[^\]]{1,48}\]$/;
function extractLyricBlock(t){
// a whole-page Genius dump: lyrics live between the first [Section]
// header and the Embed/About/Comments footer
const lines = t.split('\n');
const first = lines.findIndex(l=>SECTION_RE.test(l.trim()));
if(first < 0) return null;
let end = -1;
for(let i = first; i < lines.length; i++){
const s = lines[i].trim();
if(/^\d*\s*Embed$/.test(s) || /^About$/.test(s) || /^Comments$/.test(s)){ end = i; break; }
}
const head = lines.slice(0, first).join('\n');
const pageish = /Contributors|Translations|Lyrics/i.test(head);
if(end < 0 && !pageish) return null; // just lyrics with [tags] — leave alone
let block = lines.slice(first, end < 0 ? lines.length : end);
// recommendation inserts run from "You might also like" to the next [Section]
const out = [];
let skipping = false;
for(const l of block){
const s = l.trim();
if(/^You might also like/i.test(s)){ skipping = true; continue; }
if(skipping){
if(SECTION_RE.test(s)) skipping = false;
else continue;
}
out.push(l);
}
while(out.length && /^\d*$/.test(out[out.length - 1].trim())) out.pop();
return out.join('\n').trim() + '\n';
}
function extractAZBlock(t){
// AZLyrics-style page: quoted-title headers up top, a "Thanks to…"
// credits footer below — the lyrics are what's between
const lines = t.split('\n');
const headRe = [/^AZLyrics/i, /^"[^"]+" lyrics$/i, /^.{1,60} Lyrics$/, /^"[^"]+"$/];
let start = -1;
for(let i = 0; i < Math.min(lines.length, 10); i++){
const s = lines[i].trim();
if(s && headRe.some(r=>r.test(s))) start = i;
}
if(start < 0) return null;
const endRe = [/^Thanks to .+ for (adding|correcting)/i, /^Writer\(s\):/i,
/^Lyrics licensed by/i, /^You May Also Like$/i, /^album:/i,
/^Submit Lyrics$/i];
let end = -1;
for(let i = start + 1; i < lines.length; i++){
if(endRe.some(r=>r.test(lines[i].trim()))){ end = i; break; }
}
if(end < 0) return null; // no footer evidence — not a page dump
const block = lines.slice(start + 1, end).join('\n').trim();
return block ? block + '\n' : null;
}
function extractGoogleBlock(t){
// Google's knowledge panel: a standalone "Lyrics" header, the verse,
// then a Source:/Songwriters: footer — search-result noise follows
const lines = t.split('\n');
let start = -1;
for(let i = 0; i < lines.length; i++){
if(lines[i].trim() === 'Lyrics'){ start = i; break; }
}
if(start < 0) return null;
while(start + 1 < lines.length &&
(lines[start + 1].trim() === 'Lyrics' || !lines[start + 1].trim())) start++;
let end = -1;
for(let i = start + 1; i < lines.length; i++){
if(/^(Source:\s|Songwriters?:)/i.test(lines[i].trim())){ end = i; break; }
}
if(end < 0) return null;
const block = lines.slice(start + 1, end).join('\n').trim();
return block ? block + '\n' : null;
}
function cleanPaste(t){
t = t.replace(/\r\n?/g, '\n')
.replace(/[\u2028\u2029\u0085\u000B\u000C]/g, '\n') // Google's lyrics box
.replace(/[\u2018\u2019\u02BC]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u00A0/g, ' ');
const extracted = extractLyricBlock(t) || extractGoogleBlock(t) || extractAZBlock(t);
if(extracted) return extracted;
const out = [];
t.split('\n').forEach((l, i)=>{
const s = l.trim();
if(i < 4 && /^\d+\s*Contributors/i.test(s)) return;
if(i < 4 && /Lyrics$/.test(s) && s.length < 60) return;
if(/^Translations$/i.test(s)) return;
if(/^You might also like/i.test(s)) return;
if(/^Source:\s/i.test(s)) return;
if(/^Songwriters?:/i.test(s)) return;
if(/^Musixmatch$/i.test(s)) return;
if(/^\d*\s*Embed$/.test(s)) return;
out.push(l);
});
return out.join('\n');
}
editor.addEventListener('paste', e=>{
const text = e.clipboardData && e.clipboardData.getData('text/plain');
if(!text) return;
const cleaned = cleanPaste(text);
if(cleaned === text) return;
e.preventDefault();
// execCommand keeps the native undo stack intact (setRangeText doesn't)
editor.focus();
if(!document.execCommand('insertText', false, cleaned)){
editor.setRangeText(cleaned, editor.selectionStart, editor.selectionEnd, 'end');
}
render(); analyzeSoon();
});
lookupInput.addEventListener('paste', e=>{
const text = e.clipboardData && e.clipboardData.getData('text/plain');
if(text && /[\u2018\u2019]/.test(text)){
e.preventDefault();
lookupInput.focus();
const fixed = text.replace(/[\u2018\u2019]/g, "'");
if(!document.execCommand('insertText', false, fixed)){
lookupInput.setRangeText(fixed, lookupInput.selectionStart, lookupInput.selectionEnd, 'end');
}
}
});
/* ============================================================
FILES IN, FILES OUT — drafts shouldn't be hostage to localStorage
============================================================ */
const shareBtn = document.getElementById('shareBtn');
if(navigator.share){
shareBtn.hidden = false;
shareBtn.addEventListener('click', async ()=>{
if(!editor.value.trim()) return;
const doc = docsState.docs.find(d=>d.id===docsState.current);
try{
await navigator.share({
title: (doc && doc.title !== 'Untitled') ? doc.title : 'RhymePad draft',
text: editor.value,
});
}catch(e){ /* user closed the sheet */ }
});
}
// drop a .txt (or any text file) anywhere -> it becomes a new draft
document.addEventListener('dragover', e=>{ e.preventDefault(); });
document.addEventListener('drop', async e=>{
e.preventDefault();
const files = [...(e.dataTransfer?.files || [])].slice(0, 8);
for(const f of files){
if(f.size > 1024 * 1024) continue;
const text = cleanPaste(await f.text());
if(!text.trim()) continue;
const id = newId();
docsState.docs.push({id, title: titleOf(text)});
localStorage.setItem(docKey(id), text);
openDoc(id);
}
});
/* ============================================================
COPY / CLEAR / SAMPLE
============================================================ */
document.getElementById('copyBtn').addEventListener('click', async ()=>{
try{ await navigator.clipboard.writeText(editor.value); flash('copyBtn','Copied!'); }
catch{ editor.select(); document.execCommand('copy'); flash('copyBtn','Copied!'); }
});
function flash(id,msg){
const b=document.getElementById(id); const o=b.textContent; b.textContent=msg;
setTimeout(()=>b.textContent=o,1100);
}
/* ============================================================
BEATS — Web Audio synthesized drum patterns, adjustable tempo.
No external samples; everything is generated.
============================================================ */
const tab = (name)=>{
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active', t.dataset.tab===name));
document.getElementById('tab-lookup').style.display = name==='lookup'?'block':'none';
document.getElementById('tab-beats').style.display = name==='beats'?'block':'none';
};
document.querySelectorAll('.tab').forEach(t=> t.addEventListener('click', ()=>tab(t.dataset.tab)));
// bass: [step, semitone offset, length in 16ths]
const PATTERNS = {
'Boom Bap': {bpm:90, kick:[0,6,8], snare:[4,12], hat:[0,2,4,6,8,10,12,14], swing:0.12,
bass:[[0,0,2],[6,0,1],[8,0,2],[14,3,1]]},
'Trap': {bpm:140, kick:[0,3,7,8,11], snare:[8], hat:[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], swing:0,
clap:true, bass:[[0,0,3],[7,-2,1],[8,0,3],[11,3,2]]},
'Lo-Fi': {bpm:75, kick:[0,8], snare:[4,12], hat:[2,6,10,14], swing:0.18,
ohat:[10], bass:[[0,0,3],[8,5,3],[12,3,2]]},
'Drill': {bpm:142, kick:[0,3,6,10,11], snare:[4,12,13], hat:[0,2,4,6,8,10,12,14], swing:0.05,
bass:[[0,0,2],[3,1,2],[6,-2,2],[10,0,1],[11,3,2]]},
'Spoken/Jazz':{bpm:96, kick:[0,10], snare:[4,12], hat:[0,3,6,9,12,15], swing:0.2,
ohat:[14], bass:[[0,0,2],[4,7,2],[8,5,2],[12,3,2]]},
'West Coast': {bpm:94, kick:[0,6,8,14], snare:[4,12], hat:[0,2,4,6,8,10,12,14], swing:0.08,
bass:[[0,0,2],[6,0,1],[8,-4,2],[14,0,1]]},
'Halftime': {bpm:74, kick:[0,10], snare:[8], hat:[0,2,4,6,8,10,12,14], swing:0.1,
clap:true, ohat:[6], bass:[[0,0,6],[10,-2,4]]},
'Dembow': {bpm:96, kick:[0,4,8,12], snare:[3,6,11,14], hat:[0,2,4,6,8,10,12,14], swing:0,
bass:[[0,0,3],[6,0,1],[8,0,3],[14,0,1]]}
};
let actx=null, masterGain=null, playing=false, schedTimer=null, current='Boom Bap', step=0, nextTime=0;
let userTempo = 90;
const beatGrid = document.getElementById('beatGrid');
Object.entries(PATTERNS).forEach(([name,p])=>{
const d=document.createElement('div'); d.className='beat'; d.dataset.name=name;
d.innerHTML = `<div class="name">${name}</div><div class="bpm">${p.bpm} BPM feel</div>`;
d.addEventListener('click', ()=>{
current=name;
document.getElementById('tempo').value = p.bpm;
setTempo(p.bpm);
if(!playing) startBeat();
document.querySelectorAll('.beat').forEach(b=>b.classList.toggle('playing', b.dataset.name===name));
});
beatGrid.appendChild(d);
});
const tempoEl = document.getElementById('tempo'), tempoVal=document.getElementById('tempoVal');
function setTempo(v){ userTempo=+v; tempoEl.value=v; tempoVal.textContent = v+' BPM'; }
tempoEl.addEventListener('input', e=> setTempo(e.target.value));
function ensureCtx(){
if(!actx){
actx = new (window.AudioContext||window.webkitAudioContext)();
masterGain = actx.createGain();
masterGain.connect(actx.destination);
setVol(volEl.value);
}
}
const volEl = document.getElementById('vol'), volVal = document.getElementById('volVal');
function setVol(v){
volVal.textContent = v + '%';
if(masterGain) masterGain.gain.value = Math.pow(v / 100, 2); // perceptual
}
volEl.addEventListener('input', e=> setVol(e.target.value));
function kick(t){
const o=actx.createOscillator(), g=actx.createGain();
o.frequency.setValueAtTime(150,t); o.frequency.exponentialRampToValueAtTime(50,t+0.12);
g.gain.setValueAtTime(0.9,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.18);
o.connect(g).connect(masterGain); o.start(t); o.stop(t+0.2);
}
function snare(t){
const noise=actx.createBufferSource();
const buf=actx.createBuffer(1, actx.sampleRate*0.2, actx.sampleRate);
const d=buf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
noise.buffer=buf;
const f=actx.createBiquadFilter(); f.type='highpass'; f.frequency.value=1500;
const g=actx.createGain(); g.gain.setValueAtTime(0.6,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.18);
noise.connect(f).connect(g).connect(masterGain); noise.start(t); noise.stop(t+0.2);
}
function hat(t){
const noise=actx.createBufferSource();
const buf=actx.createBuffer(1, actx.sampleRate*0.05, actx.sampleRate);
const d=buf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
noise.buffer=buf;
const f=actx.createBiquadFilter(); f.type='highpass'; f.frequency.value=7000;
const g=actx.createGain();
g.gain.setValueAtTime(0.2 + Math.random()*0.12, t); // humanized
g.gain.exponentialRampToValueAtTime(0.001,t+0.05);
noise.connect(f).connect(g).connect(masterGain); noise.start(t); noise.stop(t+0.06);
}
function ohat(t){
const noise=actx.createBufferSource();
const buf=actx.createBuffer(1, actx.sampleRate*0.3, actx.sampleRate);
const d=buf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
noise.buffer=buf;
const f=actx.createBiquadFilter(); f.type='highpass'; f.frequency.value=6000;
const g=actx.createGain(); g.gain.setValueAtTime(0.16,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.28);
noise.connect(f).connect(g).connect(masterGain); noise.start(t); noise.stop(t+0.3);
}
function clap(t){
for(let k=0;k<3;k++){
const n=actx.createBufferSource();
const buf=actx.createBuffer(1, actx.sampleRate*0.08, actx.sampleRate);
const d=buf.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=Math.random()*2-1;
n.buffer=buf;
const f=actx.createBiquadFilter(); f.type='bandpass'; f.frequency.value=1500; f.Q.value=1.2;
const g=actx.createGain(); const tt=t+k*0.012;
g.gain.setValueAtTime(0.3,tt); g.gain.exponentialRampToValueAtTime(0.001,tt+0.09);
n.connect(f).connect(g).connect(masterGain); n.start(tt); n.stop(tt+0.12);
}
}
function bass(t, semi, lenSteps, secPer16){
const o=actx.createOscillator(), g=actx.createGain();
o.type='sine';
const f0 = 55 * Math.pow(2, semi/12);
o.frequency.setValueAtTime(f0*2, t);
o.frequency.exponentialRampToValueAtTime(f0, t+0.035);
const len = Math.max(0.12, lenSteps * secPer16 * 0.95);
g.gain.setValueAtTime(0.5, t);
g.gain.exponentialRampToValueAtTime(0.001, t+len);
o.connect(g).connect(masterGain); o.start(t); o.stop(t+len+0.05);
}
function scheduler(){
const p=PATTERNS[current];
const secPer16 = (60/userTempo)/4;
while(nextTime < actx.currentTime + 0.1){
const s = step % 16;
const swing = (s%2===1) ? secPer16 * (p.swing||0) : 0;
const t = nextTime + swing;
if(p.kick.includes(s)) kick(t);
if(p.snare.includes(s)){ snare(t); if(p.clap) clap(t); }
if(p.hat.includes(s)) hat(t);
if(p.ohat && p.ohat.includes(s)) ohat(t);
if(p.bass) p.bass.forEach(([bs, semi, len])=>{ if(bs === s) bass(t, semi, len, secPer16); });
if(s % 4 === 0){
const beat = s / 4;
setTimeout(()=>flashBeat(beat), Math.max(0, (t - actx.currentTime) * 1000));
}
nextTime += secPer16;
step++;
}
schedTimer = setTimeout(scheduler, 25);
}
const beatInd = document.getElementById('beatInd');
function flashBeat(n){
if(!playing) return;
[...beatInd.children].forEach((d, i)=> d.classList.toggle('on', i === n));
}
const taps = [];
document.getElementById('tapBtn').addEventListener('click', ()=>{
const now = performance.now();
if(taps.length && now - taps[taps.length - 1] > 2000) taps.length = 0;
taps.push(now);
if(taps.length >= 2){
const iv = (taps[taps.length - 1] - taps[0]) / (taps.length - 1);
setTempo(Math.max(60, Math.min(180, Math.round(60000 / iv))));
}
});
function startBeat(){
ensureCtx(); if(actx.state==='suspended') actx.resume();
if(playing) return;
playing=true; step=0; nextTime=actx.currentTime+0.05; scheduler();
document.querySelectorAll('.beat').forEach(b=>b.classList.toggle('playing', b.dataset.name===current));
document.getElementById('playBeat').textContent='Playing…';
}
function stopBeat(){
playing=false; clearTimeout(schedTimer);
[...beatInd.children].forEach(d=>d.classList.remove('on'));
document.querySelectorAll('.beat').forEach(b=>b.classList.remove('playing'));
document.getElementById('playBeat').textContent='Play';
}
document.getElementById('playBeat').addEventListener('click', startBeat);
document.getElementById('stopBeat').addEventListener('click', stopBeat);
document.addEventListener('keydown', e=>{
if(!(e.metaKey || e.ctrlKey)) return;
if(e.key === 's'){
e.preventDefault(); // it's already saved — say so
const prev = schemeReadout.innerHTML;
schemeReadout.innerHTML = 'saved ✓';
setTimeout(buildReadout, 900);
}else if(e.key === 'k'){
e.preventDefault();
tab('lookup');
lookupInput.focus();
}
});
if(window.matchMedia('(pointer: fine)').matches) editor.focus();
render();
analyze();
</script>
</body>
</html>