feat(ui): add security shield icon in sidepanel header (3 states)

Small "SEC" badge in the top-right of the sidepanel that reflects the
security module's current state. Three states drive color:

  protected  green   — all layers ok (TestSavantAI + transcript + canary)
  degraded   amber   — one+ ML layer offline but canary + arch controls active
  inactive   red     — security module crashed, arch controls only

Consumes /health.security (surfaced in commit 7e9600ff). Updated once on
connection bootstrap. Shield stays hidden until /health arrives so the user
never sees a flickering "unknown" state.

Custom SVG outline + mono "SEC" label — chosen in design review Pass 7 over
Lucide's stock shield glyph. Matches the industrial/CLI brand voice in
DESIGN.md ("monospace as personality font").

Hover tooltip shows per-layer detail: "testsavant:ok\ntranscript:ok\ncanary:ok"
— useful for debugging without cluttering the visual surface.

Known v1 limitation: only updates at connection bootstrap. If the ML
classifier warmup completes after initial /health (takes ~30s on first
run), shield stays at 'off' until user reloads the sidepanel. Follow-up
TODO: extend /sidebar-chat polling to refresh security state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-19 19:20:26 +08:00
parent ffb064afda
commit 59e0635eb5
3 changed files with 74 additions and 0 deletions
+31
View File
@@ -176,6 +176,35 @@ function hideSecurityBanner() {
if (banner) banner.style.display = 'none';
}
// Shield icon state update — consumes /health.security.status.
// status ∈ { 'protected', 'degraded', 'inactive' }.
// 'protected' = all layers ok. 'degraded' = at least one ML layer off or failed
// (sidebar still defended by canary + architectural controls).
// 'inactive' = security module crashed — only architectural controls active.
const SHIELD_LABELS = {
protected: { label: 'SEC', aria: 'Security status: protected' },
degraded: { label: 'SEC', aria: 'Security status: degraded (some layers offline)' },
inactive: { label: 'SEC', aria: 'Security status: inactive (architectural controls only)' },
};
function updateSecurityShield(securityState) {
const shield = document.getElementById('security-shield');
const labelEl = document.getElementById('security-shield-label');
if (!shield || !securityState) return;
const status = securityState.status || 'inactive';
const info = SHIELD_LABELS[status] || SHIELD_LABELS.inactive;
shield.setAttribute('data-status', status);
shield.setAttribute('aria-label', info.aria);
shield.style.display = 'inline-flex';
if (labelEl) labelEl.textContent = info.label;
// Hover tooltip gives layer-level detail for debugging.
if (securityState.layers) {
const parts = Object.entries(securityState.layers).map(([k, v]) => `${k}:${v}`);
shield.setAttribute('title', `Security — ${status}\n${parts.join('\n')}`);
} else {
shield.setAttribute('title', `Security — ${status}`);
}
}
// Wire up banner interactivity once on load
document.addEventListener('DOMContentLoaded', () => {
const closeBtn = document.getElementById('security-banner-close');
@@ -1662,6 +1691,8 @@ async function tryConnect() {
`token: yes (from /health)\nStarting SSE + chat polling...`
);
updateConnection(`http://127.0.0.1:${port}`, data.token);
// Shield state arrives on /health alongside the auth token.
if (data.security) updateSecurityShield(data.security);
return;
}
setLoadingStatus(