Files
rhymepad.org/static/index.html
T
kennethreitz 69b8ab4e93 WordNet synonyms + double-click opens definitions
- mode=syn now served locally by NLTK WordNet (already a g2p-en dep),
  sense-grouped by part of speech and frequency-ranked; Datamuse gone
- Double-clicking a word in the draft opens its definition card; the
  card grew a Rhymes button for the old jump
- NLTK wordnet data baked into the Docker image and CI

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

836 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 segments — words tint the background, phrases draw the
underline; both can overlap in different colors. Background/shadow
only, so the textarea text on top stays crisp and box metrics stay
identical (no horizontal padding!) */
.hseg { border-radius: 4px; }
.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; }
.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; }
.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; }
.defcard {
background: var(--panel-2); border: 1px solid var(--line);
border-radius: 10px; padding: 12px 14px; margin: 10px 0 4px;
}
.defhead { display: flex; align-items: baseline; gap: 8px; font-size: 14px; }
.defhead .phon { color: var(--ink-dim); font-size: 12px; }
.defx { margin-left: auto; cursor: pointer; color: var(--ink-dim); font-size: 15px; }
.defx:hover { color: var(--r6); }
.defs { margin: 8px 0 10px; padding-left: 18px; font-size: 13px; line-height: 1.55; }
.defs li { margin: 4px 0; }
.defs i {
color: var(--accent-2); font-style: normal; font-size: 11px;
text-transform: uppercase; letter-spacing: .06em; margin-right: 4px;
}
.btn.small { padding: 5px 10px; font-size: 12px; }
.def-actions { display: flex; gap: 8px; }
.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="clearBtn">Clear</button>
<button class="btn" id="sampleBtn">Load sample</button>
<label class="mtoggle"><input type="checkbox" id="meterToggle"> meter check</label>
<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="defBox"></div>
<div id="lookupResults">
<p class="muted">Type a word and hit Go for rhymes. Double-click a word in your draft for its definition. Click any result chip for its definition too.</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>
<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); });
}
const colorOf = t => `var(--r${groupInfo[t.g].color % COLORS})`;
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] || []) : []).filter(t=>groupInfo[t.g]);
const words = toks.filter(t=>!t.ph);
const phrases = toks.filter(t=>t.ph);
let h = '';
if(!toks.length){
h = esc(line);
}else{
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 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);
if(!w && !p){ h += text; continue; }
let style = '';
if(w) style += `background:color-mix(in srgb, ${colorOf(w)} ${w.end ? 24 : 15}%, transparent);`;
let ul = null;
if(w && w.end){
ul = w.slant ? `color-mix(in srgb, ${colorOf(w)} 45%, transparent)` : colorOf(w);
}else if(p){
ul = p.end && !p.slant ? colorOf(p)
: `color-mix(in srgb, ${colorOf(p)} 55%, transparent)`;
}
if(ul) style += `box-shadow:inset 0 -2px 0 0 ${ul};`;
h += `<span class="hseg" style="${style}">${text}</span>`;
}
}
const m = meterToggle.checked && 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 && meterToggle.checked) 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;
tab('lookup');
showDefinition(sel);
}
});
/* ---------- 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;
defBox.innerHTML = '';
resultsBox.innerHTML = '<p class="muted">Searching…</p>';
try{
const r = await fetch(`/api/lookup?word=${encodeURIComponent(word)}&mode=${mode}`);
const data = await r.json();
if(mode === 'syn'){
if(!data.known){
resultsBox.innerHTML = `<p class="muted">No synonyms found for “${esc(word)}”.</p>`;
return;
}
renderByPos(word, data.words);
}else{
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', ()=> showDefinition(c.dataset.w));
});
}
/* ---------- definition card (free dictionary API) ---------- */
const defBox = document.getElementById('defBox');
function defCard(word, inner){
defBox.innerHTML = `
<div class="defcard">
<div class="defhead"><b>${esc(word)}</b><span class="phon" id="defPhon"></span>
<span class="defx" title="Dismiss">×</span></div>
${inner}
<div class="def-actions">
<button class="btn small" id="defInsert">Insert at cursor</button>
<button class="btn small" id="defRhymes">Rhymes</button>
</div>
</div>`;
defBox.querySelector('#defInsert').addEventListener('click', ()=> insertAtCursor(word));
defBox.querySelector('#defRhymes').addEventListener('click', ()=>{
document.getElementById('lookupInput').value = word;
doLookup();
});
defBox.querySelector('.defx').addEventListener('click', ()=>{ defBox.innerHTML=''; });
}
async function showDefinition(word){
defCard(word, '<p class="muted">Looking it up…</p>');
try{
const r = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`);
if(!r.ok) throw new Error('no entry');
const entry = (await r.json())[0];
const phon = (entry.phonetics || []).map(p=>p.text).find(Boolean) || '';
const defs = [];
(entry.meanings || []).forEach(m=>{
(m.definitions || []).slice(0, 2).forEach(d=> defs.push({pos: m.partOfSpeech, def: d.definition}));
});
const items = defs.slice(0, 4).map(d=>`<li><i>${esc(d.pos || '')}</i>${esc(d.def)}</li>`).join('');
defCard(word, `<ol class="defs">${items}</ol>`);
if(phon) defBox.querySelector('#defPhon').textContent = phon;
}catch(e){
defCard(word, '<p class="muted">No dictionary entry found.</p>');
}
}
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 renderByPos(word, items){
if(!items.length){ resultsBox.innerHTML = `<p class="muted">No synonyms for “${esc(word)}”.</p>`; return; }
const byPos = {};
items.forEach(d=>{ (byPos[d.pos || 'related'] ||= []).push(d.word); });
let h = `<div class="res-label">Synonyms for “${esc(word)}”</div>`;
['noun','verb','adjective','adverb','related'].forEach(pos=>{
if(!byPos[pos]) return;
h += `<div class="res-label">${pos}s</div>` + chipHtml(byPos[pos]);
delete byPos[pos];
});
Object.keys(byPos).forEach(pos=>{ h += `<div class="res-label">${esc(pos)}</div>` + chipHtml(byPos[pos]); });
resultsBox.innerHTML = h;
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();
}
/* ============================================================
METER CHECK — toggleable wavy-underline warnings for lines
that break their stanza's syllable pattern.
============================================================ */
const meterToggle = document.getElementById('meterToggle');
meterToggle.checked = localStorage.getItem('rhymepad.meter') === '1';
meterToggle.addEventListener('change', ()=>{
try{ localStorage.setItem('rhymepad.meter', meterToggle.checked ? '1' : '0'); }catch(e){}
render();
});
/* ============================================================
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>