// Main App — no sidebar, tweaks panel
function App() {
const techniques = [...(window.TECHNIQUES_A || []), ...(window.TECHNIQUES_B || [])];
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"accentColor": "#3b82f6",
"cardColumns": "auto",
"compactCards": false,
"showTagline": true,
"showFamilyBadge": true,
"listPaneWidth": 380
}/*EDITMODE-END*/;
const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS);
const proposal = useProposal();
const isMobile = useIsMobile();
const [theme, toggleTheme] = useTheme();
// State
const [search, setSearch] = React.useState('');
const [activeId, setActiveId] = React.useState(null);
const [filters, setFilters] = React.useState({ family:'', budget:'', stage:'' });
const [showRecommender, setShowRecommender] = React.useState(false);
const [showBuilder, setShowBuilder] = React.useState(false);
const [filtersOpen, setFiltersOpen] = React.useState(false);
// Deep-link
React.useEffect(() => {
const hash = window.location.hash.replace('#','');
if (hash) setActiveId(hash);
// Empty hash means the user navigated back — close the detail view.
// Matters most on mobile, where detail is full-screen and back is the natural exit.
const onHash = () => { setActiveId(window.location.hash.replace('#','') || null); };
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
React.useEffect(() => {
if (activeId) window.location.hash = activeId;
else window.history.replaceState(null,'',window.location.pathname);
}, [activeId]);
const score = (t, q) => {
if (!q) return 1;
const lq = q.toLowerCase();
let s = 0;
if (t.name.toLowerCase().includes(lq)) s += 10;
if (t.shortName?.toLowerCase().includes(lq)) s += 8;
if ((t.tags.synonyms||[]).some(syn => syn.toLowerCase().includes(lq))) s += 7;
if ((t.tags.businessProblems||[]).some(b => b.toLowerCase().includes(lq))) s += 5;
if ((t.tags.objectives||[]).some(o => o.toLowerCase().includes(lq))) s += 5;
if ((t.tags.useCases||[]).some(u => u.toLowerCase().includes(lq))) s += 4;
if ((t.tags.questionTypes||[]).some(qt => qt.toLowerCase().includes(lq))) s += 3;
if ((t.tags.outputTypes||[]).some(ot => ot.toLowerCase().includes(lq))) s += 3;
if (t.tagline?.toLowerCase().includes(lq)) s += 3;
if (t.family?.toLowerCase().includes(lq)) s += 2;
return s;
};
const filtered = techniques
.filter(t => {
if (filters.family && t.family !== filters.family) return false;
if (filters.budget && t.tags.budgetLevel !== filters.budget) return false;
if (filters.stage && !(t.tags.studyStages||[]).includes(filters.stage)) return false;
if (search) return score(t, search) > 0;
return true;
})
.sort((a,b) => search ? score(b,search) - score(a,search) : 0);
const activeTech = techniques.find(t => t.id === activeId);
const families = [...new Set(techniques.map(t => t.family))];
const stages = [...new Set(techniques.flatMap(t => t.tags.studyStages||[]))];
const hasFilters = filters.family || filters.budget || filters.stage;
const clearAll = () => { setSearch(''); setFilters({ family:'', budget:'', stage:'' }); };
const accent = tweaks.accentColor || COLORS.indigo[700];
// Card grid columns
const gridCols = tweaks.compactCards
? 'repeat(auto-fill, minmax(200px, 1fr))'
: tweaks.cardColumns === 'auto'
? 'repeat(auto-fill, minmax(280px, 1fr))'
: tweaks.cardColumns === '2' ? 'repeat(2, 1fr)'
: tweaks.cardColumns === '3' ? 'repeat(3, 1fr)'
: 'repeat(auto-fill, minmax(280px, 1fr))';
return (
{/* SITE NAV — mirrors the shell in index.php so this page reads as part of
DataIngrid. Desktop only: on mobile the ~52px would eat scarce height. */}
{!isMobile && }
{/* TOP BAR — single row on desktop; on mobile it wraps to two rows
(brand + actions, then a full-width search) so nothing gets squeezed out. */}
{/* Brand */}
⚗
Technique Library
Healthcare & Pharma Research
{!isMobile && }
{/* Search — own full-width row on mobile (order:1 puts it after the buttons) */}
{/* CARD LIST PANE — hidden on mobile while a technique is open, so the
detail panel gets the full width instead of a ~10px sliver. */}
{!(isMobile && activeTech) && (