Commit 4c9a8312 by PLN (Algolia)

feat(tide-table): Hall of Fame showcase viz — tierlist.html (#11)

The Foundry Hall of Fame, built through /impeccable in the Ship's Bridge language
(armada/DESIGN.md): dark instrument surface, Geist + Geist-Mono, brand magenta
reserved for the one earned accent (here the S-tier champions, the brand eyebrow,
and the now-playing pulse). Register = product: quiet, instrument-grade, the ear
leads and the screen serves.

What it shows:
- A classic S→D tier board. Tier is encoded redundantly (letter badge + lane
  position + colour), never hue alone — colour-blind safe by construction (DESIGN
  principle 4). Each kit is a chip: median grade, usage (tracks that play it),
  loop count, and a row of flag dots (seam/clip/off-grid/dc).
- Click a kit → a drawer of its loops, each with a grade bar, tier dot, and a
  PLAY button. The ear leads: every loop auditions in place (loop-on-repeat via a
  shared <audio>, _corpus/<kit>/<file>.wav over serve.py), with a now-playing
  footer. (DESIGN principle 2.)
- The rubric-validation callout, shown HONESTLY (principle 1, trust the
  instrument): gradeusage ρ=+0.34, with a log-scale scatter that *labels its own
  confound* — risers and weird_dialogs sit low-right (used most, tier low) because
  they're FX, not loops. The footnote states the coverage caveat (34/97 kits carry
  a usage token) rather than implying full coverage.
- Search, filter (all/played/flagged), sort (grade/usage/name); skeleton load,
  empty states, focus-visible, keyboard audition, and prefers-reduced-motion
  fallbacks for every animation.

