mirror of
https://github.com/kennethreitz/rhymepad.org.git
synced 2026-06-11 17:08:33 +00:00
03fc5d16d0
The scheme readout in the toolbar covers it; the panel is for lookups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
815 lines
32 KiB
HTML
815 lines
32 KiB
HTML
<!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; }
|
||
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 & 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 & 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>
|
||
|
||
<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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
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();
|
||
}
|
||
|
||
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(' ');
|
||
}
|
||
|
||
editor.addEventListener('input', ()=>{ render(); analyzeSoon(); });
|
||
editor.addEventListener('scroll', ()=>{ highlight.scrollTop = editor.scrollTop; highlight.scrollLeft = editor.scrollLeft; });
|
||
editor.addEventListener('keyup', buildReadout);
|
||
editor.addEventListener('click', buildReadout);
|
||
|
||
/* ---------- 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>
|