From b09f83f84018cf7b385d9b7db6c35e2628e16131 Mon Sep 17 00:00:00 2001 From: 0xMarcio Date: Fri, 4 Sep 2026 01:49:25 +0200 Subject: [PATCH] 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. --- docs/index.html | 25 +++---- docs/logic.js | 104 ++++++++++++-------------- docs/style.css | 162 ++++++++--------------------------------- scripts/build_pages.py | 22 ++++-- 4 files changed, 101 insertions(+), 212 deletions(-) diff --git a/docs/index.html b/docs/index.html index 77154307c1f..1013fbf6397 100644 --- a/docs/index.html +++ b/docs/index.html @@ -82,23 +82,16 @@
- FILTER -
- - +
+ SEVERITY +
- -
- - + + + +
+ SOURCE +
diff --git a/docs/logic.js b/docs/logic.js index 5ef8efaa3eb..d8b9e03684b 100644 --- a/docs/logic.js +++ b/docs/logic.js @@ -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]) => - `` + el.filterChips[kind].innerHTML = options.map(([value, label]) => + `` ).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); diff --git a/docs/style.css b/docs/style.css index a2a3abf5316..88b8386855a 100644 --- a/docs/style.css +++ b/docs/style.css @@ -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; } diff --git a/scripts/build_pages.py b/scripts/build_pages.py index aed7a44df28..07ae32e0802 100644 --- a/scripts/build_pages.py +++ b/scripts/build_pages.py @@ -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"""
{BRAND} -
""" + +enter searches the index + +
""" def footer(tail: str = 'Back to the index') -> str: @@ -398,7 +406,7 @@ def page(entry: dict, data: dict) -> str: -{header()} +{header(len(data["cves"]))}

{cid}

{''.join(chips)}