Put every filter on the page and give CVE pages the homepage search

The two filter menus hid their state behind ALL and took two clicks per
value. Each severity and source is now its own chip in the row, always
visible: the first click narrows to that value, further clicks widen the
set, and switching the last lit chip off clears. Active chips use the
colours the result rows already use for the same words, and the
selection rides in the URL as sev= and src=, so a narrowed view can be
linked like a query.

Standalone CVE pages carried a smaller search box beside the wordmark
that read as another site's. They now open with the homepage bar itself,
same height, type and placeholder, under the wordmark with the index
count on the right. Enter still submits to /?q= because the pages are
static, and the form has a real submit button for screen readers.
This commit is contained in:
0xMarcio committed 2026-09-04 01:49:32 +02:00
1 parent a7465a3b72
commit b09f83f840
4 files changed
+101 -212

No files matched your search

+9 -16
View File
@@ -82,23 +82,16 @@
<span class="search-status" data-status aria-live="polite"></span>
</form>
<div class="filters" data-filters role="group" aria-label="CVE filters">
<span class="filter-caption">FILTER</span>
<div class="filter-menu" data-filter-menu="severity">
<button type="button" class="filter-trigger" data-filter-trigger="severity"
aria-haspopup="true" aria-expanded="false" aria-controls="severity-options" disabled>
<span>SEVERITY</span><strong data-filter-summary="severity">ALL</strong>
</button>
<div class="filter-popover" id="severity-options" data-filter-options="severity"
role="group" aria-label="Severities" hidden></div>
<div class="filter-group" role="group" aria-label="Severity">
<span class="filter-caption">SEVERITY</span>
<div class="filter-chips" data-filter-chips="severity"></div>
</div>
<button type="button" class="filter-toggle" data-filter-kev aria-pressed="false" disabled>KEV</button>
<div class="filter-menu" data-filter-menu="source">
<button type="button" class="filter-trigger" data-filter-trigger="source"
aria-haspopup="true" aria-expanded="false" aria-controls="source-options" disabled>
<span>SOURCE</span><strong data-filter-summary="source">ALL</strong>
</button>
<div class="filter-popover" id="source-options" data-filter-options="source"
role="group" aria-label="Sources" hidden></div>
<span class="filter-sep" aria-hidden="true"></span>
<button type="button" class="filter-chip is-kev" data-filter-kev aria-pressed="false" disabled>KEV</button>
<span class="filter-sep" aria-hidden="true"></span>
<div class="filter-group" role="group" aria-label="Source">
<span class="filter-caption">SOURCE</span>
<div class="filter-chips" data-filter-chips="source"></div>
</div>
<button type="button" class="filter-clear" data-clear-filters hidden>CLEAR</button>
</div>
+45 -59
View File
@@ -389,17 +389,9 @@ const el = {
statPocs: document.querySelector('[data-stat-pocs]'),
statKev: document.querySelector('[data-stat-kev]'),
refreshed: document.querySelector('[data-refreshed]'),
filterTriggers: {
severity: document.querySelector('[data-filter-trigger="severity"]'),
source: document.querySelector('[data-filter-trigger="source"]')
},
filterSummaries: {
severity: document.querySelector('[data-filter-summary="severity"]'),
source: document.querySelector('[data-filter-summary="source"]')
},
filterOptions: {
severity: document.querySelector('[data-filter-options="severity"]'),
source: document.querySelector('[data-filter-options="source"]')
filterChips: {
severity: document.querySelector('[data-filter-chips="severity"]'),
source: document.querySelector('[data-filter-chips="source"]')
},
kevOnly: document.querySelector('[data-filter-kev]'),
clearFilters: document.querySelector('[data-clear-filters]')
@@ -733,6 +725,12 @@ function readURLState() {
el.input.value = query;
}
if (params.get('kev') === '1') state.kevOnly = true;
const short = { severity: 'sev', source: 'src' };
for (const [kind, options] of Object.entries(FILTER_OPTIONS)) {
const wanted = (params.get(short[kind]) || '').toUpperCase().split(',')
.filter(value => options.some(([each]) => each === value));
if (wanted.length) state.filters[kind] = new Set(wanted);
}
}
// replaceState, not pushState: typing a query should not bury the back button
@@ -744,6 +742,12 @@ function syncURL() {
const params = new URLSearchParams();
if (state.query.length >= MIN_QUERY) params.set('q', state.query);
if (state.kevOnly) params.set('kev', '1');
const short = { severity: 'sev', source: 'src' };
for (const [kind, filter] of Object.entries(state.filters)) {
if (filter.size !== FILTER_OPTIONS[kind].length) {
params.set(short[kind], FILTER_OPTIONS[kind].map(([value]) => value).filter(value => filter.has(value)).join(','));
}
}
const search = params.toString();
const next = search ? `${location.pathname}?${search}` : location.pathname;
if (next !== location.pathname + location.search) history.replaceState(null, '', next);
@@ -790,40 +794,42 @@ el.input.addEventListener('input', () => {
document.querySelector('.search').addEventListener('submit', event => event.preventDefault());
// One chip per value, every one of them on the page. A full set is the idle
// state and draws nothing lit, so the first click narrows to that value; later
// clicks widen the set, and switching the last lit chip off is a clear.
function renderFilterOptions() {
for (const [kind, options] of Object.entries(FILTER_OPTIONS)) {
el.filterOptions[kind].innerHTML = options.map(([value, label]) =>
`<label class="filter-option">` +
`<input type="checkbox" data-filter-kind="${kind}" data-filter-value="${value}">` +
`<span class="filter-check" aria-hidden="true"></span>` +
`<span class="filter-option-label">${escapeHTML(label)}</span></label>`
el.filterChips[kind].innerHTML = options.map(([value, label]) =>
`<button type="button" class="filter-chip is-${escapeHTML(value.toLowerCase())}" ` +
`data-filter-kind="${kind}" data-filter-value="${value}" aria-pressed="false" disabled>` +
`${escapeHTML(label)}</button>`
).join('');
}
}
function closeFilterMenus(except) {
for (const kind of Object.keys(FILTER_OPTIONS)) {
if (kind === except) continue;
el.filterOptions[kind].hidden = true;
el.filterTriggers[kind].setAttribute('aria-expanded', 'false');
}
function enableFilter(kind) {
for (const chip of el.filterChips[kind].querySelectorAll('[data-filter-value]')) chip.disabled = false;
}
function filterTitle(kind) {
const selected = [...state.filters[kind]];
return selected.length === FILTER_OPTIONS[kind].length ? 'All values' : selected.join(', ');
function toggleFilter(kind, value) {
const filter = state.filters[kind];
if (filter.size === FILTER_OPTIONS[kind].length) {
filter.clear();
filter.add(value);
} else if (!filter.has(value)) {
filter.add(value);
} else {
filter.delete(value);
if (!filter.size) for (const [each] of FILTER_OPTIONS[kind]) filter.add(each);
}
}
function syncFilterControls() {
for (const kind of Object.keys(FILTER_OPTIONS)) {
const filter = state.filters[kind];
const summary = filter.size === FILTER_OPTIONS[kind].length
? 'ALL'
: filter.size === 1 ? [...filter][0] : `${filter.size} SELECTED`;
el.filterSummaries[kind].textContent = summary;
el.filterTriggers[kind].title = filterTitle(kind);
for (const checkbox of el.filterOptions[kind].querySelectorAll('[data-filter-value]')) {
checkbox.checked = filter.has(checkbox.dataset.filterValue);
const narrowed = filter.size !== FILTER_OPTIONS[kind].length;
for (const chip of el.filterChips[kind].querySelectorAll('[data-filter-value]')) {
chip.setAttribute('aria-pressed', String(narrowed && filter.has(chip.dataset.filterValue)));
}
}
el.kevOnly.setAttribute('aria-pressed', String(state.kevOnly));
@@ -831,29 +837,16 @@ function syncFilterControls() {
}
for (const kind of Object.keys(FILTER_OPTIONS)) {
el.filterTriggers[kind].addEventListener('click', () => {
const opening = el.filterOptions[kind].hidden;
closeFilterMenus(kind);
el.filterOptions[kind].hidden = !opening;
el.filterTriggers[kind].setAttribute('aria-expanded', String(opening));
});
el.filterOptions[kind].addEventListener('change', event => {
const checkbox = event.target.closest('[data-filter-value]');
if (!checkbox) return;
const filter = state.filters[kind];
const value = checkbox.dataset.filterValue;
if (checkbox.checked) filter.add(value);
else filter.delete(value);
el.filterChips[kind].addEventListener('click', event => {
const chip = event.target.closest('[data-filter-value]');
if (!chip || chip.disabled) return;
toggleFilter(kind, chip.dataset.filterValue);
state.shown = PAGE_SIZE;
syncFilterControls();
render();
});
}
document.addEventListener('click', event => {
if (!event.target.closest('.filter-menu')) closeFilterMenus();
});
el.kevOnly.addEventListener('click', () => {
state.kevOnly = !state.kevOnly;
state.shown = PAGE_SIZE;
@@ -880,13 +873,6 @@ syncFilterControls();
// field it just moved to, so the first letter is never lost.
document.addEventListener('keydown', event => {
if (event.ctrlKey || event.metaKey || event.altKey) return;
const openFilter = Object.keys(FILTER_OPTIONS).find(kind =>
el.filterTriggers[kind].getAttribute('aria-expanded') === 'true');
if (event.key === 'Escape' && openFilter) {
closeFilterMenus();
el.filterTriggers[openFilter].focus();
return;
}
if (document.activeElement === el.input) {
if (event.key === 'Escape') {
el.input.value = '';
@@ -980,13 +966,13 @@ async function loadJSON(url, options) {
loadJSON('/cve_metadata.json', { cache: 'no-cache' }).then(data => {
metadata = data || {};
el.filterTriggers.severity.disabled = false;
enableFilter('severity');
if (state.ready && (state.query || hasActiveFilters())) render();
}).catch(err => console.warn(err.message));
loadJSON('/nuclei.json', { cache: 'no-cache' }).then(data => {
ratings = data || {};
el.filterTriggers.severity.disabled = false;
enableFilter('severity');
if (state.ready && (state.query || hasActiveFilters())) render();
}).catch(err => console.warn(err.message));
@@ -1004,7 +990,7 @@ async function loadJSON(url, options) {
try {
dataset = prepareDataset(await loadJSON('/CVE_list.json', { cache: 'no-cache' }));
state.ready = true;
el.filterTriggers.source.disabled = false;
enableFilter('source');
render();
} catch (err) {
console.warn(err.message);
+32 -130
View File
@@ -33,6 +33,7 @@ a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
:focus-visible { outline: 2px solid var(--accent-emphasis); outline-offset: 2px; border-radius: 2px; }
[hidden] { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.container { max-width: 1240px; width: 100%; margin: 0 auto; padding: 0 32px; }
@@ -140,110 +141,24 @@ a:hover { color: var(--accent-hover); }
.filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
gap: 6px 8px;
min-height: 35px;
margin-top: 8px;
font: 500 10px/1 var(--mono);
letter-spacing: .07em;
}
.filter-caption { margin-right: 2px; color: var(--neutral); }
.filter-menu { position: relative; }
.filter-trigger {
display: flex;
align-items: center;
gap: 9px;
.filter-group { display: flex; align-items: center; gap: 6px; }
.filter-chips { display: contents; }
.filter-sep { width: 1px; height: 18px; margin: 0 2px; background: var(--border); }
/* One chip per value, all of them visible. Nothing lit means everything
passes; the first click narrows to that value, more clicks widen the set,
and switching the last one off is the same as clearing. Active chips take
the colours the result rows already use for the same words. */
.filter-chip, .filter-clear {
height: 31px;
min-width: 154px;
padding: 0 10px;
color: var(--neutral);
border: 1px solid var(--border-muted);
border-radius: 4px;
background: var(--canvas-subtle);
font: 500 10px/1 var(--mono);
letter-spacing: .07em;
cursor: pointer;
}
.filter-menu[data-filter-menu="source"] .filter-trigger { min-width: 174px; }
.filter-trigger strong {
margin-left: auto;
color: var(--fg-subtle);
font: inherit;
}
.filter-trigger::after {
width: 6px;
height: 6px;
margin: -3px 1px 0 4px;
border-right: 1px solid var(--fg-muted);
border-bottom: 1px solid var(--fg-muted);
transform: rotate(45deg);
content: '';
}
.filter-trigger:hover, .filter-trigger[aria-expanded="true"] {
color: var(--fg-muted);
border-color: var(--fg-muted);
}
.filter-trigger[aria-expanded="true"]::after { margin-top: 3px; transform: rotate(225deg); }
.filter-popover {
position: absolute;
z-index: 20;
top: calc(100% + 6px);
left: 0;
width: 210px;
padding: 6px;
color: var(--fg-muted);
border: 1px solid var(--border);
border-radius: 5px;
background: var(--canvas-subtle);
box-shadow: 0 12px 28px rgba(1, 4, 9, .55);
}
.filter-option {
display: flex;
align-items: center;
gap: 9px;
min-height: 31px;
padding: 0 7px;
border-radius: 3px;
cursor: pointer;
}
.filter-option:hover { background: var(--canvas); }
.filter-option input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.filter-check {
position: relative;
width: 13px;
height: 13px;
flex: none;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--canvas);
}
.filter-option input:focus-visible + .filter-check {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.filter-option input:checked + .filter-check {
border-color: var(--accent);
background: var(--accent);
}
.filter-option input:checked + .filter-check::after {
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 7px;
border-right: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(45deg);
content: '';
}
.filter-option-label { color: var(--fg-subtle); }
.filter-toggle, .filter-clear {
height: 31px;
padding: 0 10px;
padding: 0 9px;
color: var(--fg-muted);
border: 1px solid var(--border-muted);
border-radius: 4px;
@@ -252,12 +167,16 @@ a:hover { color: var(--accent-hover); }
letter-spacing: .07em;
cursor: pointer;
}
.filter-toggle:hover, .filter-clear:hover { color: var(--fg); border-color: var(--fg-muted); }
.filter-toggle[aria-pressed="true"] {
color: var(--danger);
border-color: rgba(248, 81, 73, .45);
background: rgba(248, 81, 73, .1);
}
.filter-chip:hover, .filter-clear:hover { color: var(--fg); border-color: var(--fg-muted); }
.filter-chip[aria-pressed="true"] { color: #a5d6ff; border-color: rgba(56, 139, 253, .4); background: rgba(56, 139, 253, .12); }
.filter-chip.is-critical[aria-pressed="true"] { color: #ff7b72; border-color: rgba(255, 123, 114, .45); background: rgba(255, 123, 114, .1); }
.filter-chip.is-high[aria-pressed="true"] { color: #f0883e; border-color: rgba(240, 136, 62, .45); background: rgba(240, 136, 62, .1); }
.filter-chip.is-medium[aria-pressed="true"] { color: var(--attention); border-color: rgba(227, 179, 65, .4); background: rgba(227, 179, 65, .1); }
.filter-chip.is-low[aria-pressed="true"] { color: var(--fg-subtle); border-color: var(--fg-muted); background: rgba(125, 133, 144, .12); }
.filter-chip.is-kev[aria-pressed="true"] { color: var(--danger); border-color: rgba(248, 81, 73, .45); background: rgba(248, 81, 73, .1); }
.filter-chip.is-msf[aria-pressed="true"] { color: #d2a8ff; border-color: rgba(163, 113, 247, .4); background: rgba(163, 113, 247, .12); }
.filter-chip.is-edb[aria-pressed="true"] { color: #7ee787; border-color: rgba(63, 185, 80, .4); background: rgba(63, 185, 80, .12); }
.filter-chip.is-vulhub[aria-pressed="true"] { color: #ffa657; border-color: rgba(210, 153, 34, .4); background: rgba(210, 153, 34, .12); }
.filter-clear { margin-left: auto; color: var(--accent); background: transparent; }
.filters :disabled { cursor: wait; opacity: .45; }
@@ -542,40 +461,24 @@ a.chip:hover { color: var(--fg); border-color: var(--fg-muted); }
.trending .col-head, .trend-row { grid-template-columns: 66px minmax(0, 1fr); }
.col-updated, .trend-age { display: none; }
.search-status { display: none; }
.filters { align-items: stretch; flex-wrap: wrap; }
.filter-caption { display: none; }
.filter-group { flex-wrap: wrap; }
.filter-sep { display: none; }
.filter-clear { margin-left: 0; }
.filter-popover { max-width: calc(100vw - 32px); }
.filter-menu[data-filter-menu="source"] .filter-popover { right: 0; left: auto; }
}
/* ---- per-CVE page ------------------------------------------------------- */
.cve-bar {
display: flex; align-items: center; gap: 24px;
padding-top: 20px; padding-bottom: 20px;
border-bottom: 1px solid var(--border-muted);
/* The homepage bar itself, at its own size, under the wordmark. The page used
to carry a smaller box beside the name, which read as another site's search. */
.cve-top {
display: flex; align-items: baseline; justify-content: space-between; gap: 24px;
padding-top: 24px;
}
.cve-home { font: 700 19px/1 var(--mono); color: var(--fg); letter-spacing: .04em; }
.cve-home:hover { color: var(--accent); }
/* The same bar the homepage draws, at header height. It was a bare input with
its own border and no icon, which read as a different site's search box. */
.cve-search {
flex: 1; max-width: 520px;
display: flex; align-items: center; gap: 11px;
height: 40px; padding: 0 14px;
background: var(--canvas-subtle);
border: 1px solid var(--border);
border-radius: 6px;
}
.cve-search:focus-within { border-color: var(--accent-emphasis); }
.cve-search svg { flex: none; display: block; }
.cve-search input {
flex: 1; min-width: 0;
background: transparent; border: 0; outline: 0; padding: 0;
color: var(--fg); font: 400 13px/1 var(--mono);
}
.cve-search input::placeholder { color: var(--neutral); }
.cve-count { font: 400 11.5px/1 var(--mono); color: var(--fg-muted); white-space: nowrap; }
.cve-count:hover { color: var(--accent); }
.cve-top + .search-bar { padding-top: 16px; }
.cve-page { flex: 1; padding-top: 32px; padding-bottom: 56px; max-width: 900px; }
.cve-page h1 { margin: 0 0 14px; font: 700 32px/1.1 var(--mono); color: var(--fg); }
@@ -613,7 +516,6 @@ a.chip:hover { color: var(--fg); border-color: var(--fg-muted); }
}
@media (max-width: 700px) {
.cve-bar { flex-wrap: wrap; gap: 14px; }
.cve-page h1 { font-size: 25px; }
.cve-facts { grid-template-columns: 1fr; gap: 3px 0; }
.cve-facts dd { margin-bottom: 10px; }
+15 -7
View File
@@ -239,16 +239,24 @@ def link_rows(urls: list, repo_meta: dict, *, trusted: bool = False) -> str:
return "".join(rows)
def header() -> str:
"""The bar every generated page opens with, so none of them can drift."""
return f"""<header class="container cve-bar"><a class="cve-home" href="/">{BRAND}</a>
<form class="cve-search" role="search" action="/" method="get">
def header(count: int | None = None) -> str:
"""The bar every generated page opens with, so none of them can drift.
It is the homepage's own search bar, same height, type and placeholder,
under the wordmark. The pages are static, so enter submits to /?q= and the
homepage picks the query up from the URL.
"""
tally = f'<a class="cve-count" href="/">{count:,} CVEs with PoCs</a>' if count else ""
return f"""<header class="container cve-top"><a class="cve-home" href="/">{BRAND}</a>{tally}</header>
<div class="container search-bar"><form class="search" role="search" action="/" method="get" autocomplete="off">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<circle cx="7.6" cy="7.6" r="5.1" stroke="#8b949e" stroke-width="1.7"/>
<path d="M11.5 11.5 L15.6 15.6" stroke="#8b949e" stroke-width="1.7" stroke-linecap="round"/>
</svg>
<input type="text" name="q" spellcheck="false" placeholder="Search CVE, vendor, product or keyword" aria-label="Search CVE, vendor, product or keyword"/>
</form></header>"""
<input type="text" name="q" spellcheck="false" placeholder="Search CVE, vendor, product or keyword &middot; exclude with -windows" aria-label="Search CVE, vendor, product or keyword"/>
<span class="search-status">enter searches the index</span>
<button type="submit" class="sr-only">Search</button>
</form></div>"""
def footer(tail: str = '<a href="/">Back to the index</a>') -> str:
@@ -398,7 +406,7 @@ def page(entry: dict, data: dict) -> str:
<script type="application/ld+json">{ld}</script>
</head>
<body>
{header()}
{header(len(data["cves"]))}
<main class="container cve-page">
<h1>{cid}</h1>
<div class="chips">{''.join(chips)}</div>