Docs/Skills and code/skill meme-fetcher
.claude/skills/meme-fetcher/scripts/gallery.cjs
What the file says about itself
Builds research/meme-library/index.html from catalogue.json: a filterable gallery. One self-contained file; images are referenced as media/<file> (gitignored, so open it locally). node gallery.cjs
#!/usr/bin/env node
// Builds research/meme-library/index.html from catalogue.json: a filterable gallery.
// One self-contained file; images are referenced as media/<file> (gitignored, so open it locally).
// node gallery.cjs
const fs = require('fs');
const path = require('path');
const LIB = path.resolve(__dirname, '..', '..', '..', '..', 'research', 'meme-library');
const cat = JSON.parse(fs.readFileSync(path.join(LIB, 'catalogue.json'), 'utf8'));
const data = JSON.stringify(cat.memes).replace(/</g, '\\u003c');
const stamp = new Date().toISOString().slice(0, 10);
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Meme Library</title>
<style>
:root{
--bg:#f6f4ef; --panel:#ffffff; --ink:#1d1b16; --muted:#6b675c; --line:#e3dfd3;
--accent:#b4531a; --good:#2f7d4f; --good-bg:#e8f3ec; --bad:#b03a2e; --bad-bg:#f8eae8;
--a:#2f7d4f; --b:#b07a12; --c:#a2402f; --warn-bg:#fdf3d8;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#15140f; --panel:#1f1d16; --ink:#efebe0; --muted:#a39e8e; --line:#35322a;
--accent:#e8924f; --good:#6fc48f; --good-bg:#17261d; --bad:#e58476; --bad-bg:#2a1b18;
--a:#6fc48f; --b:#e0b24d; --c:#e58476; --warn-bg:#2b2410;
}
}
:root[data-theme="dark"]{
--bg:#15140f; --panel:#1f1d16; --ink:#efebe0; --muted:#a39e8e; --line:#35322a;
--accent:#e8924f; --good:#6fc48f; --good-bg:#17261d; --bad:#e58476; --bad-bg:#2a1b18;
--a:#6fc48f; --b:#e0b24d; --c:#e58476; --warn-bg:#2b2410;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
.wrap{max-width:1180px;margin:0 auto;padding:0 16px 64px}
header{padding:40px 0 20px}
h1{font-size:clamp(1.8rem,4vw,2.6rem);margin:0 0 6px;letter-spacing:-.02em}
.sub{color:var(--muted);max-width:60ch;margin:0}
.stats{display:flex;flex-wrap:wrap;gap:10px;margin:20px 0 0}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px 16px}
.stat b{display:block;font-size:1.4rem}
.stat span{color:var(--muted);font-size:.85rem}
.bar{position:sticky;top:0;z-index:5;background:var(--bg);padding:12px 0;border-bottom:1px solid var(--line);margin-bottom:20px}
.bar input{width:100%;padding:12px 14px;font:inherit;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--ink)}
.chips{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.chip{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:999px;padding:5px 12px;font:inherit;font-size:.85rem;cursor:pointer}
.chip[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
.beats{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px 20px;margin:0 0 28px}
.beats h2{margin:0 0 4px;font-size:1.1rem}
.beats p{margin:0 0 12px;color:var(--muted);font-size:.9rem}
.beat{display:grid;grid-template-columns:90px 1fr;gap:6px 14px;padding:8px 0;border-top:1px solid var(--line);font-size:.92rem}
.beat:first-of-type{border-top:0}
.beat b{color:var(--accent)}
.beat a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px;cursor:pointer}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:20px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
.card.flash{outline:3px solid var(--accent)}
.pic{background:repeating-linear-gradient(45deg,var(--bg),var(--bg) 10px,var(--panel) 10px,var(--panel) 20px);aspect-ratio:16/10;display:flex;align-items:center;justify-content:center;position:relative}
.pic img{width:100%;height:100%;object-fit:contain;background:#000}
.pic .nofile{text-align:center;color:var(--muted);padding:16px;font-size:.9rem}
.pic .nofile b{display:block;font-size:1.05rem;color:var(--ink);margin-bottom:4px}
.badges{position:absolute;top:10px;left:10px;display:flex;gap:6px}
.badge{font-size:.72rem;font-weight:700;padding:3px 9px;border-radius:999px;background:var(--panel);border:1px solid var(--line)}
.tier-A{color:var(--a)} .tier-B{color:var(--b)} .tier-C{color:var(--c)}
.body{padding:16px 18px 18px;display:flex;flex-direction:column;gap:12px}
.card h3{margin:0;font-size:1.15rem;line-height:1.25}
.feel{margin:0;color:var(--muted);font-size:.92rem;font-style:italic}
.tags{display:flex;flex-wrap:wrap;gap:6px}
.tag{font-size:.75rem;padding:2px 9px;border-radius:999px;background:var(--bg);border:1px solid var(--line);color:var(--muted);cursor:pointer}
.use,.dont{border-radius:10px;padding:10px 12px;font-size:.9rem}
.use{background:var(--good-bg);border-left:4px solid var(--good)}
.dont{background:var(--bad-bg);border-left:4px solid var(--bad)}
.use b,.dont b{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;margin-bottom:2px}
.use b{color:var(--good)} .dont b{color:var(--bad)}
.warn{background:var(--warn-bg);border-radius:10px;padding:9px 12px;font-size:.85rem}
details summary{cursor:pointer;color:var(--muted);font-size:.85rem}
details p{margin:8px 0 0;font-size:.85rem;color:var(--muted)}
details a{color:var(--accent);word-break:break-all}
.found{font-size:.8rem;color:var(--muted)}
.empty{text-align:center;color:var(--muted);padding:60px 0}
footer{margin-top:40px;color:var(--muted);font-size:.85rem;border-top:1px solid var(--line);padding-top:16px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Meme library</h1>
<p class="sub">Recognisable memes and clips for the <em>Why X died</em> videos, each with a use-when and a don't-use-when. Nothing here is cleared for use; tiers are provisional while the video rules are revisited.</p>
<div class="stats" id="stats"></div>
</header>
<div class="bar">
<input id="q" type="search" placeholder="Describe the situation: crash, reveal, easy money, denial..." aria-label="Search the library">
<div class="chips" id="tierChips"></div>
</div>
<section class="beats" id="beats" hidden></section>
<div class="grid" id="grid"></div>
<p class="empty" id="empty" hidden>No memes match. Try fewer words, or ask the meme-fetcher to look for this situation.</p>
<footer>Generated ${stamp} from catalogue.json by gallery.cjs. Images load from media/ and are not committed, so open this file from the repo folder. Regenerate after any change: <code>node .claude/skills/meme-fetcher/scripts/gallery.cjs</code></footer>
</div>
<script>
const MEMES = ${data};
const $ = (s) => document.querySelector(s);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const tierName = {A:'A · still template', B:'B · film or TV clip', C:'C · creator clip'};
const state = { q:'', tier:'all', kind:'all' };
function stop(s){return String(s||'').toLowerCase().split(/[^a-z0-9]+/).filter(t=>t.length>1).map(t=>t.replace(/s$/,''));}
function score(m, qt){
if(!qt.length) return 1;
const hay = stop([m.name,(m.aliases||[]).join(' '),(m.situations||[]).join(' '),m.use_when,m.feeling,m.found_for].join(' '));
const tags = stop((m.situations||[]).join(' '));
let s = 0; for(const t of qt){ if(tags.includes(t)) s+=3; else if(hay.includes(t)) s+=1; }
return s;
}
function stats(){
const n = MEMES.length, f = MEMES.filter(m=>m.fetched).length;
const un = MEMES.filter(m=>m.clearance==='unchecked').length;
$('#stats').innerHTML =
'<div class="stat"><b>'+n+'</b><span>memes</span></div>'+
'<div class="stat"><b>'+f+'</b><span>with a file</span></div>'+
'<div class="stat"><b>'+MEMES.filter(m=>m.tier==='A').length+'</b><span>tier A</span></div>'+
'<div class="stat"><b>'+MEMES.filter(m=>m.tier!=='A').length+'</b><span>tier B or C</span></div>'+
'<div class="stat"><b>'+un+'</b><span>uncleared</span></div>';
}
function chips(){
const items = [['all','All'],['A','Tier A'],['B','Tier B'],['C','Tier C'],['file','Has file'],['nofile','No file']];
$('#tierChips').innerHTML = items.map(([k,l])=>'<button class="chip" data-k="'+k+'" aria-pressed="'+((k===state.tier)||(k===state.kind&&k!=='all')?'true':'false')+'">'+l+'</button>').join('');
document.querySelectorAll('#tierChips .chip').forEach(b=>b.onclick=()=>{
const k=b.dataset.k;
if(k==='all'){state.tier='all';state.kind='all';}
else if(k==='file'||k==='nofile'){state.kind=state.kind===k?'all':k;}
else {state.tier=state.tier===k?'all':k;}
render();
});
}
function beatsPanel(){
const map = {};
for(const m of MEMES){
const f = m.found_for||''; const re=/B(\\d+)/g; let x;
if(!/Videocon/i.test(f)) continue;
while((x=re.exec(f))) (map[+x[1]] = map[+x[1]]||[]).push(m);
}
const keys = Object.keys(map).map(Number).sort((a,b)=>a-b);
if(!keys.length) return;
const el = $('#beats'); el.hidden=false;
el.innerHTML = '<h2>Videocon short: where memes sit</h2><p>Click a name to jump to its card. Beats with no entry are intentionally meme-free (the plant, the allegations).</p>'+
keys.map(k=>'<div class="beat"><b>B'+k+'</b><div>'+map[k].map(m=>'<a data-id="'+esc(m.id)+'">'+esc(m.name)+'</a>').join(' · ')+'</div></div>').join('');
el.querySelectorAll('a').forEach(a=>a.onclick=()=>{
state.q='';$('#q').value='';state.tier='all';state.kind='all';render();
const c=document.getElementById('card-'+a.dataset.id); if(c){c.scrollIntoView({behavior:'smooth',block:'center'});c.classList.add('flash');setTimeout(()=>c.classList.remove('flash'),1800);}
});
}
function card(m){
const pic = m.fetched && m.file
? '<img loading="lazy" src="'+esc(m.file)+'" alt="'+esc(m.name)+'" onerror="this.replaceWith(Object.assign(document.createElement(\\'div\\'),{className:\\'nofile\\',innerHTML:\\'<b>File missing</b>Run the fetcher again or check media/\\'}))">'
: '<div class="nofile"><b>'+(m.format==='video'?'Clip, not downloaded':'No image yet')+'</b>'+esc(m.tier==='B'||m.tier==='C'?'Catalogue only until a source is decided':'Needs a sourced or redrawn template')+'</div>';
const warn = /UNVERIFIED|watermark|NO FILE/i.test(m.notes||'') ? '<div class="warn">'+esc(m.notes)+'</div>' : '';
const src = (m.sources||[]).map(s=>/^https?:/.test(s)?'<a href="'+esc(s)+'" target="_blank" rel="noopener">'+esc(s)+'</a>':esc(s)).join('<br>') || 'None recorded';
return '<article class="card" id="card-'+esc(m.id)+'">'+
'<div class="pic">'+pic+'<div class="badges"><span class="badge tier-'+esc(m.tier)+'">'+esc(tierName[m.tier]||m.tier)+'</span><span class="badge">'+esc(m.clearance)+'</span></div></div>'+
'<div class="body"><h3>'+esc(m.name)+'</h3>'+
'<p class="feel">'+esc(m.feeling)+'</p>'+
'<div class="tags">'+(m.situations||[]).map(t=>'<span class="tag" data-t="'+esc(t)+'">'+esc(t)+'</span>').join('')+'</div>'+
'<div class="use"><b>Use when</b>'+esc(m.use_when)+'</div>'+
'<div class="dont"><b>Don\\'t use when</b>'+esc(m.dont_use_when)+'</div>'+
(m.found_for?'<div class="found">Found for: '+esc(m.found_for)+'</div>':'')+
(/UNVERIFIED|watermark|NO FILE/i.test(m.notes||'')?warn:'')+
'<details><summary>Origin, owner, sources, notes</summary><p><b>Origin.</b> '+esc(m.origin)+'</p><p><b>Owner.</b> '+esc(m.owner)+'</p><p><b>Sources.</b><br>'+src+'</p>'+(m.notes&&!warn?'<p><b>Notes.</b> '+esc(m.notes)+'</p>':'')+'</details>'+
'</div></article>';
}
function render(){
const qt = stop(state.q);
let rows = MEMES.map(m=>({m,s:score(m,qt)})).filter(r=>r.s>0);
if(state.tier!=='all') rows = rows.filter(r=>r.m.tier===state.tier);
if(state.kind==='file') rows = rows.filter(r=>r.m.fetched);
if(state.kind==='nofile') rows = rows.filter(r=>!r.m.fetched);
if(qt.length) rows.sort((a,b)=>b.s-a.s);
$('#grid').innerHTML = rows.map(r=>card(r.m)).join('');
$('#empty').hidden = rows.length>0;
document.querySelectorAll('.tag').forEach(t=>t.onclick=()=>{state.q=t.dataset.t;$('#q').value=t.dataset.t;render();window.scrollTo({top:0,behavior:'smooth'});});
chips();
}
$('#q').addEventListener('input',e=>{state.q=e.target.value;render();});
stats(); beatsPanel(); render();
</script>
</body>
</html>
`;
fs.writeFileSync(path.join(LIB, 'index.html'), html);
console.log('wrote ' + path.join(LIB, 'index.html') + ' (' + cat.memes.length + ' memes)');
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.