// 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}{children}
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
onPick(t.key)} style={{
display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4,
background: 'transparent', border: 'none', cursor: 'pointer',
padding: '6px 10px', width: 64,
color: active ? 'var(--ink)' : 'var(--slate)',
}}>
{t.label}
);
})}
);
}
// ─────────────────────────────────────────────────────────────
// 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,
});