Files
cve-dashboard/docs/design-system-redesign/ui_kits/home/HomePrimitives.jsx
root 27192dd69f WIP: Dashboard redesign — design system overhaul and component updates
Frontend redesign in progress: updated styles, layout, and components
across all pages to align with new design system. Includes Jira API
compliance specs, property tests, and load test script.
2026-04-29 14:20:23 +00:00

663 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// HomePrimitives.jsx — primitives for the CVE Dashboard Home page kit.
// Lifted directly from frontend/src/App.js (the home view), normalized to
// match the same vocabulary the Reporting + Knowledge Base kits use.
//
// Exported on window.HOME for the assembly + docs files to consume.
const { useState: useHomeState } = React;
/* ── Tokens ──────────────────────────────────────────────────────
Identical palette to Reporting + KB. Home adds purple (Archer)
and teal (Ivanti) — both used as left-rail / title-glow colors
on the right-side panel stack. */
const H_COLORS = {
sky: '#0EA5E9',
skySoft: '#7DD3FC',
green: '#10B981',
amber: '#F59E0B',
amberSoft: '#FCD34D',
red: '#EF4444',
redSoft: '#FCA5A5',
purple: '#8B5CF6',
teal: '#0D9488',
};
/* Card chrome shared with the rest of the system. One chrome, every panel. */
const CARD_BG = 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)';
const CARD_BORDER = '1.5px solid rgba(14,165,233,0.12)';
const CARD_BORDER_HOVER = '1.5px solid rgba(14,165,233,0.35)';
/* ── StatCard ────────────────────────────────────────────────────
Top-of-page metric tile. Color-coded by tone — sky for neutral
counts, amber for "needs attention", red for critical. Top edge
has a soft horizontal glow line in the same color. */
function StatCard({ label, value, tone = 'sky', mono = true }) {
const c = H_COLORS[tone] || H_COLORS.sky;
const isAccent = tone !== 'neutral';
return (
<div style={{
position: 'relative', overflow: 'hidden',
background: CARD_BG,
border: isAccent ? `2px solid ${c}` : CARD_BORDER,
borderRadius: 8, padding: 16,
boxShadow: isAccent
? `0 4px 16px rgba(0,0,0,0.5), 0 0 20px ${withAlpha(c, 0.15)}, inset 0 1px 0 ${withAlpha(c, 0.15)}`
: '0 4px 16px rgba(0,0,0,0.5)',
}}>
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, height: 2,
background: `linear-gradient(90deg, transparent, ${c}, transparent)`,
boxShadow: `0 0 8px ${withAlpha(c, 0.5)}`,
}} />
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.1em',
marginBottom: 4,
}}>
{label}
</div>
<div style={{
fontFamily: mono ? 'var(--font-mono)' : 'var(--font-display)',
fontSize: 24, fontWeight: 700, color: c,
textShadow: isAccent ? `0 0 16px ${withAlpha(c, 0.4)}` : 'none',
lineHeight: 1,
}}>
{value}
</div>
</div>
);
}
/* ── HomeCard ────────────────────────────────────────────────────
Same chrome as Reporting's KbCard but without a label slot —
the home cards put their title inline above the body. Used as
the wrapper for Quick Lookup, the filter row, and CVE rows. */
function HomeCard({ children, padding = 24, hover = true, leftRail, style }) {
const [h, setH] = useHomeState(false);
return (
<div
onMouseEnter={() => hover && setH(true)}
onMouseLeave={() => setH(false)}
style={{
background: CARD_BG,
border: h ? CARD_BORDER_HOVER : CARD_BORDER,
borderLeft: leftRail ? `3px solid ${leftRail}` : (h ? CARD_BORDER_HOVER : CARD_BORDER).split(' ').slice(0).join(' '),
borderRadius: 8,
padding,
transition: 'border-color 200ms ease, box-shadow 200ms ease',
position: 'relative',
...style,
}}
>
{children}
</div>
);
}
/* ── CardTitle ───────────────────────────────────────────────────
Mono uppercase, glow color matches the card's identity (sky for
neutral, amber for tickets, purple for Archer, teal for Ivanti). */
function CardTitle({ color = H_COLORS.sky, icon, children, action }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
<h3 style={{
margin: 0,
fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600,
color, textTransform: 'uppercase', letterSpacing: '0.1em',
textShadow: `0 0 12px ${withAlpha(color, 0.4)}`,
display: 'flex', alignItems: 'center', gap: 8,
}}>
{icon && <HomeIcon name={icon} size={16} color={color} />}
{children}
</h3>
{action}
</div>
);
}
/* ── HomeButton ──────────────────────────────────────────────────
Wraps the four button variants the Home page uses, keeping the
exact same tinted-fill / outlined treatment as the Reporting kit
so all pages feel consistent. */
function HomeButton({ variant = 'neutral', icon, children, size = 'md', ...rest }) {
const [hover, setHover] = useHomeState(false);
const v = {
primary: { bg: hover ? 'rgba(16,185,129,0.18)' : 'rgba(16,185,129,0.10)', bd: H_COLORS.green, fg: H_COLORS.green },
neutral: { bg: hover ? 'rgba(14,165,233,0.10)' : 'transparent', bd: 'rgba(14,165,233,0.5)', fg: H_COLORS.sky },
subtle: { bg: hover ? 'rgba(14,165,233,0.16)' : 'rgba(14,165,233,0.08)', bd: 'rgba(14,165,233,0.30)', fg: H_COLORS.sky },
danger: { bg: hover ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.10)', bd: 'rgba(239,68,68,0.5)', fg: H_COLORS.red },
warning: { bg: hover ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.10)', bd: 'rgba(245,158,11,0.5)', fg: H_COLORS.amber },
}[variant];
const padX = size === 'sm' ? 10 : 14;
const padY = size === 'sm' ? 4 : 8;
const fs = size === 'sm' ? 11 : 12;
return (
<button
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: `${padY}px ${padX}px`, borderRadius: 6,
background: v.bg, border: `1px solid ${v.bd}`, color: v.fg,
fontFamily: 'var(--font-mono)', fontSize: fs, fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.06em',
cursor: 'pointer', transition: 'all 160ms ease', whiteSpace: 'nowrap',
}}
{...rest}
>
{icon && <HomeIcon name={icon} size={fs + 2} color={v.fg} />}
{children}
</button>
);
}
/* ── SeverityBadge ───────────────────────────────────────────────
Strong tinted-fill badge used in CVE rows. Critical/High/Medium/Low. */
function SeverityBadge({ level }) {
const map = {
Critical: { c: H_COLORS.red, text: H_COLORS.redSoft },
High: { c: H_COLORS.amber, text: H_COLORS.amberSoft },
Medium: { c: H_COLORS.sky, text: H_COLORS.skySoft },
Low: { c: H_COLORS.green, text: '#6EE7B7' },
}[level] || { c: H_COLORS.sky, text: H_COLORS.skySoft };
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: `linear-gradient(135deg, ${withAlpha(map.c, 0.25)}, ${withAlpha(map.c, 0.20)})`,
border: `2px solid ${map.c}`, borderRadius: 6,
padding: '4px 10px',
color: map.text, fontWeight: 700, fontSize: 11,
textTransform: 'uppercase', letterSpacing: '0.05em',
fontFamily: 'var(--font-mono)',
textShadow: `0 0 8px ${withAlpha(map.c, 0.5)}`,
boxShadow: `0 0 16px ${withAlpha(map.c, 0.25)}, 0 4px 8px rgba(0,0,0,0.4)`,
}}>
<span style={{
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
background: map.c, boxShadow: `0 0 8px ${map.c}`,
}} />
{level}
</span>
);
}
/* ── StatusBadge ─────────────────────────────────────────────────
Tone-coded text badge used for ticket statuses (Open / In Progress /
Closed / Draft / Accepted). Smaller and lighter than SeverityBadge. */
function StatusBadge({ tone = 'sky', children, size = 'md' }) {
const c = H_COLORS[tone] || H_COLORS.sky;
const fs = size === 'sm' ? 10 : 11;
const pad = size === 'sm' ? '3px 7px' : '4px 9px';
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: withAlpha(c, 0.18),
border: `1px solid ${c}`, borderRadius: 4,
padding: pad, color: c,
fontFamily: 'var(--font-mono)', fontSize: fs, fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.05em',
whiteSpace: 'nowrap',
}}>
<span style={{
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
background: c, boxShadow: `0 0 6px ${c}`,
}} />
{children}
</span>
);
}
/* ── HomeInput / HomeSelect ──────────────────────────────────────
The intel-input look: dark fill + sky border on focus. */
function HomeInput({ icon, ...rest }) {
const [focus, setFocus] = useHomeState(false);
return (
<div style={{ position: 'relative', flex: 1 }}>
{icon && (
<div style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: H_COLORS.sky }}>
<HomeIcon name={icon} size={14} color={H_COLORS.sky} />
</div>
)}
<input
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15,23,42,0.85)',
border: `1px solid ${focus ? H_COLORS.sky : 'rgba(14,165,233,0.25)'}`,
borderRadius: 6,
padding: icon ? '9px 12px 9px 34px' : '9px 12px',
color: 'var(--fg-1)',
fontFamily: 'var(--font-mono)', fontSize: 13,
outline: 'none', transition: 'border-color 160ms ease',
boxShadow: focus ? `0 0 0 3px ${withAlpha(H_COLORS.sky, 0.15)}` : 'none',
}}
{...rest}
/>
</div>
);
}
function HomeSelect({ value, onChange, options }) {
return (
<select value={value} onChange={onChange} style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(15,23,42,0.85)',
border: '1px solid rgba(14,165,233,0.25)', borderRadius: 6,
padding: '9px 12px', color: 'var(--fg-1)',
fontFamily: 'var(--font-mono)', fontSize: 13,
outline: 'none', appearance: 'none',
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230EA5E9' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat', backgroundPosition: 'right 12px center', paddingRight: 32,
}}>
{options.map(o => <option key={o} value={o}>{o}</option>)}
</select>
);
}
function FieldLabel({ icon, children }) {
return (
<label style={{
display: 'flex', alignItems: 'center', gap: 6,
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.08em',
marginBottom: 8,
}}>
{icon && <HomeIcon name={icon} size={13} color="currentColor" />}
{children}
</label>
);
}
/* ── ResultBanner ────────────────────────────────────────────────
Sub-card used in Quick Lookup to surface scan results.
Tones: success (CVE addressed), warning (not found), error. */
function ResultBanner({ tone = 'success', icon, title, children }) {
const map = {
success: { c: H_COLORS.green, bg: 'rgba(16,185,129,0.10)', bd: 'rgba(16,185,129,0.30)' },
warning: { c: H_COLORS.amber, bg: 'rgba(245,158,11,0.10)', bd: 'rgba(245,158,11,0.30)' },
error: { c: H_COLORS.red, bg: 'rgba(239,68,68,0.10)', bd: 'rgba(239,68,68,0.30)' },
}[tone];
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: 16, borderRadius: 6,
background: map.bg, border: `1px solid ${map.bd}`,
}}>
<div style={{ color: map.c, marginTop: 1 }}>
<HomeIcon name={icon || tone} size={18} color={map.c} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600,
color: map.c, marginBottom: children ? 8 : 0,
}}>
{title}
</div>
{children}
</div>
</div>
);
}
/* ── BigStat ─────────────────────────────────────────────────────
The centered "active count + label" shown at the top of each
right-rail panel (Open Tickets · Archer · Ivanti). */
function BigStat({ value, label, color = H_COLORS.sky }) {
return (
<div style={{ textAlign: 'center', marginBottom: 12 }}>
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 32, fontWeight: 700,
color, textShadow: `0 0 16px ${withAlpha(color, 0.4)}`, lineHeight: 1,
}}>
{value}
</div>
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.12em',
marginTop: 6,
}}>
{label}
</div>
</div>
);
}
/* ── MiniTicket ──────────────────────────────────────────────────
Compact card shown inside the right-rail scrollable lists.
Color-coded by category via the `tone` prop (amber/purple/teal). */
function MiniTicket({ keyText, cveId, vendor, status, tone = 'amber', summary, onEdit, onDelete }) {
const c = H_COLORS[tone] || H_COLORS.amber;
return (
<div style={{
background: 'linear-gradient(135deg, rgba(30,41,59,0.85), rgba(51,65,85,0.75))',
border: `1px solid ${withAlpha(c, 0.25)}`, borderRadius: 6,
padding: 10,
boxShadow: '0 2px 6px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.03)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, color: H_COLORS.sky }}>
{keyText}
</span>
{(onEdit || onDelete) && (
<div style={{ display: 'flex', gap: 4 }}>
{onEdit && <button onClick={onEdit} style={iconBtn(H_COLORS.amber)}><HomeIcon name="edit" size={11} color="currentColor" /></button>}
{onDelete && <button onClick={onDelete} style={iconBtn(H_COLORS.red)}><HomeIcon name="trash" size={11} color="currentColor" /></button>}
</div>
)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-1)', marginBottom: 2 }}>{cveId}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-2)' }}>{vendor}</div>
{summary && (
<div style={{
fontSize: 11, color: 'var(--fg-2)', marginTop: 4,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontFamily: 'var(--font-display)',
}}>{summary}</div>
)}
{status && (
<div style={{ marginTop: 8 }}>
<StatusBadge tone={tone} size="sm">{status}</StatusBadge>
</div>
)}
</div>
);
}
const iconBtn = (color) => ({
background: 'transparent', border: 'none', color: 'var(--fg-2)',
cursor: 'pointer', padding: 2, display: 'inline-flex', alignItems: 'center',
transition: 'color 120ms ease',
});
/* ── CVERow ──────────────────────────────────────────────────────
The main "row" in the home feed. Collapsed = chevron · CVE-ID ·
description · meta row (severity badge, vendor count, doc count,
statuses). Expanded = full description + admin actions slot. */
function CVERow({ cveId, severity, description, vendorCount, docCount, statuses, expanded, onToggle, children }) {
return (
<div style={{
background: CARD_BG, border: CARD_BORDER, borderRadius: 8,
transition: 'border-color 200ms ease',
}}>
<button
onClick={onToggle}
style={{
width: '100%', textAlign: 'left',
background: 'transparent', border: 'none',
padding: 24, cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
<span style={{
display: 'inline-block', transform: expanded ? 'rotate(0)' : 'rotate(-90deg)',
transition: 'transform 200ms ease', color: H_COLORS.sky,
}}>
<HomeIcon name="chevron" size={18} color={H_COLORS.sky} />
</span>
<h3 style={{
margin: 0, fontFamily: 'var(--font-mono)', fontSize: 22, fontWeight: 700,
color: H_COLORS.sky, letterSpacing: '-0.01em',
}}>{cveId}</h3>
</div>
<div style={{ marginLeft: 30 }}>
<p style={{
margin: '0 0 8px 0',
color: 'var(--fg-1)', fontSize: 13, lineHeight: 1.5,
fontFamily: 'var(--font-display)',
display: '-webkit-box', WebkitLineClamp: expanded ? 'unset' : 1,
WebkitBoxOrient: 'vertical', overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{description}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<SeverityBadge level={severity} />
<span style={metaText}>{vendorCount} vendor{vendorCount !== 1 ? 's' : ''}</span>
<span style={{ ...metaText, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<HomeIcon name="doc" size={11} color="currentColor" />
{docCount} doc{docCount !== 1 ? 's' : ''}
</span>
<span style={metaText}>{statuses.join(', ')}</span>
</div>
</div>
</button>
{expanded && children && (
<div style={{ padding: '0 24px 24px', marginLeft: 30 }}>
{children}
</div>
)}
</div>
);
}
const metaText = {
fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)',
};
/* ── VendorEntry ─────────────────────────────────────────────────
Sub-card inside an expanded CVE row, one per vendor that filed
the CVE. Holds vendor name, severity, status, doc count, and
inline action buttons. */
function VendorEntry({ vendor, severity, status, docCount, children, onView, onEdit, onDelete }) {
return (
<div style={{
background: 'linear-gradient(135deg, rgba(15,23,42,0.95) 0%, rgba(30,41,59,0.9) 100%)',
border: '1.5px solid rgba(14,165,233,0.30)', borderRadius: 6,
padding: 16, marginBottom: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(14,165,233,0.08)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<h4 style={{ margin: 0, fontFamily: 'var(--font-display)', fontSize: 15, fontWeight: 600, color: 'var(--fg-1)' }}>{vendor}</h4>
<SeverityBadge level={severity} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg-2)' }}>
<span>Status: <strong style={{ color: 'var(--fg-1)', fontWeight: 500 }}>{status}</strong></span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<HomeIcon name="doc" size={13} color="currentColor" />
{docCount} doc{docCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
{onView && <HomeButton variant="neutral" icon="eye" size="sm" onClick={onView}>View</HomeButton>}
{onEdit && <HomeButton variant="warning" icon="edit" size="sm" onClick={onEdit} />}
{onDelete && <HomeButton variant="danger" icon="trash" size="sm" onClick={onDelete} />}
</div>
</div>
{children && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid rgba(14,165,233,0.20)' }}>
{children}
</div>
)}
</div>
);
}
/* ── HomeIcon ────────────────────────────────────────────────────
Inline SVGs covering every icon used on the home page so the kit
has no external icon-font dependency. Keys mirror lucide-react names. */
function HomeIcon({ name, size = 16, color = 'currentColor' }) {
const p = {
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
stroke: color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
style: { display: 'inline-block', verticalAlign: 'middle' },
};
switch (name) {
case 'search': return <svg {...p}><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>;
case 'filter': return <svg {...p}><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>;
case 'alert': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>;
case 'check':
case 'success': return <svg {...p}><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>;
case 'warning': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>;
case 'error':
case 'x': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>;
case 'shield': return <svg {...p}><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>;
case 'activity': return <svg {...p}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>;
case 'doc': return <svg {...p}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>;
case 'eye': return <svg {...p}><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>;
case 'edit': return <svg {...p}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
case 'trash': return <svg {...p}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>;
case 'plus': return <svg {...p}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>;
case 'refresh': return <svg {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/></svg>;
case 'chevron': return <svg {...p}><polyline points="6 9 12 15 18 9"/></svg>;
case 'upload': return <svg {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
case 'download': return <svg {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>;
case 'calendar': return <svg {...p}><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>;
default: return <svg {...p}><circle cx="12" cy="12" r="10"/></svg>;
}
}
/* ── CalendarMini ────────────────────────────────────────────────
Minimal calendar surface for the right rail. Static — accepts a
`today` index and an optional `markedDays` map for severity dots. */
function CalendarMini({ month = 'April 2026', today = 26, markedDays = {} }) {
const dows = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
// April 2026 starts on Wednesday — empty cells for S/M/T
const startOffset = 3;
const daysInMonth = 30;
const cells = [...Array(startOffset).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)];
return (
<div>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 12,
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg-1)', fontWeight: 600 }}>{month}</span>
<div style={{ display: 'flex', gap: 4 }}>
<button style={navBtn}></button>
<button style={navBtn}></button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{dows.map((d, i) => (
<div key={`dow-${i}`} style={{
fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-disabled)',
textAlign: 'center', padding: '4px 0', fontWeight: 600,
}}>{d}</div>
))}
{cells.map((day, i) => {
if (day === null) return <div key={`empty-${i}`} />;
const mark = markedDays[day];
const isToday = day === today;
return (
<button key={`day-${day}`} style={{
position: 'relative',
padding: '6px 0', borderRadius: 4,
background: isToday ? withAlpha(H_COLORS.sky, 0.20) : 'transparent',
border: isToday ? `1px solid ${H_COLORS.sky}` : '1px solid transparent',
color: isToday ? H_COLORS.sky : 'var(--fg-1)',
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: isToday ? 700 : 500,
cursor: 'pointer', transition: 'background 120ms ease',
}}>
{day}
{mark && (
<span style={{
position: 'absolute', bottom: 2, left: '50%', transform: 'translateX(-50%)',
width: 4, height: 4, borderRadius: '50%',
background: H_COLORS[mark] || H_COLORS.amber,
}} />
)}
</button>
);
})}
</div>
</div>
);
}
const navBtn = {
background: 'transparent', border: '1px solid rgba(14,165,233,0.25)',
color: H_COLORS.sky, borderRadius: 4, width: 22, height: 22,
fontFamily: 'var(--font-mono)', fontSize: 12, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
};
/* ── ArchiveSummary ──────────────────────────────────────────────
The bar of state pills that lives at the top of the Ivanti card.
Each pill shows an Ivanti workflow state + count, color-coded. */
function ArchiveSummary({ items, activeFilter, onSelect }) {
return (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
{items.map(it => {
const c = H_COLORS[it.tone] || H_COLORS.teal;
const active = activeFilter === it.label;
return (
<button
key={it.label}
onClick={() => onSelect && onSelect(active ? null : it.label)}
style={{
flex: '1 1 60px',
padding: '8px 10px',
background: active ? withAlpha(c, 0.20) : withAlpha(c, 0.08),
border: `1px solid ${active ? c : withAlpha(c, 0.30)}`,
borderRadius: 4,
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
}}
>
<div style={{
fontSize: 16, fontWeight: 700, color: c,
textShadow: active ? `0 0 8px ${withAlpha(c, 0.5)}` : 'none', lineHeight: 1,
}}>{it.count}</div>
<div style={{
fontSize: 9, color: 'var(--fg-2)', textTransform: 'uppercase',
letterSpacing: '0.06em', marginTop: 4, fontWeight: 600,
}}>{it.label}</div>
</button>
);
})}
</div>
);
}
/* ── ScrollList ──────────────────────────────────────────────────
Generic max-height scroll wrapper for the right-rail panels. */
function ScrollList({ maxHeight = 300, children }) {
return (
<div style={{
maxHeight, overflowY: 'auto',
display: 'flex', flexDirection: 'column', gap: 8,
paddingRight: 4,
}}>
{children}
</div>
);
}
/* ── EmptyState ──────────────────────────────────────────────────
Center-aligned check-circle + caption, used inside ScrollList
when a panel has no items. */
function EmptyState({ icon = 'check', tone = 'green', children }) {
const c = H_COLORS[tone] || H_COLORS.green;
return (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<div style={{ display: 'inline-flex', marginBottom: 8 }}>
<HomeIcon name={icon} size={32} color={c} />
</div>
<p style={{
margin: 0, fontFamily: 'var(--font-mono)', fontSize: 12,
color: 'var(--fg-2)', fontStyle: 'italic',
}}>{children}</p>
</div>
);
}
/* ── helpers ─────────────────────────────────────────────────── */
function withAlpha(hex, a) {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
window.HOME = {
COLORS: H_COLORS,
StatCard, HomeCard, CardTitle, HomeButton, SeverityBadge, StatusBadge,
HomeInput, HomeSelect, FieldLabel, ResultBanner,
BigStat, MiniTicket, CVERow, VendorEntry,
HomeIcon, CalendarMini, ArchiveSummary, ScrollList, EmptyState,
withAlpha,
};