mirror of
https://github.com/tiennm99/awesome-coding-agents.git
synced 2026-08-31 10:33:29 +00:00
feat(site): UI/UX improvements for Pages dashboard (#6)
* feat(site): UI/UX improvements for Pages dashboard Phase 1+2 of plans/260808-2317 + ui-ux-pro-max quality pass: - fix invisible single-snapshot chart series (show points when <2) - persist legend hidden-state across chart re-renders - Chart.js CDN failure fallback message - meta description, OG tags, SVG favicon, theme-color, noscript - log/linear y-axis toggle; debounced text search (table + chart) - stale-repo muting + text badge (>6 months since push) - delta7d sort puts no-baseline rows last - a11y: aria-sort, aria-pressed, canvas role/label, focus-visible, keyboard-sortable headers, 44px coarse-pointer targets, contrast >=4.5:1 both schemes, reduced-motion guard, empty state * fix(site): guard renderChart in Chart.js CDN-failure degraded mode Review finding (major): filter/search handlers call hoisted renderChart() after the early CDN-failure return, hitting TDZ consts and throwing on every interaction. Guard with window.Chart check; verified with a broken-CDN copy: table filtering works error-free, fallback msg shows.
This commit is contained in:
+188
-45
@@ -4,15 +4,24 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Awesome Coding Agents — Rankings & History</title>
|
||||
<meta name="description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
||||
<meta property="og:title" content="Awesome Coding Agents — Rankings & History">
|
||||
<meta property="og:description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
||||
<meta property="og:url" content="https://tiennm99.github.io/awesome-coding-agents/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Ctext%20y%3D%22.9em%22%20font-size%3D%2290%22%3E%E2%AD%90%3C%2Ftext%3E%3C%2Fsvg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff; --fg: #1f2328; --muted: #656d76; --border: #d0d7de;
|
||||
--bg: #ffffff; --fg: #1f2328; --muted: #59626b; --border: #d0d7de;
|
||||
--accent: #0969da; --chip: #f6f8fa; --chip-on: #0969da; --chip-on-fg: #fff;
|
||||
--pos: #1a7f37; --neg: #cf222e; --row-hover: #f6f8fa;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d1117; --fg: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--bg: #0d1117; --fg: #e6edf3; --muted: #9ba3ad; --border: #30363d;
|
||||
--accent: #58a6ff; --chip: #21262d; --chip-on: #1f6feb; --chip-on-fg: #fff;
|
||||
--pos: #3fb950; --neg: #f85149; --row-hover: #161b22;
|
||||
}
|
||||
@@ -26,16 +35,33 @@
|
||||
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
|
||||
.sub { color: var(--muted); margin-bottom: 1.25rem; }
|
||||
.sub a { color: var(--accent); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
|
||||
.chips button {
|
||||
.vh {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: .5rem .75rem; align-items: center; margin: 1rem 0 .5rem; }
|
||||
#q {
|
||||
border: 1px solid var(--border); background: var(--bg); color: var(--fg);
|
||||
border-radius: 6px; padding: .4rem .75rem; font: inherit; min-width: 14rem; flex: 1 1 14rem; max-width: 24rem;
|
||||
}
|
||||
#q::placeholder { color: var(--muted); opacity: 1; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: .5rem; margin: .5rem 0 1rem; }
|
||||
.chips button, .btn {
|
||||
border: 1px solid var(--border); background: var(--chip); color: var(--fg);
|
||||
border-radius: 999px; padding: .25rem .85rem; cursor: pointer; font-size: .9rem;
|
||||
border-radius: 999px; padding: .3rem .85rem; cursor: pointer; font-size: .9rem;
|
||||
transition: background-color .15s ease, border-color .15s ease;
|
||||
}
|
||||
.btn { border-radius: 6px; }
|
||||
.chips button.on, .btn[aria-pressed="true"] { background: var(--chip-on); color: var(--chip-on-fg); border-color: var(--chip-on); }
|
||||
.chips button:hover, .btn:hover { border-color: var(--accent); }
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, th[data-k]:focus-visible {
|
||||
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||
}
|
||||
.chips button.on { background: var(--chip-on); color: var(--chip-on-fg); border-color: var(--chip-on); }
|
||||
.tablewrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
|
||||
table { border-collapse: collapse; width: 100%; min-width: 780px; }
|
||||
th, td { padding: .5rem .75rem; text-align: left; border-top: 1px solid var(--border); }
|
||||
thead th { border-top: none; background: var(--chip); cursor: pointer; user-select: none; white-space: nowrap; }
|
||||
thead th { border-top: none; background: var(--chip); user-select: none; white-space: nowrap; }
|
||||
thead th[data-k] { cursor: pointer; }
|
||||
thead th .dir { color: var(--accent); }
|
||||
tbody tr:hover { background: var(--row-hover); }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
@@ -47,10 +73,28 @@
|
||||
display: inline-block; font-size: .75rem; padding: .05rem .5rem;
|
||||
border: 1px solid var(--border); border-radius: 999px; color: var(--muted);
|
||||
}
|
||||
h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; }
|
||||
tr.stale td { color: var(--muted); }
|
||||
.stalebadge {
|
||||
display: inline-block; font-size: .72rem; padding: 0 .45rem; margin-left: .4rem;
|
||||
border: 1px solid var(--border); border-radius: 999px; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: .03em; vertical-align: middle;
|
||||
}
|
||||
td.empty { text-align: center; color: var(--muted); padding: 1.5rem; }
|
||||
h2 { font-size: 1.2rem; margin: 0; }
|
||||
.charthead { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin: 2rem 0 .5rem; flex-wrap: wrap; }
|
||||
.chartwrap { position: relative; height: 460px; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
|
||||
.chartmsg { color: var(--muted); text-align: center; padding-top: 4rem; }
|
||||
.hint { color: var(--muted); font-size: .85rem; margin: .35rem 0 0; }
|
||||
#err { color: var(--neg); }
|
||||
noscript p { border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
|
||||
noscript a { color: var(--accent); }
|
||||
@media (pointer: coarse) {
|
||||
.chips button, .btn, #q { min-height: 44px; }
|
||||
thead th { padding-block: .85rem; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -60,26 +104,41 @@
|
||||
<span id="meta">loading…</span> ·
|
||||
<a href="https://github.com/tiennm99/awesome-coding-agents">GitHub repo</a>
|
||||
</div>
|
||||
<div id="err"></div>
|
||||
<div id="err" role="alert"></div>
|
||||
|
||||
<noscript>
|
||||
<p>This dashboard needs JavaScript to render the ranking table and charts.
|
||||
You can view the same ranking in the
|
||||
<a href="https://github.com/tiennm99/awesome-coding-agents#readme">project README on GitHub</a>.</p>
|
||||
</noscript>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="vh" for="q">Search repositories by name or description</label>
|
||||
<input id="q" type="search" placeholder="Search repos…" autocomplete="off">
|
||||
</div>
|
||||
<div class="chips" id="chips" role="group" aria-label="Filter by category"></div>
|
||||
|
||||
<div class="chips" id="chips"></div>
|
||||
<div class="tablewrap"><table id="tbl">
|
||||
<thead><tr>
|
||||
<th data-k="rank" class="num">#</th>
|
||||
<th data-k="nameWithOwner">Repo</th>
|
||||
<th data-k="stars" class="num">Stars</th>
|
||||
<th data-k="delta7d" class="num">Δ7d</th>
|
||||
<th data-k="category">Category</th>
|
||||
<th data-k="language">Language</th>
|
||||
<th data-k="pushedAt">Last push</th>
|
||||
<th>Description</th>
|
||||
<th scope="col" data-k="rank" class="num" tabindex="0">#</th>
|
||||
<th scope="col" data-k="nameWithOwner" tabindex="0">Repo</th>
|
||||
<th scope="col" data-k="stars" class="num" tabindex="0">Stars</th>
|
||||
<th scope="col" data-k="delta7d" class="num" tabindex="0">Δ7d</th>
|
||||
<th scope="col" data-k="category" tabindex="0">Category</th>
|
||||
<th scope="col" data-k="language" tabindex="0">Language</th>
|
||||
<th scope="col" data-k="pushedAt" tabindex="0">Last push</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
|
||||
<h2>Star history</h2>
|
||||
<div class="chartwrap"><canvas id="chart"></canvas></div>
|
||||
<p class="hint">Top 12 repos shown by default — click legend entries to toggle series. The category filter above also filters the chart.</p>
|
||||
<div class="charthead">
|
||||
<h2>Star history</h2>
|
||||
<button type="button" id="scaleToggle" class="btn" aria-pressed="false">Log scale</button>
|
||||
</div>
|
||||
<div class="chartwrap"><canvas id="chart" role="img"
|
||||
aria-label="Line chart of GitHub star history over time for the repositories listed in the table above."></canvas></div>
|
||||
<p class="hint">Top 12 repos shown by default — click legend entries to toggle series. The category filter and search above also filter the chart.</p>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
@@ -98,80 +157,148 @@
|
||||
document.getElementById('meta').textContent =
|
||||
'Last updated ' + data.updatedAt + ' · ' + rows.length + ' repos tracked';
|
||||
|
||||
const staleCutoff = new Date();
|
||||
staleCutoff.setMonth(staleCutoff.getMonth() - 6);
|
||||
const isStale = r => r.pushedAt && new Date(r.pushedAt) < staleCutoff;
|
||||
|
||||
// --- category filter chips ---
|
||||
const cats = ['all', ...new Set(rows.map(r => r.category).filter(Boolean).sort())];
|
||||
let activeCat = 'all';
|
||||
const chipBox = document.getElementById('chips');
|
||||
for (const c of cats) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = c;
|
||||
b.className = c === activeCat ? 'on' : '';
|
||||
b.setAttribute('aria-pressed', String(c === activeCat));
|
||||
b.onclick = () => {
|
||||
activeCat = c;
|
||||
chipBox.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b));
|
||||
chipBox.querySelectorAll('button').forEach(x => {
|
||||
const on = x === b;
|
||||
x.classList.toggle('on', on);
|
||||
x.setAttribute('aria-pressed', String(on));
|
||||
});
|
||||
renderTable(); renderChart();
|
||||
};
|
||||
chipBox.appendChild(b);
|
||||
}
|
||||
|
||||
const visible = () => activeCat === 'all' ? rows : rows.filter(r => r.category === activeCat);
|
||||
// --- text search (filters table + chart) ---
|
||||
let query = '';
|
||||
const qInput = document.getElementById('q');
|
||||
let qTimer;
|
||||
qInput.addEventListener('input', () => {
|
||||
clearTimeout(qTimer);
|
||||
qTimer = setTimeout(() => {
|
||||
query = qInput.value.trim().toLowerCase();
|
||||
renderTable(); renderChart();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
const visible = () => rows.filter(r =>
|
||||
(activeCat === 'all' || r.category === activeCat) &&
|
||||
(!query ||
|
||||
r.nameWithOwner.toLowerCase().includes(query) ||
|
||||
String(r.description || '').toLowerCase().includes(query)));
|
||||
|
||||
// --- sortable table ---
|
||||
const fmtStars = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n);
|
||||
let sortKey = 'rank', sortAsc = true;
|
||||
document.querySelectorAll('thead th[data-k]').forEach(th => {
|
||||
th.onclick = () => {
|
||||
const activate = () => {
|
||||
const k = th.dataset.k;
|
||||
if (sortKey === k) sortAsc = !sortAsc;
|
||||
else { sortKey = k; sortAsc = (k === 'rank' || k === 'nameWithOwner' || k === 'category' || k === 'language'); }
|
||||
renderTable();
|
||||
};
|
||||
th.onclick = activate;
|
||||
th.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); }
|
||||
});
|
||||
});
|
||||
|
||||
function renderTable() {
|
||||
const list = [...visible()].sort((a, b) => {
|
||||
if (sortKey === 'delta7d') {
|
||||
// repos without a 7-day baseline always sort last, either direction
|
||||
if (a.hasDelta !== b.hasDelta) return a.hasDelta ? -1 : 1;
|
||||
if (!a.hasDelta) return 0;
|
||||
return sortAsc ? a.delta7d - b.delta7d : b.delta7d - a.delta7d;
|
||||
}
|
||||
const va = a[sortKey], vb = b[sortKey];
|
||||
const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb));
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
document.querySelectorAll('thead th .dir').forEach(s => s.remove());
|
||||
const th = document.querySelector('thead th[data-k="' + sortKey + '"]');
|
||||
if (th) th.insertAdjacentHTML('beforeend', ' <span class="dir">' + (sortAsc ? '▲' : '▼') + '</span>');
|
||||
document.querySelectorAll('thead th[data-k]').forEach(th => {
|
||||
th.querySelector('.dir')?.remove();
|
||||
if (th.dataset.k === sortKey) {
|
||||
th.setAttribute('aria-sort', sortAsc ? 'ascending' : 'descending');
|
||||
th.insertAdjacentHTML('beforeend', ' <span class="dir" aria-hidden="true">' + (sortAsc ? '▲' : '▼') + '</span>');
|
||||
} else {
|
||||
th.removeAttribute('aria-sort');
|
||||
}
|
||||
});
|
||||
|
||||
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
document.querySelector('#tbl tbody').innerHTML = list.map(r => `
|
||||
<tr>
|
||||
const tbody = document.querySelector('#tbl tbody');
|
||||
if (!list.length) {
|
||||
tbody.innerHTML = '<tr><td class="empty" colspan="8">No repos match your filters.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = list.map(r => `
|
||||
<tr${isStale(r) ? ' class="stale"' : ''}>
|
||||
<td class="num">${r.rank}</td>
|
||||
<td><a class="repo" href="${esc(r.url)}">${esc(r.nameWithOwner)}</a></td>
|
||||
<td class="num">${fmtStars(r.stars)}</td>
|
||||
<td class="num ${r.delta7d > 0 ? 'pos' : r.delta7d < 0 ? 'neg' : ''}">${r.hasDelta ? (r.delta7d > 0 ? '+' : '') + r.delta7d : '—'}</td>
|
||||
<td class="num ${r.hasDelta && r.delta7d > 0 ? 'pos' : r.hasDelta && r.delta7d < 0 ? 'neg' : ''}">${r.hasDelta ? (r.delta7d > 0 ? '+' : '') + r.delta7d : '—'}</td>
|
||||
<td><span class="cat">${esc(r.category || '—')}</span></td>
|
||||
<td>${esc(r.language || '')}</td>
|
||||
<td>${esc(r.pushedAt)}</td>
|
||||
<td>${esc(r.pushedAt)}${isStale(r) ? ' <span class="stalebadge">stale</span>' : ''}</td>
|
||||
<td class="desc">${esc(r.description)}${r.notes ? ' · <em>' + esc(r.notes) + '</em>' : ''}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
renderTable();
|
||||
|
||||
// --- star-history line chart ---
|
||||
const chartWrap = document.querySelector('.chartwrap');
|
||||
if (!window.Chart) {
|
||||
chartWrap.innerHTML = '<p class="chartmsg">Chart could not be loaded (the Chart.js CDN is unreachable or blocked). The ranking table above still works.</p>';
|
||||
document.getElementById('scaleToggle').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const dates = data.history.map(s => s.date);
|
||||
const color = i => 'hsl(' + Math.round(i * 137.508) % 360 + ' 65% 50%)';
|
||||
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
// darker lines on light bg / lighter on dark bg to keep >=3:1 against background
|
||||
const color = i => 'hsl(' + Math.round(i * 137.508) % 360 + ' 70% ' + (isDark ? 62 : 38) + '%)';
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
Chart.defaults.color = styles.getPropertyValue('--muted').trim();
|
||||
Chart.defaults.borderColor = styles.getPropertyValue('--border').trim();
|
||||
let chart;
|
||||
let yType = 'linear';
|
||||
const hiddenState = {}; // repo key -> hidden bool, survives chart re-renders
|
||||
|
||||
function renderChart() {
|
||||
// Degraded mode: filter/search handlers still call this after the early
|
||||
// CDN-failure return; without the guard they hit TDZ consts and throw.
|
||||
if (!window.Chart) return;
|
||||
const list = visible();
|
||||
const datasets = list.map((r, i) => ({
|
||||
label: r.nameWithOwner,
|
||||
data: data.history.map(s => s.stars[r.key] ?? null),
|
||||
borderColor: color(i),
|
||||
backgroundColor: color(i),
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
hidden: i >= 12, // top 12 visible; rest toggleable via legend
|
||||
}));
|
||||
const datasets = list.map((r, i) => {
|
||||
const pts = data.history.map(s => s.stars[r.key] ?? null);
|
||||
const nPoints = pts.filter(v => v != null).length;
|
||||
return {
|
||||
label: r.nameWithOwner,
|
||||
repoKey: r.key,
|
||||
data: pts,
|
||||
borderColor: color(i),
|
||||
backgroundColor: color(i),
|
||||
pointRadius: nPoints < 2 ? 3 : 0, // single snapshots need a visible dot
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
hidden: r.key in hiddenState ? hiddenState[r.key] : i >= 12, // top 12 visible by default
|
||||
};
|
||||
});
|
||||
if (chart) chart.destroy();
|
||||
chart = new Chart(document.getElementById('chart'), {
|
||||
type: 'line',
|
||||
@@ -180,18 +307,34 @@
|
||||
responsive: true, maintainAspectRatio: false, animation: false,
|
||||
interaction: { mode: 'nearest', intersect: false },
|
||||
scales: {
|
||||
y: { ticks: { callback: v => fmtStars(v) } },
|
||||
y: { type: yType, ticks: { callback: v => fmtStars(v) } },
|
||||
x: { ticks: { maxTicksLimit: 12 } },
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { boxWidth: 12, boxHeight: 12 } },
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { boxWidth: 12, boxHeight: 12 },
|
||||
onClick(e, item, legend) {
|
||||
Chart.defaults.plugins.legend.onClick.call(this, e, item, legend);
|
||||
const ds = legend.chart.data.datasets[item.datasetIndex];
|
||||
hiddenState[ds.repoKey] = !legend.chart.isDatasetVisible(item.datasetIndex);
|
||||
},
|
||||
},
|
||||
tooltip: { callbacks: { label: c => c.dataset.label + ': ' + (c.parsed.y ?? '—').toLocaleString() } },
|
||||
},
|
||||
},
|
||||
});
|
||||
document.getElementById('chart').setAttribute('aria-label',
|
||||
'Line chart of GitHub star history over time for ' + list.length + ' repositories. The same data is in the table above.');
|
||||
}
|
||||
|
||||
renderTable();
|
||||
const scaleBtn = document.getElementById('scaleToggle');
|
||||
scaleBtn.onclick = () => {
|
||||
yType = yType === 'linear' ? 'logarithmic' : 'linear';
|
||||
scaleBtn.setAttribute('aria-pressed', String(yType === 'logarithmic'));
|
||||
if (chart) { chart.options.scales.y.type = yType; chart.update(); }
|
||||
};
|
||||
|
||||
renderChart();
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user