Initial scaffold: ArchyHCL, a community hardware compatibility list for Archipelago
Build and validate / build (push) Successful in 1m38s

Modeled on three researched precedents (see README): OpenWrt's Table of
Hardware for the browsable sortable/filterable table UX, postmarketOS's
working/community/testing tiers for the status field (collapsed to
working/partial/broken here), and RaspiBlitz's scattered GitHub-issues
approach as a negative example to avoid — hence structured YAML report
files validated against a JSON Schema instead of free-text issue threads.

- data/reports/*.yml + data/schema.json: one file per report, schema
  requires `issues` whenever status is partial/broken
- scripts/build.py: validates every report and builds site/data.json,
  fails loudly on bad data (same idea as archy's own
  validate-app-manifest.sh)
- site/: plain HTML/CSS/JS, no framework or build step, fetches data.json
  client-side — search, filter by status/form-factor, sortable columns,
  click a row for issues/notes detail
- .github/ISSUE_TEMPLATE/hardware-report.yml: structured submission path
  for contributors who don't want to touch git directly
- .gitea/workflows/ci.yml: runs the build/validate step on push and PRs

Not yet deployed anywhere — see README's Deployment section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 11:32:58 +00:00
co-authored by Claude Sonnet 5
commit a22a9c9826
14 changed files with 855 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
// No framework, no build step — this is a plain JSON file (site/data.json,
// generated by scripts/build.py) rendered client-side. Keeps the "add a
// device" contribution path as simple as adding one YAML file.
let reports = [];
let sortKey = "device_model";
let sortDir = 1;
const rowsEl = document.getElementById("rows");
const searchEl = document.getElementById("search");
const statusEl = document.getElementById("status-filter");
const formFactorEl = document.getElementById("form-factor-filter");
const countEl = document.getElementById("count");
const emptyEl = document.getElementById("empty");
function storageLabel(storage) {
return `${storage.size_gb}GB ${storage.type.toUpperCase()}`;
}
function matchesFilters(r, query, status, formFactor) {
if (status && r.status !== status) return false;
if (formFactor && r.form_factor !== formFactor) return false;
if (!query) return true;
const haystack = [r.device_model, r.cpu, r.wifi_chip, r.ethernet]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
}
function sortValue(r, key) {
if (key === "storage") return r.storage.size_gb;
return r[key];
}
function render() {
const query = searchEl.value.trim().toLowerCase();
const status = statusEl.value;
const formFactor = formFactorEl.value;
const filtered = reports.filter((r) => matchesFilters(r, query, status, formFactor));
filtered.sort((a, b) => {
const av = sortValue(a, sortKey);
const bv = sortValue(b, sortKey);
if (av < bv) return -1 * sortDir;
if (av > bv) return 1 * sortDir;
return 0;
});
rowsEl.innerHTML = "";
for (const r of filtered) {
const tr = document.createElement("tr");
tr.className = "expandable";
tr.innerHTML = `
<td>${escapeHtml(r.device_model)}</td>
<td>${escapeHtml(r.form_factor)}</td>
<td>${escapeHtml(r.cpu)}</td>
<td>${r.ram_gb}GB</td>
<td>${storageLabel(r.storage)}</td>
<td>${escapeHtml(r.wifi_chip)}</td>
<td><span class="badge ${r.status}">${r.status}</span></td>
<td>${escapeHtml(r.archy_version)}</td>
<td>${escapeHtml(r.tested_date)}</td>
`;
tr.addEventListener("click", () => toggleDetail(tr, r));
rowsEl.appendChild(tr);
}
countEl.textContent = `${filtered.length} of ${reports.length} report${reports.length === 1 ? "" : "s"}`;
emptyEl.hidden = filtered.length !== 0;
}
function toggleDetail(tr, r) {
const next = tr.nextElementSibling;
if (next && next.classList.contains("detail-row")) {
next.remove();
return;
}
document.querySelectorAll(".detail-row").forEach((el) => el.remove());
const detail = document.createElement("tr");
detail.className = "detail-row";
const parts = [];
if (r.ethernet) parts.push(`<strong>Ethernet:</strong> ${escapeHtml(r.ethernet)}`);
parts.push(`<strong>Install method:</strong> ${escapeHtml(r.install_method)}`);
if (r.submitted_by) parts.push(`<strong>Submitted by:</strong> ${escapeHtml(r.submitted_by)}`);
if (r.issues) parts.push(`<strong>Issues:</strong>\n${escapeHtml(r.issues.trim())}`);
if (r.notes) parts.push(`<strong>Notes:</strong>\n${escapeHtml(r.notes.trim())}`);
const td = document.createElement("td");
td.colSpan = 9;
td.innerHTML = parts.join("\n\n");
detail.appendChild(td);
tr.after(detail);
}
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s ?? "";
return div.innerHTML;
}
document.querySelectorAll("th[data-sort]").forEach((th) => {
th.addEventListener("click", () => {
const key = th.dataset.sort;
if (sortKey === key) {
sortDir *= -1;
} else {
sortKey = key;
sortDir = 1;
}
render();
});
});
searchEl.addEventListener("input", render);
statusEl.addEventListener("change", render);
formFactorEl.addEventListener("change", render);
fetch("data.json")
.then((res) => res.json())
.then((data) => {
reports = data;
render();
})
.catch((err) => {
emptyEl.hidden = false;
emptyEl.textContent = "Failed to load data.json — " + err;
});
+41
View File
@@ -0,0 +1,41 @@
[
{
"device_model": "Lenovo ThinkPad T430",
"form_factor": "laptop",
"cpu": "Intel Core i5-3320M",
"ram_gb": 8,
"storage": {
"type": "ssd",
"size_gb": 256
},
"wifi_chip": "Intel Centrino Advanced-N 6205",
"ethernet": "Intel 82579LM",
"archy_version": "1.8.4-alpha",
"install_method": "usb-iso",
"status": "working",
"tested_date": "2026-08-15",
"submitted_by": "example-contributor",
"notes": "Example report \u2014 replace with a real one. Boots and installs cleanly from\na USB stick made with the standard ISO. No BIOS changes needed beyond\nenabling AHCI mode (should already be default on most T430s).\n",
"_source_file": "example-thinkpad-t430.yml"
},
{
"device_model": "Raspberry Pi 5 (8GB)",
"form_factor": "sbc",
"cpu": "Broadcom BCM2712 (4x Cortex-A76)",
"ram_gb": 8,
"storage": {
"type": "nvme",
"size_gb": 512
},
"wifi_chip": "Broadcom BCM43455 (onboard)",
"ethernet": "Broadcom onboard Gigabit",
"archy_version": "1.8.4-alpha",
"install_method": "netboot",
"status": "partial",
"tested_date": "2026-08-10",
"submitted_by": "example-contributor",
"issues": "Example report \u2014 replace with a real one. Onboard WiFi drops the\nconnection under sustained heavy P2P traffic (Bitcoin IBD); a workaround\nis to run over Ethernet instead. Everything else (Lightning, apps,\ndashboard) works fine.\n",
"notes": "NVMe via the official PCIe HAT. Boots noticeably faster than SD-card\ninstalls.\n",
"_source_file": "example-raspberry-pi-5.yml"
}
]
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ArchyHCL — Archipelago Hardware Compatibility List</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>ArchyHCL</h1>
<p>Community-reported hardware compatibility for <a href="https://source.archipelago-foundation.org/lfg2025/archy" target="_blank" rel="noopener">Archipelago</a>. Every row here is a real install someone tested and reported — see it on GitHub/Gitea as a plain data file, no login required to browse.</p>
<p class="cta">
Tested Archipelago on your own hardware? <a href="https://source.archipelago-foundation.org/lfg2025/ArchyHCL/issues/new?template=hardware-report.yml" target="_blank" rel="noopener">Report it</a> —
takes two minutes and helps the next person know what to expect before they buy or repurpose a machine.
</p>
</header>
<main>
<div class="controls">
<input id="search" type="search" placeholder="Search model, CPU, WiFi chip…" autocomplete="off">
<select id="status-filter">
<option value="">All statuses</option>
<option value="working">Working</option>
<option value="partial">Partial</option>
<option value="broken">Broken</option>
</select>
<select id="form-factor-filter">
<option value="">All form factors</option>
<option value="laptop">Laptop</option>
<option value="desktop">Desktop</option>
<option value="mini-pc">Mini PC</option>
<option value="sbc">SBC</option>
<option value="server">Server</option>
<option value="other">Other</option>
</select>
<span id="count" class="count"></span>
</div>
<table id="table">
<thead>
<tr>
<th data-sort="device_model">Device</th>
<th data-sort="form_factor">Type</th>
<th data-sort="cpu">CPU</th>
<th data-sort="ram_gb">RAM</th>
<th data-sort="storage">Storage</th>
<th data-sort="wifi_chip">WiFi chip</th>
<th data-sort="status">Status</th>
<th data-sort="archy_version">Archy ver.</th>
<th data-sort="tested_date">Tested</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
<p id="empty" class="empty" hidden>No reports match your filters.</p>
</main>
<footer>
<p>Data lives in <code>data/reports/*.yml</code>, one file per report — browse or fork the raw data <a href="https://source.archipelago-foundation.org/lfg2025/ArchyHCL/src/branch/main/data/reports" target="_blank" rel="noopener">here</a>. See <a href="https://source.archipelago-foundation.org/lfg2025/ArchyHCL/src/branch/main/CONTRIBUTING.md" target="_blank" rel="noopener">CONTRIBUTING.md</a> for the two ways to add a report.</p>
</footer>
<script src="app.js"></script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
:root {
--bg: #0a0e12;
--panel: #10161c;
--border: #223038;
--text: #d8e4e8;
--muted: #7f97a0;
--accent: #00fff2;
--accent-dim: #00b3a8;
--working: #33d17a;
--partial: #f5b23c;
--broken: #f0506e;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: ui-monospace, "SF Mono", Consolas, monospace;
line-height: 1.5;
}
header, main, footer {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem;
}
h1 {
color: var(--accent);
letter-spacing: 0.08em;
margin-bottom: 0.25rem;
}
a { color: var(--accent-dim); }
a:hover { color: var(--accent); }
header p { color: var(--muted); max-width: 70ch; }
header .cta { color: var(--text); }
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
#search {
flex: 1 1 260px;
min-width: 200px;
}
input, select {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.7rem;
border-radius: 4px;
font-family: inherit;
font-size: 0.9rem;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent-dim);
}
.count { color: var(--muted); font-size: 0.85rem; margin-left: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
thead th {
text-align: left;
padding: 0.6rem 0.7rem;
border-bottom: 2px solid var(--border);
color: var(--muted);
cursor: pointer;
user-select: none;
white-space: nowrap;
}
thead th:hover { color: var(--accent); }
tbody tr {
border-bottom: 1px solid var(--border);
}
tbody tr:hover { background: var(--panel); }
tbody tr.expandable { cursor: pointer; }
td {
padding: 0.6rem 0.7rem;
vertical-align: top;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 3px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge.working { background: rgba(51, 209, 122, 0.15); color: var(--working); }
.badge.partial { background: rgba(245, 178, 60, 0.15); color: var(--partial); }
.badge.broken { background: rgba(240, 80, 110, 0.15); color: var(--broken); }
.detail-row td {
background: var(--panel);
color: var(--muted);
white-space: pre-wrap;
font-size: 0.82rem;
padding: 0.8rem 1.2rem;
}
.detail-row strong { color: var(--text); }
.empty { color: var(--muted); text-align: center; padding: 2rem; }
footer {
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid var(--border);
margin-top: 2rem;
}
footer code {
background: var(--panel);
padding: 0.1rem 0.35rem;
border-radius: 3px;
}