Files
rhymepad.org/static/index.html
T
kennethreitz 7eaf815e5b Meter coaching: flag lines that break the stanza's pattern
When >=60% of a stanza's lines share a syllable count (+-1), outliers
get a wavy underline and the readout names the target count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:33:27 -04:00

853 lines
33 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</title>
<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:#d4a843; --r5:#9b7ce8; --r6:#e85a5a; --r7:#46cabf;
--r8:#c0d44e; --r9:#e8a0c0; --r10:#7ad4d4; --r11:#d9824e;
}
* { 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; gap: 6px; overflow-x: auto; flex: none; padding-bottom: 2px; }
.dtab {
display: flex; align-items: center; gap: 8px;
font-size: 12px; padding: 6px 12px;
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;
}
.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, #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;
}
#highlight {
pointer-events: none;
color: transparent;
z-index: 1;
}
#editor {
background: transparent;
color: var(--ink);
caret-color: var(--accent);
resize: none;
z-index: 2;
outline: none;
}
#editor::placeholder { color: #5a5249; }
/* colored rhyme tokens — background tint only, so the textarea text on
top stays crisp and box metrics stay identical (no horizontal padding!) */
.rh {
border-radius: 4px;
background: color-mix(in srgb, var(--c) 15%, transparent);
}
.rh.end {
background: color-mix(in srgb, var(--c) 24%, transparent);
box-shadow: inset 0 -2px 0 0 var(--c);
}
.rh.slant.end {
box-shadow: inset 0 -2px 0 0 color-mix(in srgb, var(--c) 45%, transparent);
}
.offbeat {
text-decoration: underline wavy color-mix(in srgb, var(--r6) 45%, transparent);
text-decoration-skip-ink: none;
text-underline-offset: 7px;
}
.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; }
/* ---- stanza reorder overlay ---- */
#reorderOverlay {
position: fixed; inset: 0; z-index: 50;
background: rgba(10,8,6,0.7); backdrop-filter: blur(3px);
display: flex; align-items: center; justify-content: center;
}
#reorderOverlay[hidden] { display: none; }
.reorder-box {
width: min(560px, 92vw); max-height: 80vh;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
display: flex; flex-direction: column; overflow: hidden;
}
.reorder-box h3 {
margin: 0; padding: 16px 20px 12px;
font-family: 'Fraunces', serif; font-weight: 600; font-size: 18px;
}
.reorder-box .hint { padding: 0 20px 10px; color: var(--ink-dim); font-size: 12px; }
#stanzaList { padding: 0 16px 8px; overflow-y: auto; flex: 1; }
.stanza-card {
display: flex; align-items: center; gap: 12px;
background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px;
padding: 12px 14px; margin-bottom: 8px; cursor: grab;
transition: border-color .15s, opacity .15s;
}
.stanza-card:active { cursor: grabbing; }
.stanza-card.dragging { opacity: 0.35; }
.stanza-card.drop-above { border-top: 2px solid var(--accent); }
.stanza-card.drop-below { border-bottom: 2px solid var(--accent); }
.stanza-card .grip { color: var(--ink-dim); font-size: 16px; user-select: none; }
.stanza-card .preview { flex: 1; min-width: 0; }
.stanza-card .first-line {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px;
}
.stanza-card .meta-line { color: var(--ink-dim); font-size: 11px; margin-top: 3px; }
.stanza-card .scheme-badge {
color: var(--accent-2); font-size: 11px; letter-spacing: 0.18em;
text-transform: uppercase; white-space: nowrap;
}
.reorder-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 16px; }
/* ---- 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; }
.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; }
.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); }
.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; }
.legend { margin-top: 14px; }
.legend-item { display:flex; align-items:center; gap:8px; font-size:12px; margin:5px 0; color: var(--ink-dim); }
.swatch { width:14px; height:14px; border-radius:4px; }
footer { grid-column: 1 / -1; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #3a342d; border-radius: 6px; border: 2px solid var(--panel); }
</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="highlight"></div>
<textarea id="editor" spellcheck="false" placeholder="Start writing. Leave a blank line between stanzas.
Rhymes are detected phonetically — line endings get the underline,
internal rhymes get the soft glow, same color = same sound.
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="reorderBtn">Reorder stanzas</button>
<button class="btn" id="clearBtn">Clear</button>
<button class="btn" id="sampleBtn">Load sample</button>
<div class="scheme-readout" id="schemeReadout"></div>
</div>
</div>
<!-- PANEL -->
<aside>
<div class="tabs">
<button class="tab active" data-tab="lookup">Rhymes &amp; Synonyms</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="a word…" autocomplete="off">
<button class="btn" id="lookupBtn">Go</button>
</div>
<div class="seg" id="modeSeg">
<button class="active" data-mode="rhyme">Rhymes</button>
<button data-mode="near">Near rhymes</button>
<button data-mode="syn">Synonyms</button>
</div>
<div id="lookupResults">
<p class="muted">Type a word and hit Go, or double-click a word in your draft. Click any result to insert it at the cursor.</p>
</div>
<div class="legend">
<div class="res-label">Current stanza</div>
<div id="legendBox"><p class="muted">No rhymes detected yet.</p></div>
</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="beat-controls">
<button class="btn primary" id="playBeat">Play</button>
<button class="btn" id="stopBeat">Stop</button>
</div>
</div>
</div>
</aside>
<footer></footer>
</div>
<!-- STANZA REORDER -->
<div id="reorderOverlay" hidden>
<div class="reorder-box">
<h3>Reorder stanzas</h3>
<div class="hint">Drag cards to rearrange. Changes apply instantly.</div>
<div id="stanzaList"></div>
<div class="reorder-actions">
<button class="btn primary" id="reorderDone">Done</button>
</div>
</div>
</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 -> underline + tint
internal -> soft tint
slant rhymes -> faded underline (shared vowel sound)
============================================================ */
const editor = document.getElementById('editor');
const highlight = document.getElementById('highlight');
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;
/* ---------- 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 (or first visit)
const id = newId();
const text = localStorage.getItem('rhymepad.draft') || '';
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;
}
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){
docsState.current = id;
saveDocs();
editor.value = localStorage.getItem(docKey(id)) || '';
analysis = null;
renderTabs();
render(); analyze(); editor.focus();
}
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;
try{
const r = await fetch('/api/analyze', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({text})
});
analysis = await r.json();
backendOk = true;
}catch(e){
backendOk = false;
}
render();
}
const analyzeSoon = debounce(analyze, 250);
function render(){
persist();
const lines = editor.value.split('\n');
const tokByLine = {};
const groupInfo = {};
if(analysis){
analysis.groups.forEach(g=>{ groupInfo[g.id] = g; });
analysis.tokens.forEach(t=>{ (tokByLine[t.l] ||= []).push(t); });
}
let html = '';
lines.forEach((line, i)=>{
// only apply spans if this line still matches what the server analyzed
const fresh = analysis && analysis.lines[i] === line;
const toks = fresh ? (tokByLine[i] || []) : [];
toks.sort((a,b)=>a.s-b.s);
let pos = 0, h = '';
toks.forEach(t=>{
const g = groupInfo[t.g]; if(!g || t.s < pos) return;
h += esc(line.slice(pos, t.s));
const cls = 'rh' + (t.end ? ' end' : '') + (t.slant ? ' slant' : '');
h += `<span class="${cls}" style="--c:var(--r${g.color % COLORS})">` + esc(line.slice(t.s, t.e)) + '</span>';
pos = t.e;
});
h += esc(line.slice(pos));
const m = fresh && analysis.meter ? analysis.meter.find(x=>x.l===i) : null;
html += (m && m.off ? `<span class="offbeat">${h}</span>` : h) + '\n';
});
highlight.innerHTML = html;
highlight.scrollTop = editor.scrollTop;
highlight.scrollLeft = editor.scrollLeft;
buildReadout();
buildLegend();
}
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}` : '');
if(m.off) p += ` <span class="offline">· breaks stanza meter (~${m.target} syl)</span>`;
parts.push(p);
}
const st = caretStanza();
if(st && st.scheme) parts.push(`scheme <b>${st.scheme.toUpperCase().split('').join(' ')}</b>`);
schemeReadout.innerHTML = parts.join(' &nbsp;&nbsp; ');
}
function buildLegend(){
const box = document.getElementById('legendBox');
const st = caretStanza();
if(!st || !st.legend.length){ box.innerHTML = '<p class="muted">No rhymes detected yet.</p>'; return; }
box.innerHTML = st.legend.map(l=>{
const sw = l.color === null
? `<span class="swatch" style="background:#3a342d"></span>`
: `<span class="swatch" style="background:var(--r${l.color % COLORS})"></span>`;
const note = l.color === null ? ' — no rhyme' : (l.slant ? ' — slant' : '');
return `<div class="legend-item">${sw} ending “${l.ch}${note}</div>`;
}).join('');
}
editor.addEventListener('input', ()=>{ render(); analyzeSoon(); });
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = editor.scrollTop; highlight.scrollLeft = editor.scrollLeft; });
editor.addEventListener('keyup', ()=>{ buildReadout(); buildLegend(); });
editor.addEventListener('click', ()=>{ buildReadout(); buildLegend(); });
/* ---------- double-click a word -> look it up ---------- */
editor.addEventListener('dblclick', ()=>{
const sel = editor.value.slice(editor.selectionStart, editor.selectionEnd)
.trim().replace(/[^A-Za-z']/g,'');
if(sel){ document.getElementById('lookupInput').value = sel; doLookup(); }
});
/* ---------- insert at cursor ---------- */
function insertAtCursor(word){
const s = editor.selectionStart, e = editor.selectionEnd;
const v = editor.value;
const needSpace = s > 0 && !/\s/.test(v[s-1]);
const ins = (needSpace ? ' ' : '') + word;
editor.value = v.slice(0,s) + ins + v.slice(e);
const np = s + ins.length;
editor.focus();
editor.setSelectionRange(np, np);
render();
analyzeSoon();
}
/* ============================================================
LOOKUP — rhymes & near rhymes come from our backend (CMU dict,
frequency-ranked). Synonyms come from the free Datamuse API.
============================================================ */
const modeSeg = document.getElementById('modeSeg');
let mode = 'rhyme';
modeSeg.addEventListener('click', e=>{
const b = e.target.closest('button'); if(!b) return;
[...modeSeg.children].forEach(c=>c.classList.remove('active'));
b.classList.add('active'); mode = b.dataset.mode; doLookup();
});
document.getElementById('lookupBtn').addEventListener('click', doLookup);
document.getElementById('lookupInput').addEventListener('keydown', e=>{ if(e.key==='Enter') doLookup(); });
const resultsBox = document.getElementById('lookupResults');
async function doLookup(){
const word = document.getElementById('lookupInput').value.trim();
if(!word) return;
resultsBox.innerHTML = '<p class="muted">Searching…</p>';
try{
if(mode === 'syn'){
const r = await fetch(`https://api.datamuse.com/words?rel_syn=${encodeURIComponent(word)}&ml=${encodeURIComponent(word)}&max=60`);
const data = await r.json();
renderChips(`Synonyms / related for “${esc(word)}`, data.map(d=>d.word));
}else{
const r = await fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=${mode}`);
const data = await r.json();
if(!data.known){
resultsBox.innerHTML = `<p class="muted">“${esc(word)}” isn't in the pronunciation dictionary.</p>`;
return;
}
renderBySyllable(word, data.words, mode === 'near' ? 'Near rhymes' : 'Rhymes');
}
}catch(err){
resultsBox.innerHTML = `<p class="muted">Lookup failed — is the backend running? (${esc(String(err))})</p>`;
}
}
function chipHtml(words){
return '<div class="results">' +
words.map(w=>`<span class="chip" data-w="${esc(w)}">${esc(w)}</span>`).join('') +
'</div>';
}
function wireChips(){
resultsBox.querySelectorAll('.chip').forEach(c=>{
c.addEventListener('click', ()=> insertAtCursor(c.dataset.w));
});
}
function renderChips(label, words){
if(!words.length){ resultsBox.innerHTML = '<p class="muted">No results.</p>'; return; }
resultsBox.innerHTML = `<div class="res-label">${label}</div>` + chipHtml(words.slice(0,50));
wireChips();
}
function renderBySyllable(word, items, label){
if(!items.length){ resultsBox.innerHTML = `<p class="muted">No results for “${esc(word)}”.</p>`; return; }
const bySyl = {};
items.forEach(d=>{ (bySyl[d.syl || 0] ||= []).push(d.word); });
let h = `<div class="res-label">${label} for “${esc(word)}”</div>`;
Object.keys(bySyl).sort((a,b)=>a-b).forEach(k=>{
h += `<div class="res-label">${k == 0 ? '?' : k} syllable${k == 1 ? '' : 's'}</div>` + chipHtml(bySyl[k]);
});
resultsBox.innerHTML = h;
wireChips();
}
/* ============================================================
STANZA REORDER — drag cards to rearrange blank-line-separated
stanzas; the draft updates on every drop.
============================================================ */
const overlay = document.getElementById('reorderOverlay');
const stanzaList = document.getElementById('stanzaList');
let blocks = [];
function openReorder(){
blocks = editor.value.split(/\n{2,}/).map(b=>b.trim()).filter(Boolean);
if(blocks.length < 2){ flash('reorderBtn','Need 2+ stanzas'); return; }
buildCards();
overlay.hidden = false;
}
function schemeOf(block){
if(!analysis) return '';
const first = block.split('\n')[0];
const idx = analysis.lines.indexOf(first);
if(idx < 0) return '';
const st = analysis.stanzas.find(s=>s.lines.includes(idx));
return st ? st.scheme : '';
}
function buildCards(){
stanzaList.innerHTML = '';
blocks.forEach((b, i)=>{
const lines = b.split('\n');
const card = document.createElement('div');
card.className = 'stanza-card';
card.draggable = true;
card.dataset.idx = i;
const scheme = schemeOf(b);
card.innerHTML = `
<span class="grip">⠿</span>
<span class="preview">
<div class="first-line">${esc(lines[0])}</div>
<div class="meta-line">${lines.length} line${lines.length===1?'':'s'}</div>
</span>
${scheme ? `<span class="scheme-badge">${scheme.split('').join(' ')}</span>` : ''}`;
card.addEventListener('dragstart', ()=>{ dragIdx = i; card.classList.add('dragging'); });
card.addEventListener('dragend', ()=>{ dragIdx = null; card.classList.remove('dragging'); clearDrops(); });
card.addEventListener('dragover', e=>{
e.preventDefault();
clearDrops();
const before = e.offsetY < card.offsetHeight / 2;
card.classList.add(before ? 'drop-above' : 'drop-below');
});
card.addEventListener('drop', e=>{
e.preventDefault();
if(dragIdx === null) return;
const before = e.offsetY < card.offsetHeight / 2;
let to = i + (before ? 0 : 1);
const [moved] = blocks.splice(dragIdx, 1);
if(dragIdx < to) to--;
blocks.splice(to, 0, moved);
applyBlocks();
buildCards();
});
stanzaList.appendChild(card);
});
}
let dragIdx = null;
function clearDrops(){
stanzaList.querySelectorAll('.stanza-card').forEach(c=>c.classList.remove('drop-above','drop-below'));
}
function applyBlocks(){
editor.value = blocks.join('\n\n');
render();
analyzeSoon();
}
document.getElementById('reorderBtn').addEventListener('click', openReorder);
document.getElementById('reorderDone').addEventListener('click', ()=>{ overlay.hidden = true; editor.focus(); });
overlay.addEventListener('click', e=>{ if(e.target === overlay){ overlay.hidden = true; editor.focus(); } });
document.addEventListener('keydown', e=>{ if(e.key === 'Escape' && !overlay.hidden){ overlay.hidden = true; editor.focus(); } });
/* ============================================================
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);
}
document.getElementById('clearBtn').addEventListener('click', ()=>{
if(editor.value.trim()){
try{ localStorage.setItem('rhymepad.trash', editor.value); }catch(e){}
}
editor.value=''; analysis=null; render(); analyze(); editor.focus();
});
document.getElementById('sampleBtn').addEventListener('click', ()=>{
editor.value =
`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
Pressure make a diamond, grind it out the coal I hold
stories that I never told, weathered and a decade old
chasing something warmer than a chain of gold
staying up till every borrowed hour is sold`;
render(); analyze(); editor.focus();
});
/* ============================================================
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)));
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},
'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},
'Lo-Fi': {bpm:75, kick:[0,8], snare:[4,12], hat:[2,6,10,14], swing:0.18},
'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},
'Spoken/Jazz':{bpm:96, kick:[0,10], snare:[4,12], hat:[0,3,6,9,12,15], swing:0.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}
};
let actx=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)(); }
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(actx.destination); 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(actx.destination); 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.3,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.05);
noise.connect(f).connect(g).connect(actx.destination); noise.start(t); noise.stop(t+0.06);
}
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.hat.includes(s)) hat(t);
nextTime += secPer16;
step++;
}
schedTimer = setTimeout(scheduler, 25);
}
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);
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);
render();
analyze();
</script>
</body>
</html>