// Shared UI primitives — exports to window // Viewport breakpoint hook. Drives the mobile layout branches in app.jsx — // the list/detail panes must swap rather than sit side by side, which CSS // alone can't express against these inline styles. const MOBILE_QUERY = '(max-width: 768px)'; function useIsMobile() { const [isMobile, setIsMobile] = React.useState( () => typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(MOBILE_QUERY).matches : false ); React.useEffect(() => { if (!window.matchMedia) return; const mq = window.matchMedia(MOBILE_QUERY); const onChange = e => setIsMobile(e.matches); setIsMobile(mq.matches); // Safari < 14 only has the deprecated listener API if (mq.addEventListener) mq.addEventListener('change', onChange); else mq.addListener(onChange); return () => { if (mq.removeEventListener) mq.removeEventListener('change', onChange); else mq.removeListener(onChange); }; }, []); return isMobile; } // ── Themes ────────────────────────────────────────────────────────────────── // Two palettes with identical key shapes. In both, the `slate` ramp runs // surface → text: keys 50–300 are backgrounds, 400–900 is type. Components read // only through these keys, so swapping the object recolours the entire app. // True-neutral dark: R≈G≈B on every surface, so there is no blue cast. (The // site shell's own #0d1117/#131a26 are blue-leaning; these deliberately are not.) const THEME_DARK = { indigo: { 50:'#161616',100:'#1c1c1c',200:'#2a2a2a',300:'#3d3d3d',400:'#6366F1',500:'#6d8fff',600:'#3b82f6',700:'#4d91f7',800:'#93b4fb',900:'#c7dbfd' }, blue: { 50:'#161616',100:'#1c1c1c',500:'#3B82F6',600:'#60a5fa',700:'#93b4fb' }, slate: { 50:'#0a0a0a',100:'#141414',200:'#262626',300:'#383838',400:'#8a8a8a',500:'#9e9e9e',600:'#b5b5b5',700:'#d4d4d4',800:'#ededed',900:'#fafafa' }, green: { 500:'#22c55e',600:'#4ade80' }, amber: { 500:'#f59e0b',600:'#fbbf24' }, rose: { 200:'#4c2230',500:'#f43f5e',600:'#fb7185' }, teal: { 500:'#0ea5e9',600:'#38bdf8' }, purple: { 500:'#8b5cf6',600:'#a78bfa' }, pink: { 500:'#EC4899',600:'#f472b6' }, orange: { 500:'#F97316',600:'#fb923c' }, }; // Original light palette (Tailwind-ish), restored for the light setting. const THEME_LIGHT = { indigo: { 50:'#EEF2FF',100:'#E0E7FF',200:'#C7D2FE',300:'#A5B4FC',400:'#818CF8',500:'#6366F1',600:'#4F46E5',700:'#4338CA',800:'#3730A3',900:'#312E81' }, blue: { 50:'#EFF6FF',100:'#DBEAFE',500:'#3B82F6',600:'#2563EB',700:'#1D4ED8' }, slate: { 50:'#F8FAFC',100:'#F1F5F9',200:'#E2E8F0',300:'#CBD5E1',400:'#94A3B8',500:'#64748B',600:'#475569',700:'#334155',800:'#1E293B',900:'#0F172A' }, green: { 500:'#10B981',600:'#059669' }, amber: { 500:'#F59E0B',600:'#D97706' }, rose: { 200:'#FECDD3',500:'#F43F5E',600:'#E11D48' }, teal: { 500:'#14B8A6',600:'#0D9488' }, purple: { 500:'#A855F7',600:'#9333EA' }, pink: { 500:'#EC4899',600:'#DB2777' }, orange: { 500:'#F97316',600:'#EA580C' }, }; // Surface-vs-paper tokens that can't come from the ramp (code blocks, tooltips, // tinted callouts) — they need a different treatment per theme, not an inverted one. const THEME_TOKENS = { dark: { code:'#000000', codeText:'#d4d4d4', tooltip:'#262626', warnBg:'rgba(245,158,11,.08)', warnBorder:'rgba(245,158,11,.28)', okBg:'rgba(34,197,94,.09)', okBorder:'rgba(34,197,94,.28)', shadow:'0 1px 3px rgba(0,0,0,0.5)' }, light: { code:'#0F172A', codeText:'#E2E8F0', tooltip:'#0F172A', warnBg:'#FFF7ED', warnBorder:'#FED7AA', okBg:'#F0FDF4', okBorder:'#BBF7D0', shadow:'0 1px 3px rgba(0,0,0,0.04)' }, }; const THEME_STORAGE_KEY = 'techlib-theme'; function getInitialTheme() { try { const saved = localStorage.getItem(THEME_STORAGE_KEY); if (saved === 'dark' || saved === 'light') return saved; } catch (e) { /* private mode / blocked storage — fall through */ } return 'light'; } // Resolved once at load. Reading storage repeatedly risks the palette, the // React state and the DOM attribute disagreeing on the very first paint. const INITIAL_THEME = getInitialTheme(); // Live palette. Mutated in place on theme change so the ~300 existing // `COLORS.slate[x]` reads across the app keep working untouched; the App then // re-renders via state, and every component picks up the new values. const COLORS = Object.assign({}, INITIAL_THEME === 'dark' ? THEME_DARK : THEME_LIGHT); let TOKENS = Object.assign({}, THEME_TOKENS[INITIAL_THEME]); function applyTheme(name) { const src = name === 'dark' ? THEME_DARK : THEME_LIGHT; Object.keys(src).forEach(family => { COLORS[family] = Object.assign({}, src[family]); }); Object.assign(TOKENS, THEME_TOKENS[name]); refreshFamilyColors(); const root = document.documentElement; root.setAttribute('data-theme', name); try { localStorage.setItem(THEME_STORAGE_KEY, name); } catch (e) { /* ignore */ } } // Which palette COLORS currently holds. Lets the render-time sync below skip // the rewrite when nothing changed. let CURRENT_THEME = INITIAL_THEME; // Swap the palette WITHOUT touching the DOM or storage. This has to run during // render: components read COLORS.* while rendering, so doing the swap in an // effect (which fires after the render) leaves every inline style one theme // behind — the UI would only catch up on the next reload. function syncPalette(name) { if (name === CURRENT_THEME) return; const src = name === 'dark' ? THEME_DARK : THEME_LIGHT; Object.keys(src).forEach(family => { COLORS[family] = Object.assign({}, src[family]); }); Object.assign(TOKENS, THEME_TOKENS[name]); refreshFamilyColors(); CURRENT_THEME = name; } // Owns the theme for the app; returns [name, toggle]. function useTheme() { const [theme, setTheme] = React.useState(INITIAL_THEME); // Render-time: the palette must be correct before any child reads COLORS. syncPalette(theme); // Effect-time: DOM attribute + persistence are side effects, so they stay here. React.useEffect(() => { document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem(THEME_STORAGE_KEY, theme); } catch (e) { /* ignore */ } }, [theme]); return [theme, () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))]; } // Toggle control for the top bar function ThemeToggle({ theme, onToggle, compact }) { const dark = theme === 'dark'; return ( ); } // Rebuilt on theme change (see refreshFamilyColors below) so it never holds // stale values from the palette that was active at load time. const FAMILY_COLORS = {}; function refreshFamilyColors() { Object.assign(FAMILY_COLORS, { 'Preference Research': COLORS.indigo[600], 'Portfolio & Coverage Research': COLORS.teal[600], 'Pricing Research': COLORS.purple[600], 'Driver & Correlation Research': COLORS.green[600], 'Segmentation Research': COLORS.amber[600], 'Predictive & Explainable Modelling': COLORS.rose[600], }); } refreshFamilyColors(); // Site nav — mirrors the .nav block in index.php so the library reads as part // of DataIngrid. Links are relative to this file's location under the web root. // Deliberately stays dark in both themes: it reproduces the site chrome, which // is dark site-wide, so a light-themed page still sits under the same masthead. const SITE_ROOT = '../../'; function SiteNav() { const links = [ { label:'Features', href: SITE_ROOT + 'index.php#features' }, { label:'How it works',href: SITE_ROOT + 'index.php#how-it-works' }, { label:'Data QC', href: SITE_ROOT + 'index.php#data-qc' }, { label:'PRF Stacking',href: SITE_ROOT + 'index.php#prf' }, ]; return (
DataIngrid
{links.map(l => ( {l.label} ))} Analytics
); } // Badge function Badge({ label, color, small }) { return ( {label} ); } // Icon button function IconBtn({ icon, onClick, title, active, size=32 }) { const [hov, setHov] = React.useState(false); return ( ); } // Section heading inside detail function SectionLabel({ children }) { return (
{children}
); } // Divider function Divider({ margin='16px 0' }) { return
; } // Chip row function ChipRow({ items, color }) { if (!items || !items.length) return null; return (
{items.map((item,i) => )}
); } // Copy button function CopyBtn({ text, label='Copy' }) { const [copied, setCopied] = React.useState(false); const copy = () => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); }); }; return ( ); } // Tab bar function TabBar({ tabs, active, onChange }) { // overflowY must be explicit: with only overflowX set, CSS promotes the other // axis from visible to auto, and the buttons' -1px marginBottom is enough to // raise a stray 1px vertical scrollbar. return (
{tabs.map(t => { const isActive = t.id === active; return ( ); })}
); } // Collapsible function Collapse({ title, children, defaultOpen=false }) { const [open, setOpen] = React.useState(defaultOpen); return (
{open && (
{children}
)}
); } // Stat card function StatCard({ label, value, sub, color }) { return (
{label}
{value}
{sub &&
{sub}
}
); } // Process step function ProcessStep({ step, title, detail, color }) { const [hov, setHov] = React.useState(false); return (
setHov(true)} onMouseLeave={() => setHov(false)} style={{ display:'flex', gap:16, padding:'14px 16px', borderRadius:10, background: hov ? COLORS.slate[50] : 'transparent', transition:'background 0.12s' }}>
{step}
{title}
{detail}
); } // Technique card (for the grid) function TechniqueCard({ technique, onClick, isActive, compact }) { const [hov, setHov] = React.useState(false); const c = technique.color || COLORS.indigo[600]; return (
onClick(technique.id)} onMouseEnter={() => setHov(true)} onMouseLeave={() => setHov(false)} role="button" tabIndex={0} onKeyDown={e => e.key==='Enter' && onClick(technique.id)} style={{ background:COLORS.slate[100], borderRadius:16, border: isActive ? `2px solid ${c}` : `1px solid ${COLORS.slate[200]}`, padding: compact ? '14px 16px' : '20px', cursor:'pointer', transition:'all 0.15s', boxShadow: hov ? '0 4px 20px rgba(0,0,0,0.08)' : isActive ? `0 0 0 4px ${c}20` : 'none', transform: hov && !isActive ? 'translateY(-1px)' : 'none', display:'flex', flexDirection:'column', gap:10 }}>
{technique.icon}
{technique.name}
{technique.family}
{!compact && ( <>
{technique.tagline}
{(technique.tags.businessProblems || []).slice(0,3).map((t,i) => ( ))}
)}
); } // Data table function DataTable({ headers, rows }) { return (
{headers.map((h,i) => ( ))} {rows.map((row,i) => ( {row.map((cell,j) => ( ))} ))}
{h}
{cell}
); } // Bar chart (horizontal) function HorizBarChart({ items, valueKey, labelKey, colorKey, maxValue, unit='' }) { const max = maxValue || Math.max(...items.map(i => i[valueKey])); return (
{items.map((item, i) => { const pct = (item[valueKey] / max) * 100; const color = item[colorKey] || COLORS.indigo[600]; return (
{item[labelKey]} {item[valueKey]}{unit}
); })}
); } // Empty state function EmptyState({ title, subtitle, action }) { return (
🔍
{title}
{subtitle &&
{subtitle}
} {action}
); } // Modal function Modal({ open, onClose, title, children, width=680 }) { const isMobile = useIsMobile(); if (!open) return null; return (
e.stopPropagation()} style={{ background:COLORS.slate[100], borderRadius: isMobile ? 0 : 16, width:'100%', maxWidth: isMobile ? 'none' : width, height: isMobile ? '100%' : 'auto', maxHeight: isMobile ? 'none' : '90vh', overflow:'hidden', display:'flex', flexDirection:'column', boxShadow: isMobile ? 'none' : '0 25px 60px rgba(0,0,0,0.25)', paddingTop: isMobile ? 'env(safe-area-inset-top)' : 0, paddingBottom: isMobile ? 'env(safe-area-inset-bottom)' : 0 }}>
{title}
{children}
); } // Tooltip wrapper function Tooltip({ text, children }) { const [show, setShow] = React.useState(false); return ( setShow(true)} onMouseLeave={() => setShow(false)}> {children} {show && ( {text} )} ); } Object.assign(window, { COLORS, FAMILY_COLORS, Badge, IconBtn, SectionLabel, Divider, ChipRow, CopyBtn, TabBar, Collapse, StatCard, ProcessStep, TechniqueCard, DataTable, HorizBarChart, EmptyState, Modal, Tooltip, useIsMobile, SiteNav, TOKENS, useTheme, ThemeToggle, applyTheme });