Verified in Chromium at 1280/390 widths via Playwright: no console errors, drawer +
audition + scatter all render, copy carries no em-dashes (impeccable ban). Audio
served through the local _corpus symlink (gitignored). Runs via
`python3 serve.py --dir tide-table --port 8731` → /tierlist.html.
parent 91334e26
...@@ -2,3 +2,4 @@ dist/ ...@@ -2,3 +2,4 @@ dist/
__pycache__/ __pycache__/
# machine-local audition symlink → Dirt-Samples (recreated by build_unwrapped.py) # machine-local audition symlink → Dirt-Samples (recreated by build_unwrapped.py)
_samples _samples
_corpus
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Foundry · Hall of Fame</title>
<meta name="description" content="Every hand-cut ParVagues loop, graded by the Foundry katana and ranked S to D. The rubric, validated against what PLN actually plays.">
<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=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
/* ───────────────────────────────────────────────────────────────────────────
Foundry · Hall of Fame — the corpus tierlist (task #11).
Ship's Bridge language (armada/DESIGN.md): dark instrument surface, Geist +
Geist Mono, never hue alone (tier = letter + lane + colour), brand magenta
reserved for the ONE earned accent — here, the S-tier champions. The ear
leads: every loop auditions on click. Trust the instrument: the rubric
validation is shown honestly, confound and coverage included. Tokens override
from tokens.json; data from tierlist.json. Zero npm deps, self-contained.
─────────────────────────────────────────────────────────────────────────── */
:root{
--surface:#0a0a0a; --raised:#111113; --overlay:#16161a; --hairline:#ffffff1c;
--ink:#e8e8ea; --mute:#9a9aa2; --faint:#67676e;
--magenta:#d900ff; --magenta-low:#a700d1;
--tier-s:#d900ff; --tier-a:#5bc091; --tier-b:#7db3ff; --tier-c:#e0a82e; --tier-d:#7a7a82;
--ok:#5bc091; --warn:#e0a82e; --bad:#ff5252;
--grid:#ffffff10; --maxw:1180px;
--r-sm:4px; --r-md:6px; --r-lg:10px;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
@media (prefers-reduced-motion: reduce){html{scroll-behavior:auto}}
body{margin:0;background:var(--surface);color:var(--ink);
font:400 15px/1.5 Geist,Inter,system-ui,-apple-system,sans-serif;
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
.mono{font-family:"Geist Mono",ui-monospace,SFMono-Regular,monospace;
font-variant-numeric:tabular-nums}
a{color:var(--tier-b);text-decoration:none}a:hover{text-decoration:underline}
code{font-family:"Geist Mono",ui-monospace,monospace;font-size:.86em;
background:#ffffff0d;border:1px solid var(--hairline);border-radius:5px;
padding:1px 5px;color:#9fd0ff}
:focus-visible{outline:2px solid var(--tier-b);outline-offset:2px;border-radius:var(--r-sm)}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 24px}
/* ── masthead ─────────────────────────────────────────────────────────────── */
header.top{padding:48px 0 28px;border-bottom:1px solid var(--hairline)}
.eyebrow{font:600 12px/1 "Geist Mono",monospace;letter-spacing:.14em;
text-transform:uppercase;color:var(--magenta);margin:0 0 14px}
h1{margin:0;font-size:clamp(2rem,4.5vw,3rem);font-weight:600;letter-spacing:-0.035em;
line-height:1.05;text-wrap:balance}
.lede{margin:14px 0 0;font-size:clamp(1rem,1.5vw,1.15rem);color:var(--mute);
max-width:60ch;line-height:1.5;text-wrap:pretty}
.lede b{color:var(--ink);font-weight:500}
.stats{display:flex;flex-wrap:wrap;gap:0;margin-top:28px}
.stat{padding:0 22px;border-left:1px solid var(--hairline)}
.stat:first-child{padding-left:0;border-left:0}
.stat .v{font:600 1.7rem/1 "Geist Mono",monospace;letter-spacing:-0.02em}
.stat .l{font-size:.75rem;color:var(--faint);margin-top:6px;letter-spacing:.02em}
.stat .v small{font-size:.9rem;color:var(--mute);font-weight:500}
/* ── validation callout — the instrument, honest ───────────────────────────── */
.valid{margin-top:28px;border:1px solid var(--hairline);border-radius:var(--r-lg);
background:linear-gradient(180deg,#ffffff06,transparent);overflow:hidden;
display:grid;grid-template-columns:minmax(0,1fr) 360px}
@media(max-width:840px){.valid{grid-template-columns:1fr}}
.valid .txt{padding:20px 22px}
.valid h2{margin:0;font-size:1.05rem;font-weight:600;display:flex;align-items:center;gap:10px}
.valid h2 .rho{font-family:"Geist Mono",monospace;color:var(--ok);font-weight:600}
.valid p{margin:.7rem 0 0;color:var(--mute);font-size:.9rem;line-height:1.55;max-width:62ch}
.valid p b{color:var(--ink);font-weight:500}
.valid .scatter{border-left:1px solid var(--hairline);padding:14px;min-width:0;
display:flex;flex-direction:column;gap:6px}
@media(max-width:840px){.valid .scatter{border-left:0;border-top:1px solid var(--hairline)}}
.valid .scatter .cap{font:500 11px/1.3 "Geist Mono",monospace;color:var(--faint);
letter-spacing:.02em}
.scatter svg{width:100%;height:auto;display:block;overflow:visible}
/* ── controls toolbar ───────────────────────────────────────────────────────── */
.toolbar{position:sticky;top:0;z-index:30;background:#0a0a0aef;
-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);
border-bottom:1px solid var(--hairline);padding:11px 0}
.toolbar .wrap{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
.search{flex:1;min-width:180px;display:flex;align-items:center;gap:8px;
background:var(--raised);border:1px solid var(--hairline);border-radius:var(--r-md);
padding:7px 11px}
.search input{flex:1;background:transparent;border:0;color:var(--ink);
font:400 14px Geist,sans-serif;outline:none;min-width:0}
.search input::placeholder{color:var(--faint)}
.search .ico{color:var(--faint);font-size:14px}
.seg{display:flex;background:var(--raised);border:1px solid var(--hairline);
border-radius:var(--r-md);padding:3px;gap:2px}
.seg button{appearance:none;border:0;background:transparent;cursor:pointer;
font:500 12.5px/1 Geist,sans-serif;color:var(--mute);padding:6px 12px;
border-radius:var(--r-sm);transition:background .16s ease,color .16s ease}
.seg button.on{color:var(--ink);background:#ffffff14}
.seg button:hover:not(.on){color:var(--ink)}
.seg .lbl{font:500 10px/1 "Geist Mono",monospace;color:var(--faint);
letter-spacing:.06em;text-transform:uppercase;padding:0 9px;align-self:center}
/* ── tier board ─────────────────────────────────────────────────────────────── */
main{padding:24px 0 80px}
.tier{display:grid;grid-template-columns:84px minmax(0,1fr);gap:16px;
margin-bottom:14px;align-items:stretch}
.tier .badge{border-radius:var(--r-lg);display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:3px;padding:14px 0;
border:1px solid var(--hairline);position:relative;overflow:hidden}
.tier .badge .L{font:600 2.1rem/1 "Geist Mono",monospace;letter-spacing:-0.02em}
.tier .badge .c{font:500 10px/1 "Geist Mono",monospace;color:var(--faint)}
.tier[data-t=S] .badge{background:linear-gradient(160deg,#d900ff2e,#d900ff08);
border-color:#d900ff55}
.tier[data-t=S] .badge .L{color:var(--tier-s)}
.tier[data-t=A] .badge .L{color:var(--tier-a)} .tier[data-t=A] .badge{background:#5bc09112}
.tier[data-t=B] .badge .L{color:var(--tier-b)} .tier[data-t=B] .badge{background:#7db3ff10}
.tier[data-t=C] .badge .L{color:var(--tier-c)} .tier[data-t=C] .badge{background:#e0a82e0e}
.tier[data-t=D] .badge .L{color:var(--tier-d)} .tier[data-t=D] .badge{background:#ffffff06}
.tier .row{display:flex;flex-wrap:wrap;gap:8px;align-content:flex-start;
padding:4px 0}
.tier .empty{color:var(--faint);font-size:.85rem;font-style:italic;padding:14px 2px;align-self:center}
/* ── kit chip ───────────────────────────────────────────────────────────────── */
.kit{appearance:none;text-align:left;cursor:pointer;background:var(--raised);
border:1px solid var(--hairline);border-radius:var(--r-md);padding:9px 12px;
display:flex;flex-direction:column;gap:5px;min-width:142px;
transition:border-color .16s ease,background .16s ease,transform .16s ease}
.kit:hover{border-color:#ffffff3a;background:var(--overlay)}
.kit.open{border-color:var(--ink);background:var(--overlay)}
.kit .nm{font:500 13.5px/1.15 Geist,sans-serif;letter-spacing:-0.01em;
word-break:break-word}
.kit .meta{display:flex;align-items:center;gap:9px;font-family:"Geist Mono",monospace;
font-size:11px;color:var(--mute)}
.kit .g{color:var(--ink);font-weight:500}
.kit .use{display:inline-flex;align-items:center;gap:3px}
.kit .use.hot{color:var(--ink);font-weight:600}
.kit .flg{display:flex;gap:3px;margin-left:auto}
.kit .flg i{width:6px;height:6px;border-radius:50%;display:inline-block}
.kit .flg i.seam{background:var(--bad)} .kit .flg i.clip{background:var(--warn)}
.kit .flg i.grid{background:var(--tier-c)} .kit .flg i.dc{background:var(--faint)}
.dim{opacity:.26;filter:saturate(.4);pointer-events:none}
/* ── detail drawer ──────────────────────────────────────────────────────────── */
.drawer{grid-column:1 / -1;margin-top:2px}
.dpanel{border:1px solid var(--ink);border-radius:var(--r-lg);overflow:hidden;
background:var(--raised)}
.dhead{display:flex;flex-wrap:wrap;align-items:baseline;gap:14px;padding:16px 18px;
border-bottom:1px solid var(--hairline)}
.dhead .nm{font:600 1.15rem/1 Geist,sans-serif;letter-spacing:-0.02em}
.dhead .tag{font:500 11px/1 "Geist Mono",monospace;color:var(--mute);
border:1px solid var(--hairline);border-radius:var(--r-sm);padding:4px 8px}
.dhead .tag b{color:var(--ink)}
.dhead .x{margin-left:auto;appearance:none;border:0;background:transparent;
color:var(--mute);cursor:pointer;font-size:18px;line-height:1;padding:4px 6px;border-radius:var(--r-sm)}
.dhead .x:hover{color:var(--ink);background:#ffffff10}
.loops{list-style:none;margin:0;padding:6px}
.loop{display:grid;grid-template-columns:34px minmax(0,1fr) auto;gap:12px;
align-items:center;padding:8px 12px;border-radius:var(--r-md);cursor:pointer;
transition:background .14s ease}
.loop:hover{background:var(--overlay)}
.loop.playing{background:#d900ff14}
.loop .play{width:30px;height:30px;border-radius:50%;border:1px solid var(--hairline);
background:var(--overlay);color:var(--ink);display:grid;place-items:center;
font-size:11px;transition:border-color .14s ease}
.loop.playing .play{border-color:var(--magenta);color:var(--magenta)}
.loop .info{min-width:0}
.loop .lnm{font:500 13px/1.2 Geist,sans-serif;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis}
.loop .lsub{font:400 11px/1.3 "Geist Mono",monospace;color:var(--faint);margin-top:2px}
.loop .lflags{color:var(--warn)}
.loop .right{display:flex;align-items:center;gap:12px}
.bar{width:120px;height:6px;border-radius:3px;background:#ffffff12;overflow:hidden;position:relative}
@media(max-width:560px){.bar{width:64px}}
.bar span{position:absolute;inset:0 auto 0 0;border-radius:3px}
.loop .lg{font:600 13px/1 "Geist Mono",monospace;width:46px;text-align:right}
.tierdot{width:18px;height:18px;border-radius:var(--r-sm);display:grid;place-items:center;
font:600 10px/1 "Geist Mono",monospace;color:#0a0a0a}
/* ── now-playing footer ─────────────────────────────────────────────────────── */
.np{position:fixed;left:0;right:0;bottom:0;z-index:40;transform:translateY(110%);
transition:transform .24s cubic-bezier(.22,1,.36,1);background:#0c0c0ff2;
-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
border-top:1px solid var(--hairline)}
.np.show{transform:translateY(0)}
@media (prefers-reduced-motion: reduce){.np{transition:none}}
.np .wrap{display:flex;align-items:center;gap:14px;padding:11px 24px}
.np .eq{display:flex;align-items:flex-end;gap:2px;height:18px;width:20px}
.np .eq i{flex:1;background:var(--magenta);border-radius:1px;height:30%;
animation:eq .9s ease-in-out infinite}
.np .eq i:nth-child(2){animation-delay:.3s} .np .eq i:nth-child(3){animation-delay:.6s}
@keyframes eq{0%,100%{height:30%}50%{height:100%}}
@media (prefers-reduced-motion: reduce){.np .eq i{animation:none;height:60%}}
.np .lbl{font:500 13px Geist,sans-serif}.np .lbl .k{color:var(--mute)}
.np .lbl .g{font-family:"Geist Mono",monospace;color:var(--mute);margin-left:8px;font-size:12px}
.np .stop{margin-left:auto;appearance:none;border:1px solid var(--hairline);
background:var(--raised);color:var(--ink);cursor:pointer;border-radius:var(--r-md);
padding:6px 13px;font:500 12.5px Geist,sans-serif}
.np .stop:hover{border-color:#ffffff3a}
.footnote{color:var(--faint);font-size:.8rem;line-height:1.55;margin-top:30px;
max-width:74ch;border-top:1px solid var(--hairline);padding-top:18px}
.err{padding:40px 0;color:var(--mute)}.err b{color:var(--ink)}
.skel{height:64px;border-radius:var(--r-md);background:linear-gradient(90deg,#ffffff08,#ffffff12,#ffffff08);
background-size:200% 100%;animation:sh 1.3s linear infinite;margin-bottom:10px}
@keyframes sh{to{background-position:-200% 0}}
@media (prefers-reduced-motion: reduce){.skel{animation:none}}
</style>
</head>
<body>
<header class="top"><div class="wrap">
<p class="eyebrow">Foundry · the katana, validated</p>
<h1>Hall of Fame</h1>
<p class="lede" id="lede">Every hand-cut loop in the ParVagues palette, graded by the
Foundry loop-grader and ranked S to D. <b>The ear leads</b>: click any loop to audition it.</p>
<div class="stats" id="stats"></div>
<section class="valid" id="valid" aria-label="Rubric validation" hidden>
<div class="txt">
<h2>Does the rubric track reality? <span class="rho" id="rho"></span></h2>
<p id="validtext"></p>
</div>
<div class="scatter">
<div class="cap">grade (y) × tracks that play it (x) · log scale</div>
<div id="scatter"></div>
</div>
</section>
</div></header>
<nav class="toolbar"><div class="wrap">
<label class="search"><span class="ico"></span>
<input id="q" type="search" placeholder="Filter kits…" aria-label="Filter kits by name">
</label>
<div class="seg" role="group" aria-label="Filter by usage">
<span class="lbl">Show</span>
<button data-f="all" class="on">All</button>
<button data-f="used">Played</button>
<button data-f="flawed">Flagged</button>
</div>
<div class="seg" role="group" aria-label="Sort kits">
<span class="lbl">Sort</span>
<button data-s="grade" class="on">Grade</button>
<button data-s="usage">Usage</button>
<button data-s="name">Name</button>
</div>
</div></nav>
<main class="wrap" id="board" aria-live="polite">
<div class="skel"></div><div class="skel"></div><div class="skel"></div>
</main>
<div class="wrap"><p class="footnote" id="foot"></p></div>
<div class="np" id="np" aria-live="polite">
<div class="wrap">
<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>
<span class="lbl"><span id="npname"></span><span class="g" id="npgrade"></span></span>
<button class="stop" id="npstop">Stop</button>
</div>
</div>
<script>
const TIERS=["S","A","B","C","D"];
const TIERLABEL={S:"the best",A:"excellent",B:"good",C:"fair",D:"flawed"};
const FLAGCLASS={"seam-click":"seam","clipping":"clip","off-grid":"grid","dc-offset":"dc","near-silent":"seam","mono-incompatible":"dc","one-shot":"dc"};
let DATA=null, TOK=null, audio=new Audio(), playingId=null;
const $=(s,r=document)=>r.querySelector(s);
const el=(t,a={},...kids)=>{const n=document.createElement(t);
for(const[k,v]of Object.entries(a)){if(k==="class")n.className=v;else if(k==="html")n.innerHTML=v;
else if(k.startsWith("on"))n.addEventListener(k.slice(2),v);else n.setAttribute(k,v);}
for(const c of kids)if(c!=null)n.append(c.nodeType?c:document.createTextNode(c));return n;};
const SVGNS="http://www.w3.org/2000/svg";
const S=(t,a={})=>{const n=document.createElementNS(SVGNS,t);for(const[k,v]of Object.entries(a))n.setAttribute(k,v);return n;};
async function load(){
try{
DATA=await fetch("tierlist.json").then(r=>{if(!r.ok)throw new Error("tierlist.json "+r.status);return r.json();});
}catch(e){
$("#board").innerHTML=`<div class="err"><b>Can't load tierlist.json.</b> Serve the dir:
<code>python3 serve.py --dir tide-table --port 8731</code> then open
<code>/tierlist.html</code>. (${e.message})</div>`;return;}
try{TOK=await fetch("tokens.json").then(r=>r.ok?r.json():null);}catch(_){}
applyTokens(); renderStats(); renderValidation(); render(); wireControls(); wireAudio();
}
function applyTokens(){
if(!TOK)return;const root=document.documentElement.style;
for(const[k,v]of Object.entries(TOK.neutrals||{}))root.setProperty("--"+k,v);
if(TOK.brand&&TOK.brand.magenta){root.setProperty("--magenta",TOK.brand.magenta);
root.setProperty("--tier-s",TOK.brand.magenta);}
}
function renderStats(){
const d=DATA, td=d.tier_distribution;
const items=[
[d.n_kits,"kits"],[fmt(d.n_wavs),"loops graded"],
[`${td.S}<small> S</small> ${td.A}<small> A</small> ${td.B}<small> B</small>`,"top tiers"],
[`<small>as of </small>${d.as_of||"—"}`,"corpus snapshot"],
];
$("#stats").replaceChildren(...items.map(([v,l])=>
el("div",{class:"stat"},el("div",{class:"v mono",html:String(v)}),el("div",{class:"l"},l))));
}
function renderValidation(){
const v=DATA.validation; if(v.grade_usage_spearman==null){$("#valid").remove();return;}
$("#valid").hidden=false;
const rho=v.grade_usage_spearman, pass=v.core_kits_b_tier_or_above, core=v.core_kits_usage_ge_5;
$("#rho").textContent=`ρ = ${rho>0?"+":""}${rho.toFixed(2)}`;
$("#validtext").innerHTML=
`Yes, directionally: the kits PLN plays more tend to grade higher
(Spearman <b>ρ=${rho>0?"+":""}${rho.toFixed(2)}</b> across ${v.n_kits_with_usage} kits with a usage token).
The honest confound is the outliers. <b>risers</b> and <b>weird_dialogs</b> are played the most yet
tier low, and rightly so: they're FX sweeps, not loops, so they fail the seam and bar-grid checks by
design. Strip the FX and the line is steeper, so the grader is really telling loops from transitions.
That divergence is a feature.`;
drawScatter();
}
function drawScatter(){
const kits=DATA.kits.filter(k=>k.usage>0);
const W=340,H=190,m={l:30,r:10,t:10,b:24};
const iw=W-m.l-m.r, ih=H-m.t-m.b;
const maxU=Math.max(...kits.map(k=>k.usage));
const lx=u=>m.l+(Math.log(u)/Math.log(maxU))*iw; // log x
const ly=g=>m.t+(1-g)*ih; // grade 0..1 top-down
const svg=S("svg",{viewBox:`0 0 ${W} ${H}`,role:"img","aria-label":`Scatter of ${kits.length} kits: grade versus usage`});
// gridlines at grade .25/.5/.75
[.25,.5,.75].forEach(g=>{svg.append(S("line",{x1:m.l,y1:ly(g),x2:W-m.r,y2:ly(g),stroke:"var(--grid)"}));
const t=S("text",{x:4,y:ly(g)+3,fill:"var(--faint)","font-size":"8","font-family":"Geist Mono"});t.textContent=g;svg.append(t);});
// trend line (least-squares on log-usage vs grade)
const xs=kits.map(k=>Math.log(k.usage)/Math.log(maxU)), ys=kits.map(k=>k.grade);
const n=xs.length, mx=xs.reduce((a,b)=>a+b)/n, my=ys.reduce((a,b)=>a+b)/n;
let num=0,den=0; for(let i=0;i<n;i++){num+=(xs[i]-mx)*(ys[i]-my);den+=(xs[i]-mx)**2;}
const slope=num/den, b0=my-slope*mx;
svg.append(S("line",{x1:m.l,y1:m.t+(1-(b0))*ih,x2:W-m.r,y2:m.t+(1-(slope+b0))*ih,
stroke:"var(--ink)","stroke-width":"1","stroke-dasharray":"3 3",opacity:".5"}));
// points
const FX=new Set(["risers","weird_dialogs"]);
kits.forEach(k=>{
const fx=FX.has(k.kit);
const c=S("circle",{cx:lx(k.usage),cy:ly(k.grade),r:fx?4:3,
fill:`var(--tier-${k.tier.toLowerCase()})`,opacity:fx?"1":".8"});
c.append(S("title",{}));c.lastChild.textContent=`${k.kit} · ${k.tier} · g=${k.grade.toFixed(2)} · ${k.usage}×`;
svg.append(c);
if(fx){const t=S("text",{x:lx(k.usage)-6,y:ly(k.grade)+3,fill:"var(--mute)",
"font-size":"8.5","font-family":"Geist Mono","text-anchor":"end"});t.textContent=k.kit;svg.append(t);}
});
$("#scatter").replaceChildren(svg);
}
// ── board render ──────────────────────────────────────────────────────────────
let state={q:"",f:"all",s:"grade",open:null};
function passes(k){
if(state.q && !k.kit.toLowerCase().includes(state.q))return false;
if(state.f==="used" && k.usage<1)return false;
if(state.f==="flawed" && !Object.keys(k.top_flags||{}).length)return false;
return true;
}
function sortKits(a,b){
if(state.s==="usage")return b.usage-a.usage || b.grade-a.grade;
if(state.s==="name")return a.kit.localeCompare(b.kit);
return b.grade-a.grade;
}
function render(){
const board=$("#board"); board.replaceChildren();
const byTier=Object.fromEntries(TIERS.map(t=>[t,[]]));
DATA.kits.forEach(k=>byTier[k.tier].push(k));
let shown=0;
for(const t of TIERS){
const kits=byTier[t].slice().sort(sortKits);
const row=el("div",{class:"row"});
let any=false;
for(const k of kits){
const ok=passes(k); if(ok)any=true; if(ok)shown++;
row.append(kitChip(k,ok));
}
if(!kits.length)row.append(el("div",{class:"empty"},"no kits"));
else if(!any)row.append(el("div",{class:"empty"},"none match the filter"));
const tier=el("div",{class:"tier","data-t":t},
el("div",{class:"badge"},el("div",{class:"L"},t),el("div",{class:"c mono"},`${kits.length}`)),
row);
board.append(tier);
if(state.open && state.open.tier===t){
const k=kits.find(x=>x.kit===state.open.kit);
if(k)board.append(drawer(k));
}
}
$("#foot").innerHTML=foot(shown);
}
function kitChip(k,ok){
const flags=Object.keys(k.top_flags||{});
const fl=el("span",{class:"flg"});
["seam-click","clipping","off-grid","dc-offset"].forEach(f=>{
if(flags.includes(f))fl.append(el("i",{class:FLAGCLASS[f],title:f}));});
const hot=k.usage>=5;
const chip=el("button",{class:"kit"+(ok?"":" dim")+(state.open&&state.open.kit===k.kit?" open":""),
"aria-expanded":String(!!(state.open&&state.open.kit===k.kit)),
onclick:()=>toggle(k)},
el("div",{class:"nm"},k.kit),
el("div",{class:"meta"},
el("span",{class:"g"},k.grade.toFixed(3)),
el("span",{class:"use"+(hot?" hot":""),title:`${k.usage} tracks play this`},
"▸ "+(k.usage||"·")),
el("span",{class:"mono",title:"loops in kit",style:"color:var(--faint)"},k.n_wavs+"w"),
fl));
return chip;
}
function toggle(k){
state.open=(state.open&&state.open.kit===k.kit)?null:{tier:k.tier,kit:k.kit};
render();
}
function drawer(k){
const head=el("div",{class:"dhead"},
el("div",{class:"nm"},k.kit),
el("span",{class:"tag",html:`tier <b>${k.tier}</b> · ${TIERLABEL[k.tier]}`}),
el("span",{class:"tag",html:`median <b>${k.grade.toFixed(3)}</b>`}),
el("span",{class:"tag",html:`range <b>${k.worst.toFixed(2)}${k.best.toFixed(2)}</b>`}),
el("span",{class:"tag",html:`<b>${k.usage}</b> tracks play it`}),
el("button",{class:"x","aria-label":"Close",onclick:()=>{state.open=null;render();}},"✕"));
const list=el("ul",{class:"loops"});
k.files.forEach((f,i)=>list.append(loopRow(k,f,i)));
return el("div",{class:"drawer"},el("div",{class:"dpanel"},head,list));
}
function loopRow(k,f,i){
const id=k.kit+"/"+f.name;
const flags=(f.flags||[]).filter(x=>x!=="one-shot");
const t=f.tier, col=`var(--tier-${t.toLowerCase()})`;
const row=el("li",{class:"loop"+(playingId===id?" playing":""),"data-id":id,
role:"button",tabindex:"0",
onclick:()=>playLoop(k,f),
onkeydown:e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();playLoop(k,f);}}},
el("div",{class:"play","aria-hidden":"true"},playingId===id?"❚❚":"▶"),
el("div",{class:"info"},
el("div",{class:"lnm"},f.name),
el("div",{class:"lsub"},`${f.dur_s}s · ${f.rms_dbfs} dBFS`+
(f.kind==="one_shot"?" · one-shot":"")+
(flags.length?` · `:"")+(flags.length?flags.join(", "):""))),
el("div",{class:"right"},
el("div",{class:"bar"},el("span",{style:`width:${Math.round(f.grade*100)}%;background:${col}`})),
el("div",{class:"lg",style:`color:${col}`},f.grade.toFixed(2)),
el("div",{class:"tierdot",style:`background:${col}`,title:`tier ${t}`},t)));
if((f.lsub||"").length)row.querySelector(".lsub").classList.add("lflags");
return row;
}
// ── audio ───────────────────────────────────────────────────────────────────
function playLoop(k,f){
const id=k.kit+"/"+f.name;
if(playingId===id){audio.pause();playingId=null;updatePlaying();return;}
audio.src="_corpus/"+encodeURIComponent(k.kit)+"/"+encodeURIComponent(f.name);
audio.loop=true; audio.currentTime=0;
audio.play().catch(()=>{});
playingId=id;
$("#npname").innerHTML=`<span class="k">${k.kit}/</span>${f.name}`;
$("#npgrade").textContent=`${f.tier} · ${f.grade.toFixed(3)}`;
updatePlaying();
}
function updatePlaying(){
$("#np").classList.toggle("show",!!playingId);
document.querySelectorAll(".loop").forEach(r=>{
const on=r.dataset.id===playingId;
r.classList.toggle("playing",on);
const p=r.querySelector(".play");if(p)p.textContent=on?"❚❚":"▶";
});
}
function wireAudio(){
$("#npstop").addEventListener("click",()=>{audio.pause();playingId=null;updatePlaying();});
audio.addEventListener("ended",()=>{playingId=null;updatePlaying();});
}
// ── controls ──────────────────────────────────────────────────────────────────
function wireControls(){
$("#q").addEventListener("input",e=>{state.q=e.target.value.trim().toLowerCase();render();});
document.querySelectorAll(".seg [data-f]").forEach(b=>b.addEventListener("click",()=>{
state.f=b.dataset.f;segOn(b,"data-f");render();}));
document.querySelectorAll(".seg [data-s]").forEach(b=>b.addEventListener("click",()=>{
state.s=b.dataset.s;segOn(b,"data-s");render();}));
}
function segOn(btn,attr){btn.parentElement.querySelectorAll(`[${attr}]`).forEach(x=>x.classList.remove("on"));btn.classList.add("on");}
// ── helpers ───────────────────────────────────────────────────────────────────
function fmt(n){return String(n).replace(/\B(?=(\d{3})+(?!\d))/g," ");}
function foot(shown){
const v=DATA.validation;
return `Graded by <code>${DATA.grader}</code> over <code>${DATA.samples_root.replace(/^.*\/Samples/,'…/Samples')}</code>.
Tier = median grade of a kit's loops (S≥.90 · A≥.80 · B≥.70 · C≥.55 · D&lt;.55). Usage = tracks
that play <code>s "kit"</code>, from the catalog. ${v.n_kits_with_usage} of ${DATA.n_kits} kits carry a
usage token (folder name vs <code>s</code>-token, and the catalog covers a subset of tracks), so the
correlation is over those. Grader thresholds are v1; the dc-offset flag is intentionally strict.
${shown} kit${shown===1?"":"s"} shown.`;
}
load();
</script>
</body></html>
{ {
"schema": "foundry tierlist v1", "schema": "foundry tierlist v1",
"as_of": "", "as_of": "2026-06-23",
"samples_root": "/home/pln/Work/Sound/Samples", "samples_root": "/home/pln/Work/Sound/Samples",
"grader": "tools/foundry/engine/grade.py", "grader": "tools/foundry/engine/grade.py",
"n_kits": 97, "n_kits": 97,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment