// Loop MVP — shared UI primitives // All components are kept compact and rooted in the Loop design system tokens. // ───────────────────────────────────────────────────────────── // Eyebrow — uppercase label with orange dot // ───────────────────────────────────────────────────────────── function Eyebrow({ children, style = {} }) { return (
{children}
); } // ───────────────────────────────────────────────────────────── // Avatar — circular initials OR photo, optional tint // ───────────────────────────────────────────────────────────── function Avatar({ initials, tint = 'var(--ink)', size = 40, ring = false, photo, style = {} }) { const [loaded, setLoaded] = React.useState(!photo); const [errored, setErrored] = React.useState(false); const showPhoto = photo && !errored; return (
{!showPhoto && {initials}} {showPhoto && ( setLoaded(true)} onError={() => setErrored(true)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: loaded ? 1 : 0, transition: 'opacity 300ms var(--ease-soft)', }} /> )} {showPhoto && !loaded && {initials}}
); } // Look up a person record by name from data.js so participant rows can render with photos function findPerson(nameOrInitials) { const all = (window.LOOP_DATA && window.LOOP_DATA.people) || []; return all.find(p => p.name === nameOrInitials || p.initials === nameOrInitials); } // Avatar cluster (stacked) function AvatarStack({ people = [], size = 28, max = 4 }) { const show = people.slice(0, max); const extra = people.length - show.length; return (
{show.map((p, i) => (
))} {extra > 0 && (
+{extra}
)}
); } // ───────────────────────────────────────────────────────────── // Card — sits directly on cream, no shadow by default // ───────────────────────────────────────────────────────────── function Card({ children, lifted = false, ink = false, style = {}, onClick }) { const bg = ink ? 'var(--ink)' : lifted ? 'var(--canvas-lifted)' : 'var(--canvas-lifted)'; return (
{children}
); } // ───────────────────────────────────────────────────────────── // Pill button — Loop's signature 20px-radius button // ───────────────────────────────────────────────────────────── function Button({ children, variant = 'primary', size = 'md', onClick, style = {}, icon = null, full = false }) { const base = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8, fontFamily: 'var(--font-sans)', fontWeight: 500, letterSpacing: '-0.02em', lineHeight: 1, borderRadius: 20, border: '1.5px solid transparent', cursor: 'pointer', userSelect: 'none', transition: 'transform 120ms, background 200ms', padding: size === 'sm' ? '10px 5px' : '14px 22px', fontSize: size === 'sm' ? 14 : 16, width: full ? '100%' : 'auto', }; const variants = { primary: { background: 'var(--ink)', color: 'var(--canvas)', borderColor: 'var(--ink)' }, secondary: { background: 'transparent', color: 'var(--ink)', borderColor: 'var(--ink)' }, ghost: { background: 'transparent', color: 'var(--ink)', borderColor: 'transparent' }, signal: { background: 'var(--loop-orange)', color: 'var(--white)', borderColor: 'var(--loop-orange)' }, onInk: { background: 'var(--canvas)', color: 'var(--ink)', borderColor: 'var(--canvas)' }, }; return ( ); } // ───────────────────────────────────────────────────────────── // Icon — Lucide via the loaded CDN script (lucide.icons.) // Falls back to a tiny dot if lucide isn't available. // ───────────────────────────────────────────────────────────── function Icon({ name, size = 20, color = 'currentColor', style = {} }) { const ref = React.useRef(null); React.useEffect(() => { if (!ref.current || !window.lucide) return; ref.current.innerHTML = ''; const el = document.createElement('i'); el.setAttribute('data-lucide', name); el.style.width = size + 'px'; el.style.height = size + 'px'; el.style.color = color; ref.current.appendChild(el); try { window.lucide.createIcons({ icons: window.lucide.icons }); } catch (e) {} }, [name, size, color]); return ; } // ───────────────────────────────────────────────────────────── // Top header — title + optional back + trailing slot // ───────────────────────────────────────────────────────────── function Header({ title, eyebrow, back, trailing, style = {}, large = false }) { return (
{back && ( )} {!large && (
{eyebrow &&
{eyebrow}
}
{title}
)}
{trailing}
{large && (
{eyebrow &&
{eyebrow}
}

{title}

)}
); } // ───────────────────────────────────────────────────────────── // Bottom tab bar — 5 tabs. Sits as a normal flex child at the // bottom of the device column, so internal scrolling never // affects it (no position:absolute / no stacking-context risk). // ───────────────────────────────────────────────────────────── function TabBar({ tabs, current, onPick }) { const isPhone = useIsPhone(); return (
{tabs.map(t => { const active = current === t.key; return ( ); })}
); } // ───────────────────────────────────────────────────────────── // Scroll container — body of every screen // ───────────────────────────────────────────────────────────── function Screen({ children, style = {}, padBottom = 24 }) { return (
{children}
); } // ───────────────────────────────────────────────────────────── // Chip — small inline label // ───────────────────────────────────────────────────────────── function Chip({ children, tone = 'default', style = {} }) { const tones = { default: { bg: 'var(--canvas-whisper)', fg: 'var(--ink)' }, ink: { bg: 'var(--ink)', fg: 'var(--canvas)' }, signal: { bg: 'var(--loop-orange-light)', fg: 'var(--white)' }, success: { bg: 'rgba(31,122,77,0.12)', fg: 'var(--success)' }, danger: { bg: 'rgba(177,35,24,0.12)', fg: 'var(--danger)' }, warning: { bg: 'rgba(207,69,0,0.12)', fg: 'var(--loop-orange)' }, muted: { bg: 'transparent', fg: 'var(--slate)' }, }; const t = tones[tone] || tones.default; return ( {children} ); } // ───────────────────────────────────────────────────────────── // Row — generic tappable row in a list card // ───────────────────────────────────────────────────────────── function Row({ leading, title, subtitle, trailing, onClick, divider = true, style = {} }) { return (
{leading}
{title}
{subtitle &&
{subtitle}
}
{trailing}
); } // ───────────────────────────────────────────────────────────── // Section — eyebrow + heading + body // ───────────────────────────────────────────────────────────── function Section({ eyebrow, title, action, children, style = {} }) { return (
{(eyebrow || title || action) && (
{eyebrow &&
{eyebrow}
} {title &&

{title}

}
{action}
)} {children}
); } // Satellite CTA — white circle, docked CTA function SatelliteCTA({ icon = 'arrow-up-right', onClick, size = 48, tint, style = {} }) { return ( ); } // Status dot — small colored signal function Dot({ tone = 'default', size = 8, style = {} }) { const colors = { default: 'var(--slate)', high: 'var(--loop-orange)', med: 'var(--loop-orange-light)', low: 'var(--dust)', success: 'var(--success)', danger: 'var(--danger)', }; return ; } // Orange orbital arc — the Loop signature function OrbitalArc({ width = 320, height = 80, opacity = 0.7, style = {} }) { return ( ); } // ───────────────────────────────────────────────────────────── // CompanyLogo — programmatic brand marks on a tinted square. // Each company id maps to a distinctive geometric glyph. // ───────────────────────────────────────────────────────────── function CompanyLogo({ company, size = 36, rounded = 8 }) { if (!company) return null; const marks = { // Helix — interlocking arcs (helical strand) helix: ( ), // Caldera — concentric rings, top-right offset caldera: ( ), // Northstar — 8-point asterisk northstar: ( ), // Sandera — S-wave curve sandera: ( ), }; const mark = marks[company.id] || ( {company.initials} ); return (
{mark}
); } Object.assign(window, { Eyebrow, Avatar, AvatarStack, Card, Button, Icon, Header, TabBar, Screen, Chip, Row, Section, SatelliteCTA, Dot, OrbitalArc, findPerson, CompanyLogo, });