Skip to content

Commit 50357b3

Browse files
knhn1004claude
andcommitted
feat(site): dark/auto/light theme toggle, sortable + paginated rule list, RWD
- Theme: tri-state toggle (Light/Auto/Dark) in the header. Persisted in localStorage; applied synchronously before paint via an inline boot script so the page never flashes the wrong palette. prefers-color-scheme drives the Auto path; explicit Light/Dark wins via [data-theme] on <html>. Card hover uses --surface-2 so dark mode no longer flashes a near-white card on the dark background. - Listings: per-page selector (5 / 10 / 25 / All) plus a sort dropdown (severity, name, id). Counts show "1–5 of N" so users know there's more behind the page break. - Pagination: compact numeric paginator with prev/next, ellipses for long ranges, and auto-scroll-into-view on page change. - RWD: header pills/nav reflow at 720/460, container goes edge-to-edge under 1100, hero typography scales down, the rule card collapses to a single column under 720, and the footer stacks vertically. Mobile keeps GitHub link visible by hiding the in-page anchors first. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a5d588f commit 50357b3

3 files changed

Lines changed: 386 additions & 34 deletions

File tree

site/app.js

Lines changed: 144 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,75 @@
1-
// Vanilla JS — fetch the static index, render rule cards, wire search +
2-
// the copy-install button. No framework, no bundler. The page must
3-
// render correctly on plain GitHub Pages with no build step.
1+
// Vanilla JS — fetch the static index, render rule cards, wire search,
2+
// theme toggle, sort, and pagination. No framework, no bundler.
43
//
54
// Rendering uses createElement + textContent throughout so user-supplied
65
// rule data (names, descriptions, tags) never reaches an HTML parser.
76
// Treat every field on a rule as untrusted — the registry is a public
87
// PR target.
98

10-
// The CLI auto-registers `openagentlock-rules` as the upstream id, so the
11-
// install command can stay short — no need to namespace `<registry>:<id>`
12-
// for the default catalog.
139
const INSTALL_CMD = (id) => `agentlock rules install ${id}`;
10+
const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
11+
const THEME_KEY = "oal-rules-theme";
1412

1513
const $q = document.getElementById("q");
1614
const $results = document.getElementById("results");
15+
const $pagination = document.getElementById("pagination");
1716
const $generated = document.getElementById("generated-at");
1817
const $count = document.getElementById("count");
1918
const $metaCount = document.getElementById("meta-count");
19+
const $sort = document.getElementById("sort");
20+
const $pageSize = document.getElementById("page-size");
21+
const $themeToggle = document.getElementById("theme-toggle");
2022
const $filters = Array.from(document.querySelectorAll('.filters input[type="checkbox"]'));
2123

2224
let RULES = [];
25+
let page = 1;
26+
27+
// ---------- theme ----------
28+
29+
function readTheme() {
30+
try {
31+
return localStorage.getItem(THEME_KEY) ?? "system";
32+
} catch {
33+
return "system";
34+
}
35+
}
36+
function applyTheme(theme) {
37+
if (theme === "light" || theme === "dark") {
38+
document.documentElement.setAttribute("data-theme", theme);
39+
} else {
40+
document.documentElement.removeAttribute("data-theme");
41+
}
42+
if ($themeToggle) {
43+
for (const btn of $themeToggle.querySelectorAll("button[data-theme]")) {
44+
btn.setAttribute("aria-pressed", btn.dataset.theme === theme ? "true" : "false");
45+
}
46+
}
47+
}
48+
function setTheme(theme) {
49+
try {
50+
if (theme === "system") localStorage.removeItem(THEME_KEY);
51+
else localStorage.setItem(THEME_KEY, theme);
52+
} catch { /* ignore — private mode etc. */ }
53+
applyTheme(theme);
54+
}
55+
applyTheme(readTheme());
56+
if ($themeToggle) {
57+
$themeToggle.addEventListener("click", (e) => {
58+
const target = e.target;
59+
if (target instanceof HTMLButtonElement && target.dataset.theme) {
60+
setTheme(target.dataset.theme);
61+
}
62+
});
63+
}
64+
65+
// ---------- helpers ----------
2366

2467
function severityClass(sev) {
25-
return ["critical", "high", "medium", "low", "info"].includes(sev)
26-
? sev
27-
: "info";
68+
return ["critical", "high", "medium", "low", "info"].includes(sev) ? sev : "info";
2869
}
2970

