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)}