diff --git a/frontend/src/components/report/LayerModal.jsx b/frontend/src/components/report/LayerModal.jsx index 8d83513..3418f27 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 5fe58fd..e608727 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 5c8191e..c9da00c 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 2bb4491..01067e8 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 d65449a..4400dc9 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 1e9f5ba..c5696ef 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 d419f1e..677c74f 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 = {