Files

663 lines
30 KiB
React
Raw Permalink Normal View History

// 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,
};