From 84713ce0c34126442be2dfc4d0a0a1927c440202 Mon Sep 17 00:00:00 2001 From: Stanzin Date: Wed, 17 Jun 2026 06:47:09 -0500 Subject: [PATCH] feat(report): truthful labels + security-product redesign for layer modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the scan report both factually correct and visually credible, focused on the layer detail modals (the report's signature interactive surface) and the verdict labels around them. No detection logic, scoring weights, or decision.resolve() precedence changed. Correctness / labels - LayerModal: severity-first triage — Issues (loud) -> Not analyzed (neutral) -> Cleared (quiet). Status wording is now precise: "High risk" (>=0.7) / "Issue" (>=0.4) only with evidence; "Not analyzed" when coverage is absent (never "Clear"); "Clear" only when a check ran and found nothing. Triage is a pure, tested helper (triageFactors). - LayerModal header restates the authoritative layer verdict (Safe / Needs review / Not safe) and the top accent rail reflects the verdict band (data-band), so BLOCK/Not-safe never reads like Review. - Sidebar tile: issue count is a real signal (severity-tinted, "N issues", precise wording) instead of gray "N findings" metadata. - Aggregate counter wording unified to "issue(s)". - normalizeScanResult: fix FACTOR_HUMAN_TITLE keys that never matched the backend factor names (Entropy/ManifestPosture/WebStoreTrust/ MaintenanceHealth -> Obfuscation/Manifest/Webstore/Maintenance, + add governance factors) so Key Findings titles humanize and match the modal. Design - Issues use a colored rail + tint + glyph + solid status pill; high risk (red) vs moderate (amber) read differently. Cleared rows are a quiet, dense, low-contrast list (no green pill noise). Not analyzed is dashed + neutral + HelpCircle + an explicit "treat as unknown, not safe" note. - Stronger type hierarchy and spacing rhythm; sharper modal radius (14px). - Sidebar tile tokenized to --risk-*/--theme-* (was hardcoded rgba), removed the templated pulse animation, added a keyboard focus ring. Accessibility / responsive - Status pills use dark ink on solid accent (white-on-amber failed WCAG AA). - Mobile header verdict pill left-aligns when wrapped; cleared names wrap instead of silently truncating; status conveyed by text+icon, not color. Tests - LayerModal.test.jsx covers status mapping (Issue/High risk/Not analyzed/ Clear) and triage ordering (issues severe-first; not-analyzed never in cleared). Verified by executing the real bundled functions (11/11) since vitest can't run in this sandbox; full `npm run build` passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/report/LayerModal.jsx | 210 ++++++--- .../src/components/report/LayerModal.scss | 433 ++++++++---------- .../src/components/report/LayerModal.test.jsx | 85 +++- .../components/report/ResultsSidebarTile.jsx | 5 +- .../components/report/ResultsSidebarTile.scss | 78 ++-- .../src/pages/scanner/ScanResultsPageV2.jsx | 2 +- frontend/src/utils/normalizeScanResult.ts | 28 +- 7 files changed, 446 insertions(+), 395 deletions(-) diff --git a/frontend/src/components/report/LayerModal.jsx b/frontend/src/components/report/LayerModal.jsx index 8d83513b..3418f27c 100644 --- a/frontend/src/components/report/LayerModal.jsx +++ b/frontend/src/components/report/LayerModal.jsx @@ -5,7 +5,7 @@ import { DialogHeader, DialogTitle, } from '../ui/dialog'; -import { CheckCircle, AlertCircle, Info } from 'lucide-react'; +import { AlertTriangle, Check, HelpCircle, Info } from 'lucide-react'; import './LayerModal.scss'; const FACTOR_HUMAN = { @@ -25,28 +25,20 @@ const FACTOR_HUMAN = { DisclosureAlignment: { label: 'Disclosure Accuracy', category: 'policy', desc: 'Validates privacy policy against actual data collection' }, }; -const CATEGORY_LABELS = { - code: 'Code Checks', - threat: 'Threat Detection', - trust: 'Trust Signals', +// Short, sentence-case tags used as a secondary caption on flagged/uncovered rows. +const CATEGORY_TAG = { + code: 'Code', + threat: 'Threat', + trust: 'Trust', access: 'Permissions', - data: 'Data Handling', - policy: 'Policies', + data: 'Data', + policy: 'Policy', }; const LAYER_CONFIG = { - security: { - title: 'Security', - icon: '🛡️', - }, - privacy: { - title: 'Privacy', - icon: '🔒', - }, - governance: { - title: 'Governance', - icon: '📋', - }, + security: { title: 'Security', icon: '🛡️' }, + privacy: { title: 'Privacy', icon: '🔒' }, + governance: { title: 'Governance', icon: '📋' }, }; /** @@ -61,6 +53,13 @@ export function isNotAnalyzed(factor) { return false; } +/** + * Map a factor to a truthful presentation status: + * - issues: the check ran and found something material (severity >= 0.4). + * tone splits high (>= 0.7 -> bad/red) vs moderate (warn/amber). + * - unknown: the check could not run -> "Not analyzed" (never "Clear"). + * - clear: the check ran and found nothing material. + */ export function humanizeFactor(factor) { const info = FACTOR_HUMAN[factor.name] || { label: factor.name, @@ -68,38 +67,57 @@ export function humanizeFactor(factor) { desc: '', }; const severity = factor.severity ?? 0; - let status, statusType; + let status, statusType, tone; if (severity >= 0.4) { - status = 'Issue'; statusType = 'issues'; + tone = severity >= 0.7 ? 'bad' : 'warn'; + status = severity >= 0.7 ? 'High risk' : 'Issue'; } else if (isNotAnalyzed(factor)) { - status = 'Not analyzed'; statusType = 'unknown'; + tone = 'neutral'; + status = 'Not analyzed'; } else { - status = 'Clear'; statusType = 'clear'; + tone = 'good'; + status = 'Clear'; } - return { ...info, status, statusType, severity, raw: factor }; + return { ...info, status, statusType, tone, severity, raw: factor }; } -function groupByCategory(items) { - const groups = {}; - items.forEach(item => { - const cat = item.category || 'other'; - if (!groups[cat]) groups[cat] = []; - groups[cat].push(item); - }); - Object.values(groups).forEach(g => g.sort((a, b) => b.severity - a.severity)); - return Object.entries(groups) - .sort(([, a], [, b]) => Math.max(...b.map(x => x.severity)) - Math.max(...a.map(x => x.severity))); +/** + * Triage a layer's factors into severity tiers for display: + * issues (most severe first) -> not analyzed -> cleared (alphabetical). + * Keeping this pure makes the "issues first / not-analyzed distinct" ordering testable. + */ +export function triageFactors(factors = []) { + const humanised = (factors || []).map(humanizeFactor); + return { + all: humanised, + issues: humanised + .filter((i) => i.statusType === 'issues') + .sort((a, b) => b.severity - a.severity), + notAnalyzed: humanised.filter((i) => i.statusType === 'unknown'), + cleared: humanised + .filter((i) => i.statusType === 'clear') + .sort((a, b) => a.label.localeCompare(b.label)), + }; } function bandLabel(band) { switch (band) { case 'GOOD': return 'Safe'; - case 'WARN': return 'Needs Review'; - case 'BAD': return 'Not Safe'; - default: return ''; + case 'WARN': return 'Needs review'; + case 'BAD': return 'Not safe'; + default: return 'Not rated'; + } +} + +function bandToneClass(band) { + switch (band) { + case 'GOOD': return 'lm-verdict-good'; + case 'WARN': return 'lm-verdict-warn'; + case 'BAD': return 'lm-verdict-bad'; + default: return 'lm-verdict-na'; } } @@ -117,10 +135,37 @@ const InfoTooltip = ({ text }) => { ); }; +/** Prominent row for a flagged or uncovered check (issues + not-analyzed tiers). */ +const PrimaryCheckRow = ({ item, index }) => ( +
+ + + {item.statusType === 'issues' + ? + : } + + + + {item.label} + {item.desc && } + + {CATEGORY_TAG[item.category] && ( + {CATEGORY_TAG[item.category]} + )} + + {item.status} +
+); + const LayerModal = ({ open, onClose, layer, + // eslint-disable-next-line no-unused-vars score = null, band = 'NA', factors = [], @@ -130,18 +175,20 @@ const LayerModal = ({ gateResults = [], // eslint-disable-next-line no-unused-vars layerReasons = [], + // eslint-disable-next-line no-unused-vars layerDetails = null, // eslint-disable-next-line no-unused-vars onViewEvidence = null, }) => { const config = LAYER_CONFIG[layer] || LAYER_CONFIG.security; - const humanised = factors.map(humanizeFactor); - const grouped = groupByCategory(humanised); + // Severity-first triage: what's wrong, then what couldn't be checked, then what's fine. + const { all, issues, notAnalyzed, cleared } = triageFactors(factors); + const hasChecks = all.length > 0; return ( - +
@@ -149,46 +196,61 @@ const LayerModal = ({ {config.icon} {config.title}
+ {bandLabel(band)}
- {grouped.length > 0 && ( -
- {grouped.map(([cat, items], catIdx) => ( -
- {CATEGORY_LABELS[cat] || cat} -
- {items.map((item, idx) => ( -
-
- {item.label} - {item.desc && } -
- - {item.statusType === 'clear' ? ( - - ) : item.statusType === 'unknown' ? ( - - ) : ( - - )} - - {item.status} - - -
- ))} + {!hasChecks && ( +

No checks are available for this layer.

+ )} + + {issues.length > 0 && ( +
+
+ Issues found + {issues.length} +
+
+ {issues.map((item, idx) => ( + + ))} +
+
+ )} + + {notAnalyzed.length > 0 && ( +
+
+ Not analyzed + {notAnalyzed.length} +
+
+ {notAnalyzed.map((item, idx) => ( + + ))} +
+

Coverage unavailable — treat as unknown, not safe.

+
+ )} + + {cleared.length > 0 && ( +
+
+ Cleared + {cleared.length} +
+
+ {cleared.map((item, idx) => ( +
+ + {item.label} + {item.desc && }
-
- ))} -
+ ))} +
+ )}
diff --git a/frontend/src/components/report/LayerModal.scss b/frontend/src/components/report/LayerModal.scss index 5fe58fd3..e6087277 100644 --- a/frontend/src/components/report/LayerModal.scss +++ b/frontend/src/components/report/LayerModal.scss @@ -1,5 +1,6 @@ // =========================================================================== -// LayerModal – Compact, decision-focused layer detail dialog +// LayerModal — severity-triaged layer detail dialog +// Issues first (loud) → Not analyzed (neutral) → Cleared (quiet). // =========================================================================== :root { @@ -24,60 +25,50 @@ } // --------------------------------------------------------------------------- -// Container +// Container — accent rail reflects the authoritative verdict, not the layer. // --------------------------------------------------------------------------- .lm-content { + --lm-accent: var(--risk-neutral); + font-family: var(--report-font, var(--font-sans)); background: var(--theme-bg-card); border: 1px solid var(--theme-border); + border-top: 3px solid var(--lm-accent); color: var(--theme-text-primary); - max-width: 420px; + max-width: 460px; width: 92vw; - max-height: 80vh; + max-height: 82vh; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: var(--theme-border-subtle) transparent; box-shadow: var(--theme-shadow-dropdown); - border-radius: var(--radius-lg, 12px); + border-radius: 14px; padding: 0; animation: lmSlideIn var(--lm-transition-duration) var(--lm-ease) both; display: flex; flex-direction: column; gap: 0; - &[data-layer="security"] { - border-left: 3px solid var(--risk-warn, #f59e0b); - } - &[data-layer="privacy"] { - border-left: 3px solid var(--risk-warn, #f59e0b); - } - &[data-layer="governance"] { - border-left: 3px solid var(--risk-good, #10b981); - } + &[data-band="GOOD"] { --lm-accent: var(--risk-good); } + &[data-band="WARN"] { --lm-accent: var(--risk-warn); } + &[data-band="BAD"] { --lm-accent: var(--risk-bad); } } @keyframes lmSlideIn { - from { - opacity: 0; - transform: translate(-50%, -48%) scale(0.98); - } - to { - opacity: 1; - transform: translate(-50%, -50%) scale(1); - } + from { opacity: 0; transform: translate(-50%, -48%) scale(0.985); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } } // --------------------------------------------------------------------------- -// Header: compact identity bar — icon + title left, verdict right -// Override DialogHeader's default (flex-col, text-center) so content is row, left/right. +// Header — identity left, verdict right // --------------------------------------------------------------------------- .lm-header-wrap { display: flex; flex-direction: row; align-items: center; justify-content: center; - text-align: center; + text-align: left; gap: 0; margin: 0; padding: 0; @@ -89,19 +80,16 @@ margin: 0; padding: 0; border: none; - font-size: inherit; - font-weight: inherit; - line-height: inherit; + font: inherit; letter-spacing: inherit; - tracking-tight: none; } .lm-header-inner { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; width: 100%; - padding: 16px 20px 14px; + padding: 18px 20px 16px; padding-right: 48px; border-bottom: 1px solid var(--theme-border-subtle); gap: 12px; @@ -111,229 +99,236 @@ display: flex; align-items: center; gap: 10px; - flex-shrink: 0; + min-width: 0; } .lm-header-inner .lm-icon { - font-size: 20px; + font-size: 19px; line-height: 1; } .lm-header-inner .lm-title { - font-size: 1.05rem; - font-weight: 600; + font-size: 1.0625rem; + font-weight: 650; color: var(--theme-text-primary); letter-spacing: -0.01em; } +// Verdict pill — the layer's authoritative status, restated in the modal. .lm-verdict-pill { + flex-shrink: 0; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; - padding: 5px 12px; + letter-spacing: 0.06em; + padding: 5px 11px; border-radius: var(--radius-full, 9999px); white-space: nowrap; + border: 1px solid transparent; } +.lm-verdict-good { color: var(--risk-good); background: var(--risk-good-bg); border-color: var(--risk-good-border); } +.lm-verdict-warn { color: var(--risk-warn); background: var(--risk-warn-bg); border-color: var(--risk-warn-border); } +.lm-verdict-bad { color: var(--risk-bad); background: var(--risk-bad-bg); border-color: var(--risk-bad-border); } +.lm-verdict-na { color: var(--theme-text-muted); background: var(--theme-bg-secondary); border-color: var(--theme-border); } -.lm-verdict-good { - color: var(--risk-good); - background: var(--risk-good-bg); - border: 1px solid var(--risk-good-border); -} -.lm-verdict-warn { - color: var(--risk-warn); - background: var(--risk-warn-bg); - border: 1px solid var(--risk-warn-border); -} -.lm-verdict-bad { - color: var(--risk-bad); - background: var(--risk-bad-bg); - border: 1px solid var(--risk-bad-border); -} -.lm-verdict-na { - color: var(--theme-text-muted); - background: var(--theme-bg-secondary); - border: 1px solid var(--theme-border); -} - -// --------------------------------------------------------------------------- -// Body: no summary, checks only // --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Body: no summary, checks only +// Body — tiers stacked by severity // --------------------------------------------------------------------------- .lm-body { display: flex; flex-direction: column; - gap: 0; - padding: 14px 20px 20px; + gap: 18px; + padding: 16px 20px 20px; } -.lm-stats-row { +.lm-empty { + margin: 0; + padding: 8px 0; + font-size: 0.8125rem; + color: var(--theme-text-muted); +} + +.lm-tier { display: flex; - align-items: center; - gap: 16px; - padding-bottom: 12px; - margin-bottom: 12px; - border-bottom: 1px solid var(--theme-border-subtle); + flex-direction: column; + gap: 8px; } -.lm-stat { - font-size: 0.8125rem; - color: var(--theme-text-muted); - display: inline-flex; +.lm-tier-head { + display: flex; align-items: center; - gap: 4px; + gap: 8px; } -.lm-stat-num { +.lm-tier-title { + font-size: 0.6875rem; font-weight: 700; - color: var(--theme-text-primary); - font-variant-numeric: tabular-nums; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--theme-text-muted); } -.lm-stat-issues .lm-stat-num { - color: var(--risk-warn); +.lm-tier--issues .lm-tier-title { + color: var(--risk-bad); } -.lm-stat-clear { - color: var(--risk-good); - font-weight: 600; +.lm-tier-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + font-size: 0.6875rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--theme-text-secondary); + background: var(--theme-bg-tertiary); + border-radius: var(--radius-full, 9999px); + + // Accent text on a tint (AA-safe), matching the verdict-pill treatment. + &--issues { + color: var(--risk-bad); + background: var(--risk-bad-bg); + border: 1px solid var(--risk-bad-border); + } } -// --------------------------------------------------------------------------- -// Check groups: flat grid -// --------------------------------------------------------------------------- -.lm-checks { - display: flex; - flex-direction: column; - gap: 14px; +.lm-tier-note { + margin: 2px 0 0; + font-size: 0.75rem; + line-height: 1.4; + color: var(--theme-text-subtle); } -.lm-group { +.lm-rows { display: flex; flex-direction: column; - gap: 6px; - animation: lmFadeUp 0.3s var(--lm-ease) both; -} - -.lm-group-label { - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--theme-text-muted); - padding-left: 2px; + gap: 8px; } -.lm-group-items { - display: flex; - flex-wrap: wrap; - gap: 6px; -} +// --------------------------------------------------------------------------- +// Primary row — issues + not analyzed +// --------------------------------------------------------------------------- +.lm-check { + --row-accent: var(--risk-neutral); + --row-tint: var(--theme-bg-secondary); -// Check card: full width row — label + info left, status right -.lm-check-card { - display: flex; + position: relative; + display: grid; + grid-template-columns: auto 1fr auto; align-items: center; - justify-content: space-between; - width: 100%; gap: 10px; - padding: 7px 10px; - border-radius: var(--radius, 8px); - background: var(--theme-bg-secondary); + width: 100%; + padding: 11px 13px 11px 15px; + border-radius: var(--radius, 10px); + background: var(--row-tint); border: 1px solid var(--theme-border-subtle); - transition: background 0.15s ease, border-color 0.15s ease; - animation: lmFadeUp 0.25s var(--lm-ease) both; - box-sizing: border-box; + overflow: hidden; + animation: lmFadeUp 0.28s var(--lm-ease) both; - &:hover { - background: var(--theme-bg-tertiary); - border-color: var(--theme-border); - } + &.lm-tone-bad { --row-accent: var(--risk-bad); --row-tint: var(--risk-bad-bg); border-color: var(--risk-bad-border); } + &.lm-tone-warn { --row-accent: var(--risk-warn); --row-tint: var(--risk-warn-bg); border-color: var(--risk-warn-border); } + &.lm-tone-neutral { --row-accent: var(--risk-neutral); --row-tint: var(--theme-bg-secondary); border-style: dashed; border-color: var(--theme-border); } +} - &.lm-check-issues { - border-color: var(--risk-warn-border); - background: var(--risk-warn-bg); - &:hover { - background: var(--risk-warn-bg); - border-color: var(--risk-warn-border); - } - } +.lm-check-rail { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--row-accent); } -.lm-check-left { - display: flex; +.lm-check-glyph { + display: inline-flex; align-items: center; - gap: 6px; - min-width: 0; - flex: 1; + justify-content: center; + color: var(--row-accent); } -.lm-check-card .lm-check-name { - font-size: 0.8125rem; - font-weight: 500; - color: var(--theme-text-primary); - line-height: 1.3; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.lm-check-main { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 8px; + min-width: 0; } -// Status: icon + short label (right-aligned in card) -.lm-status-wrap { +.lm-check-name { display: inline-flex; align-items: center; - gap: 4px; - flex-shrink: 0; + gap: 5px; + font-size: 0.875rem; + font-weight: 600; + color: var(--theme-text-primary); + line-height: 1.25; } -.lm-status-icon { - width: 14px; - height: 14px; - flex-shrink: 0; +.lm-check-tag { + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--theme-text-muted); + padding: 2px 6px; + border-radius: 4px; + background: var(--theme-bg-tertiary); } -.lm-status { +.lm-check-status { + flex-shrink: 0; font-size: 0.625rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.03em; - padding: 3px 7px; - border-radius: 5px; - border: 1px solid; + letter-spacing: 0.05em; + padding: 4px 9px; + border-radius: var(--radius-full, 9999px); white-space: nowrap; - color: var(--theme-text-primary); + border: 1px solid transparent; } - -.lm-status-clear { - color: var(--theme-text-primary); - border-color: var(--risk-good-border); - background: var(--risk-good-bg); -} - +// Dark ink on a solid accent: loud, and AA-contrast on both amber and red in +// either theme (white-on-amber failed WCAG). .lm-status-issues { - color: var(--theme-text-primary); - border-color: var(--risk-warn-border); - background: var(--risk-warn-bg); + color: #1c1a17; + background: var(--row-accent); + font-weight: 800; } - -// Neutral "Not analyzed" status — no coverage, so neither safe nor flagged. .lm-status-unknown { color: var(--theme-text-secondary); - border-color: var(--theme-border); background: var(--theme-bg-tertiary); + border-color: var(--theme-border); } -.lm-check-card.lm-check-clear .lm-status-icon { - color: var(--risk-good); +// --------------------------------------------------------------------------- +// Cleared tier — quiet, dense, low-contrast (no green pill noise) +// --------------------------------------------------------------------------- +.lm-clear-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 16px; } -.lm-check-card.lm-check-issues .lm-status-icon { - color: var(--risk-warn); + +.lm-clear-row { + display: flex; + align-items: center; + gap: 7px; + padding: 5px 2px; + min-width: 0; } -.lm-check-card.lm-check-unknown .lm-status-icon { - color: var(--theme-text-secondary); + +.lm-clear-tick { + color: var(--risk-good); + opacity: 0.85; + flex-shrink: 0; +} + +.lm-clear-name { + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.3; + color: var(--theme-text-muted); } // --------------------------------------------------------------------------- @@ -347,7 +342,7 @@ color: var(--theme-text-muted); cursor: help; flex-shrink: 0; - opacity: 0.5; + opacity: 0.55; transition: opacity 0.15s ease, color 0.15s ease; outline: none; border-radius: 50%; @@ -391,25 +386,13 @@ } @keyframes lmTooltipIn { - from { - opacity: 0; - transform: translateX(-50%) translateY(4px); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } + from { opacity: 0; transform: translateX(-50%) translateY(4px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } } @keyframes lmFadeUp { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } } // --------------------------------------------------------------------------- @@ -422,14 +405,16 @@ } .lm-header-inner { flex-wrap: wrap; + justify-content: flex-start; gap: 8px; padding-right: 48px; } - .lm-group-items { - flex-direction: column; + .lm-verdict-pill { + align-self: flex-start; } - .lm-check-card { - width: 100%; + .lm-clear-grid { + grid-template-columns: 1fr; + gap: 2px; } } @@ -457,66 +442,20 @@ box-shadow: var(--theme-shadow-dropdown); } -.light .lm-header { - border-bottom-color: var(--theme-border-subtle); - .lm-title { color: var(--theme-text-primary); } -} - .light .lm-header-inner { border-bottom-color: var(--theme-border-subtle); } -.light .lm-stats-row { - border-bottom-color: var(--theme-border-subtle); -} - -.light .lm-stat { - color: var(--theme-text-muted); +.light .lm-check { + background: var(--row-tint, var(--theme-bg-secondary)); } -.light .lm-stat-num { - color: var(--theme-text-primary); -} - -.light .lm-group-label { - color: var(--theme-text-muted); -} - -.light .lm-check-card { +.light .lm-check.lm-tone-neutral { background: var(--theme-bg-secondary); - border-color: var(--theme-border-subtle); - &:hover { - background: var(--theme-bg-tertiary); - border-color: var(--theme-border); - } - &.lm-check-issues { - background: var(--risk-warn-bg); - border-color: var(--risk-warn-border); - } -} - -.light .lm-check-card .lm-check-name { - color: var(--theme-text-primary); -} - -.light .lm-status-clear { - background: var(--risk-good-bg); - border-color: var(--risk-good-border); - color: var(--risk-good); } .light .lm-status-issues { - background: var(--risk-warn-bg); - border-color: var(--risk-warn-border); - color: var(--risk-warn); -} - -.light .lm-info-trigger { - color: var(--extensionshield-text-muted); - &:hover, - &:focus-visible { - color: var(--extensionshield-text-secondary); - } + color: #fff; } .light .lm-info-tooltip { diff --git a/frontend/src/components/report/LayerModal.test.jsx b/frontend/src/components/report/LayerModal.test.jsx index 5c8191ea..c9da00c5 100644 --- a/frontend/src/components/report/LayerModal.test.jsx +++ b/frontend/src/components/report/LayerModal.test.jsx @@ -1,55 +1,63 @@ /** - * LayerModal status-mapping tests (Phase 1 follow-up). + * LayerModal status + triage tests (presentation correctness). * - * A check whose underlying analysis did not run has no coverage and must read as - * "Not analyzed", never "Clear" (which overstates certainty). The network/exfil - * analyzer reports this via details.network_analysis_enabled === false. + * Truthful statuses: + * - "Issue"/"High risk" only when the check ran and found something (severity >= 0.4) + * - "Not analyzed" when coverage is absent (never "Clear") + * - "Clear" only when the check ran and found nothing material + * Triage ordering: issues (most severe first) -> not analyzed -> cleared. * - * These exercise the pure status mapping only — no rendering required. + * Pure logic only — no rendering required. */ import { describe, it, expect } from 'vitest'; -import { humanizeFactor, isNotAnalyzed } from './LayerModal'; +import { humanizeFactor, isNotAnalyzed, triageFactors } from './LayerModal'; describe('LayerModal status mapping', () => { it('Data Sharing with no network coverage is "Not analyzed", not "Clear"', () => { - const factor = { + const result = humanizeFactor({ name: 'NetworkExfil', severity: 0, confidence: 0.5, details: { network_analysis_enabled: false }, - }; - const result = humanizeFactor(factor); + }); expect(result.status).toBe('Not analyzed'); expect(result.statusType).toBe('unknown'); + expect(result.tone).toBe('neutral'); expect(result.label).toBe('Data Sharing'); }); it('Data Sharing WITH coverage and no findings reads "Clear"', () => { - const factor = { + const result = humanizeFactor({ name: 'NetworkExfil', severity: 0, - confidence: 0.7, details: { network_analysis_enabled: true, domains_analyzed: 3 }, - }; - const result = humanizeFactor(factor); + }); expect(result.status).toBe('Clear'); expect(result.statusType).toBe('clear'); + expect(result.tone).toBe('good'); }); - it('an actual ISSUE-level finding wins over the not-analyzed flag', () => { - const factor = { - name: 'NetworkExfil', - severity: 0.6, - details: { network_analysis_enabled: false }, - }; - const result = humanizeFactor(factor); + it('a moderate finding (>=0.4) is an amber "Issue"', () => { + const result = humanizeFactor({ name: 'Maintenance', severity: 0.5 }); expect(result.status).toBe('Issue'); expect(result.statusType).toBe('issues'); + expect(result.tone).toBe('warn'); }); - it('a normal sub-threshold factor without coverage flags is "Clear"', () => { - const factor = { name: 'CaptureSignals', severity: 0.1, details: {} }; - expect(humanizeFactor(factor).status).toBe('Clear'); + it('a severe finding (>=0.7) is a red "High risk"', () => { + const result = humanizeFactor({ name: 'ToSViolations', severity: 0.8 }); + expect(result.status).toBe('High risk'); + expect(result.statusType).toBe('issues'); + expect(result.tone).toBe('bad'); + }); + + it('an actual finding wins over the not-analyzed flag', () => { + const result = humanizeFactor({ + name: 'NetworkExfil', + severity: 0.6, + details: { network_analysis_enabled: false }, + }); + expect(result.statusType).toBe('issues'); }); it('isNotAnalyzed only fires on the explicit coverage flag', () => { @@ -59,3 +67,34 @@ describe('LayerModal status mapping', () => { expect(isNotAnalyzed({})).toBe(false); }); }); + +describe('LayerModal triage ordering', () => { + const factors = [ + { name: 'SAST', severity: 0.1 }, // clear + { name: 'CaptureSignals', severity: 0.5 }, // issue (warn) + { name: 'NetworkExfil', severity: 0, details: { network_analysis_enabled: false } }, // not analyzed + { name: 'ToSViolations', severity: 0.9 }, // issue (bad) + { name: 'Webstore', severity: 0.2 }, // clear + ]; + + it('separates the three tiers correctly', () => { + const { issues, notAnalyzed, cleared } = triageFactors(factors); + expect(issues.map((i) => i.label)).toEqual(['Policy Violations', 'Screen Capture']); // severe first + expect(notAnalyzed.map((i) => i.label)).toEqual(['Data Sharing']); + expect(cleared.map((i) => i.label)).toEqual(['Code Safety', 'Store Reputation']); // alphabetical + }); + + it('a not-analyzed check never lands in the cleared tier', () => { + const { cleared, notAnalyzed } = triageFactors(factors); + expect(cleared.some((i) => i.label === 'Data Sharing')).toBe(false); + expect(notAnalyzed.some((i) => i.label === 'Data Sharing')).toBe(true); + }); + + it('handles an empty layer without throwing', () => { + const { all, issues, notAnalyzed, cleared } = triageFactors([]); + expect(all).toEqual([]); + expect(issues).toEqual([]); + expect(notAnalyzed).toEqual([]); + expect(cleared).toEqual([]); + }); +}); diff --git a/frontend/src/components/report/ResultsSidebarTile.jsx b/frontend/src/components/report/ResultsSidebarTile.jsx index 2bb44915..01067e8c 100644 --- a/frontend/src/components/report/ResultsSidebarTile.jsx +++ b/frontend/src/components/report/ResultsSidebarTile.jsx @@ -62,8 +62,9 @@ const ResultsSidebarTile = ({ {getBandLabel()} {findingsCount > 0 && ( - - {findingsCount} {findingsCount === 1 ? 'finding' : 'findings'} + + + {findingsCount} {findingsCount === 1 ? 'issue' : 'issues'} )}
diff --git a/frontend/src/components/report/ResultsSidebarTile.scss b/frontend/src/components/report/ResultsSidebarTile.scss index d65449a2..4400dc92 100644 --- a/frontend/src/components/report/ResultsSidebarTile.scss +++ b/frontend/src/components/report/ResultsSidebarTile.scss @@ -1,23 +1,3 @@ -@keyframes tile-clickable-hint { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); - border-color: rgba(255, 255, 255, 0.08); - } - 25% { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12); - border-color: rgba(255, 255, 255, 0.18); - } - 50% { - box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.14); - } - 75% { - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.12); - } -} - .results-sidebar-tile { --tile-band-color: var(--risk-neutral); background: rgba(255, 255, 255, 0.04); @@ -36,7 +16,6 @@ &.is-clickable { cursor: pointer; - animation: tile-clickable-hint 2.2s ease-in-out 2; &:hover { background: rgba(255, 255, 255, 0.06); @@ -48,15 +27,20 @@ &.band-good { --tile-band-color: var(--risk-good); - border-color: rgba(16, 185, 129, 0.25); + border-color: var(--risk-good-border); } &.band-warn { --tile-band-color: var(--risk-warn); - border-color: rgba(245, 158, 11, 0.25); + border-color: var(--risk-warn-border); } &.band-bad { --tile-band-color: var(--risk-bad); - border-color: rgba(239, 68, 68, 0.25); + border-color: var(--risk-bad-border); + } + + &:focus-visible { + outline: 2px solid var(--color-ring, var(--theme-border-strong)); + outline-offset: 2px; } } @@ -109,26 +93,29 @@ font-size: 0.75rem; font-weight: 600; border-radius: 9999px; - color: var(--theme-text-primary); - background: rgba(16, 185, 129, 0.25); border: 1px solid transparent; + // Saturated risk color on its own tint — AA-safe and theme-aware (matches + // the LayerModal verdict pill), instead of hardcoded rgba on white text. &.tile-pill-good { - background: rgba(16, 185, 129, 0.25); - border-color: rgba(16, 185, 129, 0.4); + color: var(--risk-good); + background: var(--risk-good-bg); + border-color: var(--risk-good-border); } &.tile-pill-warn { - background: rgba(245, 158, 11, 0.35); - border-color: rgba(245, 158, 11, 0.4); + color: var(--risk-warn); + background: var(--risk-warn-bg); + border-color: var(--risk-warn-border); } &.tile-pill-bad { - background: rgba(239, 68, 68, 0.25); - border-color: rgba(239, 68, 68, 0.4); + color: var(--risk-bad); + background: var(--risk-bad-bg); + border-color: var(--risk-bad-border); } &.tile-pill-na { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.6); + color: var(--theme-text-muted); + background: var(--theme-bg-tertiary); + border-color: var(--theme-border); } } @@ -138,9 +125,24 @@ } .tile-findings-badge { + display: inline-flex; + align-items: center; + gap: 6px; font-size: 0.75rem; - font-weight: 500; - color: rgba(248, 250, 252, 0.55); + font-weight: 600; + color: var(--theme-text-secondary); + + .tile-findings-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; + } + + // Issues are a real signal, not gray metadata — tint by severity band. + &--warn { color: var(--risk-warn); } + &--bad { color: var(--risk-bad); } } /* Row 3: Progress bar */ @@ -201,6 +203,8 @@ .light .tile-findings-badge { color: var(--extensionshield-text-secondary); } +.light .tile-findings-badge--warn { color: var(--risk-warn); } +.light .tile-findings-badge--bad { color: var(--risk-bad); } .light .tile-pill { &.tile-pill-good { diff --git a/frontend/src/pages/scanner/ScanResultsPageV2.jsx b/frontend/src/pages/scanner/ScanResultsPageV2.jsx index 1e9f5baa..c5696ef4 100644 --- a/frontend/src/pages/scanner/ScanResultsPageV2.jsx +++ b/frontend/src/pages/scanner/ScanResultsPageV2.jsx @@ -786,7 +786,7 @@ const ScanResultsPageV2 = () => {
{totalFindingsCount} - {totalFindingsCount === 1 ? 'finding' : 'findings'} + {totalFindingsCount === 1 ? 'issue' : 'issues'}
)} diff --git a/frontend/src/utils/normalizeScanResult.ts b/frontend/src/utils/normalizeScanResult.ts index d419f1e1..677c74ff 100644 --- a/frontend/src/utils/normalizeScanResult.ts +++ b/frontend/src/utils/normalizeScanResult.ts @@ -213,18 +213,24 @@ const GATE_HUMAN_TITLE: Record = { CAPTURE_SIGNALS: 'May capture your screen or input', }; +// Keys MUST match the backend FactorName strings (scoring/weights.py). Labels +// are kept consistent with LayerModal's FACTOR_HUMAN so a factor reads the same +// in Key Findings and in the layer modal. const FACTOR_HUMAN_TITLE: Record = { - SAST: 'Code security scan', - VirusTotal: 'Antivirus scan', - Entropy: 'Code obfuscation check', - ManifestPosture: 'Extension configuration', - ChromeStats: 'Chrome Web Store reputation', - WebStoreTrust: 'Developer trust signals', - MaintenanceHealth: 'Update & maintenance status', - PermissionsBaseline: 'Permission risk level', - PermissionCombos: 'Risky permission combinations', - NetworkExfil: 'Data sent to external servers', - CaptureSignals: 'Screen or input capture', + SAST: 'Code Safety', + VirusTotal: 'Malware Scan', + Obfuscation: 'Hidden Code', + Manifest: 'Extension Config', + ChromeStats: 'Threat Intel', + Webstore: 'Store Reputation', + Maintenance: 'Update Freshness', + PermissionsBaseline: 'Permission Risk', + PermissionCombos: 'Dangerous Combos', + NetworkExfil: 'Data Sharing', + CaptureSignals: 'Screen Capture', + ToSViolations: 'Policy Violations', + Consistency: 'Behavior Match', + DisclosureAlignment: 'Disclosure Accuracy', }; const SAST_HUMAN_TITLE: Record = {