3071
function activeSeverities() {
31-
return new Set(
32-
$filters.filter((c) => c.checked).map((c) => c.dataset.severity),
33-
);
72+
return new Set($filters.filter((c) => c.checked).map((c) => c.dataset.severity));
3473
}
3574

3675
function matches(rule, query, sevSet) {
@@ -46,6 +85,18 @@ function matches(rule, query, sevSet) {
4685
);
4786
}
4887

88+
function sortBy(sortKey) {
89+
return (a, b) => {
90+
if (sortKey === "severity") {
91+
const da = SEVERITY_ORDER[a.severity] ?? 99;
92+
const db = SEVERITY_ORDER[b.severity] ?? 99;
93+
if (da !== db) return da - db;
94+
}
95+
if (sortKey === "name") return a.name.localeCompare(b.name);
96+
return a.id.localeCompare(b.id);
97+
};
98+
}
99+
49100
function el(tag, attrs, children) {
50101
const node = document.createElement(tag);
51102
if (attrs) {
@@ -78,10 +129,7 @@ function ruleCard(r) {
78129
meta.appendChild(el("span", null, [`action: `, el("code", null, r.action)]));
79130
if (r.compatible_agentlock) {
80131
meta.appendChild(
81-
el("span", null, [
82-
"agentlock ",
83-
el("code", null, r.compatible_agentlock),
84-
]),
132+
el("span", null, ["agentlock ", el("code", null, r.compatible_agentlock)]),
85133
);
86134
}
87135
for (const t of r.tags || []) {
@@ -120,29 +168,93 @@ function ruleCard(r) {
120168
return el("article", { class: "rule" }, [left, right]);
121169
}
122170

171+
// ---------- pagination ----------
172+
173+
function pageSize() {
174+
const v = $pageSize?.value ?? "10";
175+
return v === "all" ? Infinity : Math.max(1, parseInt(v, 10));
176+
}
177+
178+
function renderPagination(totalPages) {
179+
if (!$pagination) return;
180+
$pagination.replaceChildren();
181+
if (totalPages <= 1) return;
182+
183+
const prev = el("button", { type: "button" }, "‹ Prev");
184+
prev.disabled = page <= 1;
185+
prev.addEventListener("click", () => goTo(page - 1));
186+
$pagination.appendChild(prev);
187+
188+
// Compact numeric paginator: 1 ... (page-1) page (page+1) ... last.
189+
const slots = new Set([1, totalPages, page, page - 1, page + 1]);
190+
const ordered = [...slots].filter((n) => n >= 1 && n <= totalPages).sort((a, b) => a - b);
191+
192+
let last = 0;
193+
for (const n of ordered) {
194+
if (last && n - last > 1) {
195+
$pagination.appendChild(el("span", { class: "ellipsis" }, "…"));
196+
}
197+
const btn = el("button", { type: "button" }, String(n));
198+
if (n === page) btn.setAttribute("aria-current", "page");
199+
btn.addEventListener("click", () => goTo(n));
200+
$pagination.appendChild(btn);
201+
last = n;
202+
}
203+
204+
const next = el("button", { type: "button" }, "Next ›");
205+
next.disabled = page >= totalPages;
206+
next.addEventListener("click", () => goTo(page + 1));
207+
$pagination.appendChild(next);
208+
}
209+
210+
function goTo(n) {
211+
page = Math.max(1, n);
212+
render();
213+
// Scroll the result list into view so paging on mobile doesn't strand
214+
// the user at the bottom of the previous page.
215+
document.getElementById("results")?.scrollIntoView({ behavior: "smooth", block: "start" });
216+
}
217+
218+
// ---------- main render ----------
219+
123220
function render() {
124221
const q = $q.value.trim();
125222
const sevSet = activeSeverities();
126-
const visible = RULES.filter((r) => matches(r, q, sevSet));
223+
const sortKey = $sort?.value ?? "severity";
224+
225+
const filtered = RULES.filter((r) => matches(r, q, sevSet)).sort(sortBy(sortKey));
226+
const total = filtered.length;
227+
const sz = pageSize();
228+
const totalPages = sz === Infinity ? 1 : Math.max(1, Math.ceil(total / sz));
229+
if (page > totalPages) page = totalPages;
230+
231+
const start = (page - 1) * (sz === Infinity ? 0 : sz);
232+
const pageRules = sz === Infinity ? filtered : filtered.slice(start, start + sz);
127233

128234
$results.replaceChildren();
129235
if ($count) {
130-
const n = visible.length;
131-
$count.textContent = n === RULES.length ? `${n} rule${n === 1 ? "" : "s"}` : `${n} of ${RULES.length}`;
236+
if (total === 0) {
237+
$count.textContent = "0 rules";
238+
} else if (sz === Infinity || total <= sz) {
239+
$count.textContent = `${total} rule${total === 1 ? "" : "s"}`;
240+
} else {
241+
$count.textContent = `${start + 1}${Math.min(start + sz, total)} of ${total}`;
242+
}
132243
}
133244

134-
if (visible.length === 0) {
245+
if (pageRules.length === 0) {
135246
$results.appendChild(
136247
el("div", { class: "empty" }, "No rules match. Try a different query."),
137248
);
138-
return;
249+
} else {
250+
for (const r of pageRules) $results.appendChild(ruleCard(r));
139251
}
140252

141-
for (const r of visible) {
142-
$results.appendChild(ruleCard(r));
143-
}
253+
renderPagination(totalPages);
144254
}
145255

256+
// ---------- bootstrap ----------
257+
146258
async function main() {
147259
try {
148260
const res = await fetch("data/index.json", { cache: "no-cache" });
@@ -167,8 +279,14 @@ async function main() {
167279
}
168280

169281
render();
170-
$q.addEventListener("input", render);
171-
for (const c of $filters) c.addEventListener("change", render);
282+
const onChange = () => {
283+
page = 1;
284+
render();
285+
};
286+
$q.addEventListener("input", onChange);
287+
for (const c of $filters) c.addEventListener("change", onChange);
288+
$sort?.addEventListener("change", onChange);
289+
$pageSize?.addEventListener("change", onChange);
172290
}
173291

174292
main();

site/index.html

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515
rel="stylesheet"
1616
/>
1717
<link rel="stylesheet" href="style.css" />
18+
<script>
19+
// Apply the persisted theme synchronously before paint so the page
20+
// never flashes the wrong color when reloaded with a stored choice.
21+
(function () {
22+
try {
23+
var t = localStorage.getItem("oal-rules-theme");
24+
if (t === "light" || t === "dark") {
25+
document.documentElement.setAttribute("data-theme", t);
26+
}
27+
} catch (_) { /* localStorage may be denied */ }
28+
})();
29+
</script>
1830
</head>
1931
<body>
2032
<header>
@@ -28,6 +40,16 @@
2840
<a href="#self-host">Self-host</a>
2941
<a href="#contribute">Contribute</a>
3042
<a href="https://github.com/openagentlock/rules" rel="noopener">GitHub</a>
43+
<div
44+
class="theme-toggle"
45+
role="group"
46+
aria-label="Color theme"
47+
id="theme-toggle"
48+
>
49+
<button data-theme="light" aria-pressed="false" title="Light mode">Light</button>
50+
<button data-theme="system" aria-pressed="true" title="Match system">Auto</button>
51+
<button data-theme="dark" aria-pressed="false" title="Dark mode">Dark</button>
52+
</div>
3153
</nav>
3254
</div>
3355
</header>
@@ -73,9 +95,29 @@ <h1>Block bad agent behavior, <em>one rule at a time</em>.</h1>
7395
<section class="results-section container">
7496
<div class="results-meta">
7597
<span>Latest from <a href="https://github.com/openagentlock/rules">openagentlock/rules</a></span>
76-
<span class="count" id="count"></span>
98+
<div class="results-controls">
99+
<label
100+
>Sort
101+
<select id="sort">
102+
<option value="severity">Severity</option>
103+
<option value="name">Name</option>
104+
<option value="id">ID</option>
105+
</select>
106+
</label>
107+
<label
108+
>Per page
109+
<select id="page-size">
110+
<option value="5">5</option>
111+
<option value="10" selected>10</option>
112+
<option value="25">25</option>
113+
<option value="all">All</option>
114+
</select>
115+
</label>
116+
<span class="count" id="count"></span>
117+
</div>
77118
</div>
78119
<div id="results"></div>
120+
<nav class="pagination" id="pagination" aria-label="Rule list pagination"></nav>
79121
</section>
80122

81123
<section id="using" class="explainer">

0 commit comments

Comments
 (0)