Download index.html from Hugdrigo/liftlab-experiment-intelligence: direct link, hf CLI and curl.
- Browser
- Download file 47 kB
-
https://huggingface.co/spaces/Hugdrigo/liftlab-experiment-intelligence/resolve/main/index.html
- Command line
-
hf download hf://spaces/Hugdrigo/liftlab-experiment-intelligence/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Hugdrigo/liftlab-experiment-intelligence/resolve/main/index.html
47 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="theme-color" content="#101a30"><meta name="description" content="LiftLab: transparent, local-first A/B experiment analysis with confidence intervals, power planning, and scenario testing."> | |
| <title>LiftLab | Experiment Intelligence Studio</title> | |
| <style> | |
| :root{--bg:#f3f5fb;--surface:#fff;--surface2:#f7f9fe;--text:#17243a;--muted:#71819b;--line:#e2e8f1;--indigo:#5654d8;--teal:#099d92;--amber:#c88a3b;--red:#d85f75;--shadow:0 16px 46px #293b5910;color-scheme:light} | |
| body.dark{--bg:#0d1726;--surface:#16243a;--surface2:#192b45;--text:#f0f5ff;--muted:#9eb0c7;--line:#30425b;--indigo:#a19bff;--teal:#58d7be;--amber:#fac271;--red:#ff91a1;--shadow:0 18px 35px #0002;color-scheme:dark} | |
| *{box-sizing:border-box}body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0;background:var(--bg);color:var(--text);line-height:1.5}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #968af3;outline-offset:2px}a{color:var(--indigo)}.shell{display:grid;grid-template-columns:256px minmax(0,1fr);min-height:100vh}.sidebar{background:#111c31;color:#b4bed0;padding:28px 16px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column;z-index:3}.brand{display:flex;align-items:center;gap:11px;padding:2px 10px 24px;color:white}.brand-icon{width:42px;height:42px;border-radius:13px;background:linear-gradient(145deg,#5f5af0,#1cc6b1);display:grid;place-items:center;font-weight:900;font-size:26px;box-shadow:0 7px 19px #3c48cb58}.brand strong{font-size:21px;letter-spacing:-.8px}.brand small{display:block;font-size:9px;letter-spacing:2.1px;color:#a8b8d0;font-weight:700}.nav-label{font-size:10px;letter-spacing:2px;text-transform:uppercase;padding:14px 13px 8px;color:#7288a4;font-weight:750}.nav{display:grid;gap:6px}.nav button{background:none;border:1px solid transparent;border-radius:10px;color:#aebdd1;padding:13px;display:flex;align-items:center;gap:11px;text-align:left;font-size:13px;font-weight:650;width:100%}.nav button span:first-child{font-size:17px;width:22px;text-align:center}.nav button.active,.nav button:hover{background:#253651;color:white;border-color:#364966}.aside-footer{margin-top:auto;border:1px solid #34445d;border-radius:13px;padding:15px;background:#1b2b43;font-size:12px;color:#c9d6e5}.aside-footer strong{display:block;color:white;margin-bottom:4px}.aside-footer p{margin:0 0 10px;font-size:11px;color:#9eb0c7}.aside-footer .tinyline{font-size:10px;color:#7f92ae}.main{min-width:0;padding:0 35px 35px;max-width:1570px;width:100%;margin:0 auto}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:25px 0 22px;border-bottom:1px solid var(--line);margin-bottom:28px}.crumb{font-size:11px;font-weight:800;letter-spacing:1.55px;color:var(--muted)}.crumb b{color:var(--indigo)}.topactions{display:flex;gap:9px;align-items:center;flex-wrap:wrap}.live{font-size:10px;letter-spacing:1px;padding:8px 10px;background:#e4f8f2;color:#0d826f;border-radius:99px;font-weight:850}.dark .live{background:#1c423c;color:#6de3c4}.btn{border:1px solid var(--line);background:var(--surface);color:var(--text);border-radius:9px;padding:10px 14px;font-size:12px;font-weight:750;box-shadow:0 3px 12px #15233c05}.btn:hover{border-color:var(--indigo)}.btn.primary{background:var(--indigo);color:#fff;border-color:transparent}.btn.soft{background:var(--surface2)}.btn.danger{color:var(--red)}.btn.small{padding:8px 10px}.hidden{display:none!important}.eyebrow{color:var(--indigo);text-transform:uppercase;font-size:11px;font-weight:900;letter-spacing:1.7px}.pagehead{display:flex;gap:18px;align-items:end;justify-content:space-between;margin-bottom:22px}.pagehead h1{font-size:31px;line-height:1.16;letter-spacing:-1.35px;margin:5px 0}.sub{color:var(--muted);font-size:13px;margin:0;max-width:650px}.hero{border-radius:18px;background:linear-gradient(114deg,#192747 2%,#222b69 53%,#324d9f);color:white;padding:30px 34px;display:flex;justify-content:space-between;gap:20px;position:relative;overflow:hidden;box-shadow:0 22px 45px #172c6b26;margin-bottom:22px}.hero:after{content:'';position:absolute;right:12%;top:-120px;width:330px;height:330px;border-radius:50%;border:54px solid #ffffff0b;pointer-events:none}.hero .eyebrow{color:#91d8ee}.hero h2{font-size:34px;line-height:1.08;letter-spacing:-1.1px;margin:9px 0 12px}.hero p{max-width:480px;color:#c8d7eb;font-size:12.5px;line-height:1.7}.hero button{background:white;border:0;color:#1e2f64;padding:11px 15px;border-radius:9px;font-weight:850;font-size:12px;margin-top:8px}.hero-stat{z-index:1;display:flex;flex-direction:column;justify-content:center;align-items:flex-end;min-width:210px}.hero-stat div{font-weight:900;font-size:45px;letter-spacing:-1.5px}.hero-stat span{font-size:11px;color:#c6d6f0;letter-spacing:1px;text-transform:uppercase}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin:0 0 20px}.card{background:var(--surface);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);min-width:0}.metric{padding:18px 19px;min-height:139px}.metric-label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;font-weight:800;display:flex;justify-content:space-between;gap:7px}.metric-label i{font-style:normal;font-size:18px;color:var(--indigo)}.metric-value{font-size:30px;font-weight:850;letter-spacing:-1.2px;margin:8px 0 0;font-variant-numeric:tabular-nums}.metric-note{font-size:11px;color:var(--muted)}.dashgrid{display:grid;grid-template-columns:1.6fr 1fr;gap:17px}.pad{padding:22px}.cardtitle{display:flex;align-items:start;justify-content:space-between;gap:14px;margin-bottom:17px}.cardtitle h3{font-size:15px;margin:0 0 2px;letter-spacing:-.3px}.cardtitle p{color:var(--muted);font-size:11px;margin:0}.tag{background:var(--surface2);border:1px solid var(--line);color:var(--muted);border-radius:30px;padding:6px 9px;font-size:10px;font-weight:800;white-space:nowrap}.chartwrap{overflow:hidden;width:100%}.chartwrap svg{display:block;width:100%;height:auto}.muted{color:var(--muted)}.block-split{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.cohort{padding:17px;border:1px solid var(--line);border-radius:12px;background:var(--surface2)}.cohort h4{font-size:11px;letter-spacing:.8px;text-transform:uppercase;color:var(--muted);margin:0 0 9px}.cohort strong{font-size:28px;letter-spacing:-1px}.cohort small{display:block;color:var(--muted);font-size:11px;margin-top:3px}.cohort.a{border-left:4px solid #7772e5}.cohort.b{border-left:4px solid #12b5ab}.insight{padding:16px;border-radius:11px;background:var(--surface2);border:1px solid var(--line);margin-top:16px;font-size:12px}.insight strong{display:block;margin-bottom:5px}.insight p{margin:0;color:var(--muted)}.section-title{margin:26px 0 13px;display:flex;justify-content:space-between;align-items:end}.section-title h2{font-size:19px;letter-spacing:-.5px;margin:0}.section-title p{font-size:12px;color:var(--muted);margin:4px 0 0}.tablewrap{overflow-x:auto}.table{width:100%;border-collapse:collapse;font-size:12px;white-space:nowrap}.table th{text-align:left;font-size:10px;letter-spacing:.8px;text-transform:uppercase;color:var(--muted);padding:12px 13px;background:var(--surface2);font-weight:850}.table td{border-top:1px solid var(--line);padding:14px 13px}.table tr:hover td{background:var(--surface2)}.table th:first-child,.table td:first-child{padding-left:21px}.table th:last-child,.table td:last-child{padding-right:21px}.table .name{font-weight:800}.table .meta{font-size:10px;color:var(--muted);margin-top:2px}.positive{color:var(--teal)}.negative{color:var(--red)}.chip{display:inline-flex;align-items:center;background:#e5f7f1;color:#078578;padding:5px 9px;border-radius:20px;font-size:10px;font-weight:800}.chip.warn{background:#fff2db;color:#a37423}.chip.bad{background:#ffebef;color:#b94963}.dark .chip{background:#244b46;color:#69dcc5}.dark .chip.warn{background:#4a3a28;color:#ffcd82}.dark .chip.bad{background:#52303a;color:#ff9bb0}.labgrid{display:grid;grid-template-columns:350px minmax(0,1fr);gap:18px}.group{display:grid;gap:6px;margin-bottom:14px}.group label{font-size:11px;color:var(--muted);font-weight:800;letter-spacing:.4px}.control,input[type=number],input[type=text],select,input[type=date]{width:100%;padding:11px 12px;background:var(--surface);border:1px solid var(--line);color:var(--text);border-radius:9px;font-size:12px}.group input[type=range]{width:100%;accent-color:var(--indigo);margin:7px 0}.grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.slider-head{display:flex;align-items:center;justify-content:space-between;font-size:11px;margin-top:7px}.slider-head strong{color:var(--indigo)}.preview{padding:14px;border:1px solid var(--line);border-radius:12px;background:var(--surface2);margin-top:12px}.preview-label{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:1px}.preview strong{font-size:25px;display:block;margin:3px 0}.divider{height:1px;background:var(--line);margin:17px 0}.results{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-bottom:17px}.result{background:var(--surface2);border-radius:11px;padding:15px;border:1px solid var(--line)}.result span{font-size:10px;text-transform:uppercase;letter-spacing:.55px;color:var(--muted);font-weight:800}.result strong{display:block;font-size:22px;letter-spacing:-.5px;margin:4px 0}.result small{color:var(--muted);font-size:10px}.bars{display:grid;gap:13px}.barline{display:grid;grid-template-columns:88px minmax(0,1fr) 70px;gap:10px;align-items:center;font-size:11px}.track{height:12px;background:var(--surface2);border-radius:20px;overflow:hidden}.fill{height:100%;background:#7772e5;border-radius:20px}.fill.b{background:#11aeaa}.footer{display:flex;justify-content:space-between;gap:15px;margin-top:35px;padding:20px 0;border-top:1px solid var(--line);font-size:10px;color:var(--muted)}.studygrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}.study{padding:18px}.study .eyebrow{font-size:10px}.study h3{font-size:16px;letter-spacing:-.3px;margin:9px 0 4px}.study p{font-size:11px;color:var(--muted);margin:0 0 13px;min-height:33px}.study .statrow{display:flex;justify-content:space-between;font-size:11px;border-top:1px solid var(--line);padding:10px 0}.study .buttons{display:flex;gap:7px;margin-top:8px}.study .buttons .btn{flex:1}.callout{border-left:4px solid var(--indigo);padding:14px 18px;background:var(--surface2);border-radius:8px;font-size:12px;color:var(--muted)}.methodgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.methodgrid h3{font-size:15px}.methodgrid p,.methodgrid li{font-size:12px;color:var(--muted);line-height:1.8}.methodgrid code{font-size:11px;color:var(--indigo)}.modalShade{position:fixed;inset:0;background:#091428a8;z-index:10;display:grid;place-items:center;padding:20px}.modal{background:var(--surface);color:var(--text);width:min(650px,100%);max-height:90vh;overflow-y:auto;border-radius:18px;padding:26px;box-shadow:0 30px 80px #0007}.modal h2{margin:0 0 4px}.modal p{color:var(--muted);font-size:12px;margin:0 0 18px}.modalfooter{display:flex;justify-content:flex-end;gap:9px;margin-top:17px}.toast{position:fixed;bottom:22px;right:22px;z-index:12;background:#15253c;color:white;border-radius:11px;padding:12px 18px;font-size:12px;box-shadow:0 12px 30px #0b173f50;opacity:0;transform:translateY(8px);pointer-events:none;transition:.2s}.toast.show{opacity:1;transform:translateY(0)} | |
| @media(max-width:1200px){.shell{grid-template-columns:210px minmax(0,1fr)}.main{padding:0 20px 25px}.labgrid{grid-template-columns:310px minmax(0,1fr)}.studygrid{grid-template-columns:repeat(2,minmax(0,1fr))}} | |
| @media(max-width:850px){.shell{display:block}.sidebar{position:relative;height:auto;padding:12px 15px}.brand{padding:0 0 9px}.brand-icon{width:34px;height:34px;font-size:20px}.brand strong{font-size:18px}.nav-label,.aside-footer{display:none}.nav{display:flex;overflow-x:auto;gap:6px}.nav button{width:auto;flex:0 0 auto;white-space:nowrap;font-size:11px;padding:9px 12px}.nav button span:first-child{width:auto;font-size:14px}.main{padding:0 16px 20px}.topbar{padding:15px 0;margin-bottom:20px}.hero{padding:24px}.hero h2{font-size:28px}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.dashgrid,.labgrid{grid-template-columns:minmax(0,1fr)}} | |
| @media(max-width:540px){.crumb{font-size:9px}.topactions .live{display:none}.pagehead{align-items:start}.pagehead h1{font-size:25px}.pagehead>.btn{padding:9px;font-size:11px}.hero{padding:21px;display:block}.hero h2{font-size:27px}.hero-stat{align-items:start;margin-top:17px}.hero-stat div{font-size:33px}.metric{min-height:115px;padding:14px}.metric-value{font-size:25px}.metric-label{font-size:9px}.metric-note{font-size:10px}.pad{padding:16px}.results{grid-template-columns:repeat(2,minmax(0,1fr))}.result strong{font-size:19px}.studygrid,.methodgrid{grid-template-columns:minmax(0,1fr)}.footer{display:block}.footer span{display:block;margin-bottom:3px}.barline{grid-template-columns:66px minmax(0,1fr) 52px;font-size:10px}.topactions{gap:5px}.topactions .btn{font-size:10px;padding:9px}.grid2{gap:8px}} | |
| @media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition:none!important}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="shell"> | |
| <aside class="sidebar"><div class="brand"><div class="brand-icon">↗</div><div><strong>LiftLab</strong><small>EXPERIMENT INTELLIGENCE</small></div></div><div class="nav-label">Workspace</div><nav class="nav" id="nav" aria-label="Sections"><button type="button" data-page="overview" class="active"><span>▦</span> Overview</button><button type="button" data-page="lab"><span>⚗</span> Experiment lab</button><button type="button" data-page="library"><span>▤</span> Experiment library</button><button type="button" data-page="method"><span>◇</span> Methodology</button></nav><div class="aside-footer"><strong>Hugdrigo / Project #93</strong><p>Local-first experimentation analytics. No APIs, servers, tracking or paid services.</p><div class="tinyline">EDUCATIONAL MODEL · SYNTHETIC DATA</div></div></aside> | |
| <main class="main"><header class="topbar"><div class="crumb">HUGDRIGO / PRODUCT ANALYTICS / <b>LIFTLAB</b></div><div class="topactions"><span class="live">● LOCAL DEMO</span><button class="btn small" id="themeBtn" type="button">☾ Theme</button><button class="btn small" data-action="export-json" type="button">↓ Backup</button></div></header> | |
| <section id="overview" data-view="overview"><div class="pagehead"><div><div class="eyebrow">Executive dashboard · Project #93</div><h1>Experiments, made clear.</h1><p class="sub">Understand conversion lift, sampling uncertainty and next actions from one transparent workspace.</p></div><button class="btn primary" data-action="new" type="button">+ New experiment</button></div> | |
| <div class="hero"><div><div class="eyebrow">MEASUREMENT / DECISION SCIENCE</div><h2>Test the change.<br>Understand the signal.</h2><p>Explore experiments with explainable statistics, realistic confidence bounds and a reproducible planning lab. Built for thoughtful decisions, not significance chasing.</p><button type="button" data-action="go-lab">Open experiment lab ↗</button></div><div class="hero-stat"><span>Current experiment · observed relative lift</span><div id="heroLift">+0.0%</div><span id="heroName">Demo experiment</span></div></div> | |
| <div class="metrics" id="overviewMetrics"></div> | |
| <div class="dashgrid"><div class="card pad"><div class="cardtitle"><div><h3>Conversion comparison</h3><p>Observed group rates with Wilson score intervals</p></div><span class="tag">Two-group proportions</span></div><div id="overviewChart" class="chartwrap"></div><div class="insight" id="overviewInsight"></div></div><div class="card pad"><div class="cardtitle"><div><h3>Current experiment</h3><p>Select any experiment to update the dashboard</p></div><span class="tag">LIVE VIEW</span></div><div class="group"><label for="activeExperiment">SELECT EXPERIMENT</label><select id="activeExperiment"></select></div><div id="overviewCounts" class="block-split"></div><div class="divider"></div><div id="overviewDetails"></div></div></div> | |
| <div class="section-title"><div><h2>Portfolio snapshot</h2><p>Quickly compare your sample studies, organized by observed lift</p></div><button class="btn small" type="button" data-action="go-library">Browse all →</button></div><div class="card tablewrap"><table class="table"><thead><tr><th>Experiment</th><th>Visitors</th><th>Lift</th><th>p-value</th><th>Assessment</th></tr></thead><tbody id="snapshotRows"></tbody></table></div> | |
| </section> | |
| <section data-view="lab" id="lab" class="hidden"><div class="pagehead"><div><div class="eyebrow">Experimentation intelligence / analytical sandbox</div><h1>Experiment Lab</h1><p class="sub">Compare conversion rates, explore uncertainty and estimate the sample size needed for a future experiment.</p></div><button class="btn" type="button" data-action="edit">Edit selected</button></div><div class="labgrid"><div><div class="card pad"><div class="cardtitle"><div><h3>Experiment settings</h3><p>Controls apply to the selected analysis</p></div><span class="tag">SYNTHETIC SAMPLE</span></div><div class="group"><label for="labExperiment">EXPERIMENT</label><select id="labExperiment"></select></div><div class="group"><label for="alpha">TWO-SIDED SIGNIFICANCE THRESHOLD</label><select id="alpha"><option value="0.05">α = 0.05 (95% confidence)</option><option value="0.01">α = 0.01 (99% confidence)</option><option value="0.10">α = 0.10 (90% confidence)</option></select></div><div class="group"><label for="planBaseline">EXPECTED BASELINE CONVERSION (%)</label><input type="number" id="planBaseline" min="0.1" max="99" step="0.1"></div><div class="group"><label for="planLift">TARGET RELATIVE LIFT (%)</label><input type="range" id="planLift" min="2" max="50" step="1"><div class="slider-head"><span>Meaningful improvement</span><strong id="liftOut">10%</strong></div></div><div class="group"><label for="planPower">TARGET POWER</label><select id="planPower"><option value="0.8">80% power</option><option value="0.9">90% power</option></select></div><div class="callout">The planner uses a normal approximation for a balanced, fixed-horizon, two-sided test. Calculate required sample size <strong>before</strong> a new test, not after looking at results.</div></div><div class="card pad" style="margin-top:16px"><div class="cardtitle"><div><h3>Plan a new test</h3><p>Per-variant sample size target</p></div><span class="tag">APPROXIMATION</span></div><div class="preview"><div class="preview-label">Estimated visitors per variant</div><strong id="sampleResult">—</strong><span class="muted" id="sampleNote" style="font-size:11px"></span></div></div></div><div><div class="card pad"><div class="cardtitle"><div><h3>Observed test outcome</h3><p>Independent two-proportion z-test for the selected sample</p></div><span class="tag">FIXED HORIZON ONLY</span></div><div class="results" id="labResults"></div><div id="labBars" class="bars"></div><div class="divider"></div><div id="labInterpretation" class="insight"></div></div><div class="card pad" style="margin-top:16px"><div class="cardtitle"><div><h3>Conversion scenario explorer</h3><p>Hypothetical treatment outcomes, keeping group sizes fixed</p></div><span class="tag">NOT NEW OBSERVATIONS</span></div><div class="group"><label for="deltaSlider">CHANGE TREATMENT CONVERSIONS</label><input id="deltaSlider" type="range" min="-150" max="150" step="1"><div class="slider-head"><span>Scenario adjustment</span><strong id="deltaOutput">+0</strong></div></div><div id="scenarioNumbers" class="results"></div><div id="scenarioChart" class="chartwrap"></div><p class="muted" style="font-size:11px;margin:10px 0 0">Scenario results are hypothetical. Moving the slider does not alter saved measurements or establish a real treatment effect.</p></div></div></div></section> | |
| <section data-view="library" id="library" class="hidden"><div class="pagehead"><div><div class="eyebrow">Local portfolio / editable demo studies</div><h1>Experiment library</h1><p class="sub">Create, edit, archive and restore experiments. Everything stays in your browser.</p></div><button class="btn primary" type="button" data-action="new">+ Add experiment</button></div><div class="card pad" style="margin-bottom:16px"><div class="grid2"><div class="group" style="margin:0"><label for="filterSearch">SEARCH EXPERIMENTS</label><input id="filterSearch" type="text" maxlength="80" placeholder="Name or channel"></div><div class="group" style="margin:0"><label for="filterState">FILTER BY STATUS</label><select id="filterState"><option value="all">All experiments</option><option value="active">Active only</option><option value="archived">Archived only</option></select></div></div><div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:14px"><button type="button" class="btn small" data-action="export-csv">↓ Export CSV</button><button type="button" class="btn small" data-action="export-json">↓ Export JSON</button><button type="button" class="btn small" data-action="import-json">↑ Import JSON</button><button type="button" class="btn small danger" data-action="reset">↺ Reset demo</button></div></div><div id="studyCards" class="studygrid"></div></section> | |
| <section data-view="method" id="method" class="hidden"><div class="pagehead"><div><div class="eyebrow">Transparency / methods & limitations</div><h1>What the numbers mean.</h1><p class="sub">All calculations are performed locally. Review the estimands and assumptions before relying on an analysis.</p></div></div><div class="methodgrid"><article class="card pad"><h3>1. Observed conversion & lift</h3><p>Conversion rate is successes divided by visitors. Absolute lift is treatment conversion rate minus control conversion rate (percentage points). Relative lift is absolute lift divided by control rate, when nonzero. Relative lift can be undefined if control conversion is zero.</p></article><article class="card pad"><h3>2. Two-sided p-value</h3><p>The fixed-horizon two-proportion z-test compares a pooled conversion rate under the null of equal probabilities. The p-value is a measure of data compatibility with the null, not the probability that the treatment works. Small expected cell counts invalidate this approximation.</p></article><article class="card pad"><h3>3. Confidence intervals</h3><p>Each group uses a Wilson score interval for binomial proportions. The difference in proportions uses the Newcombe hybrid score interval, based on Wilson endpoints. These intervals describe uncertainty under independent Bernoulli sampling; they are not causal guarantees.</p></article><article class="card pad"><h3>4. Power planning</h3><p>Balanced sample size follows the normal-approximation two-proportion formula with z-scores for the selected two-sided significance level and target power. This is an approximate pre-test design aid, not post-hoc achieved power.</p></article><article class="card pad"><h3>5. Decision safeguards</h3><p>Do not repeatedly peek at a fixed-horizon p-value, stop opportunistically, or change outcomes mid-test. Check randomization, assignment integrity, measurement quality, guardrail metrics, multiple comparisons and business impact. Low p-values alone are not grounds to ship.</p></article><article class="card pad"><h3>6. Privacy and scope</h3><p>Only synthetic data are included. LocalStorage and downloaded JSON/CSV are stored on your device, not synced to a server. Browser storage is not encrypted. Avoid confidential production information. No external dependency or API is required.</p></article></div><div class="callout" style="margin-top:17px">LiftLab is a transparent educational prototype, not an automated decision system or professional statistical review. It does not prove causal impact or replace experimental design and domain expertise.</div></section> | |
| <footer class="footer"><span>LiftLab © 2026 · Hugdrigo / Project #93 · MIT License</span><span>Local-first · Transparent methodology · Synthetic demo</span></footer></main></div> | |
| <div id="editShade" class="modalShade hidden" role="dialog" aria-modal="true" aria-labelledby="editorTitle"><div class="modal"><h2 id="editorTitle">New experiment</h2><p>Record a two-variant conversion experiment. Whole-number visitor and conversion counts only.</p><form id="editForm"><div class="grid2"><div class="group"><label for="editName">EXPERIMENT NAME</label><input id="editName" type="text" minlength="3" maxlength="65" required></div><div class="group"><label for="editChannel">CHANNEL / CATEGORY</label><input id="editChannel" type="text" maxlength="45" required></div></div><div class="grid2"><div class="group"><label for="editA">CONTROL VISITORS</label><input id="editA" type="number" min="1" max="2000000" step="1" required></div><div class="group"><label for="editAC">CONTROL CONVERSIONS</label><input id="editAC" type="number" min="0" max="2000000" step="1" required></div><div class="group"><label for="editB">TREATMENT VISITORS</label><input id="editB" type="number" min="1" max="2000000" step="1" required></div><div class="group"><label for="editBC">TREATMENT CONVERSIONS</label><input id="editBC" type="number" min="0" max="2000000" step="1" required></div></div><div id="editError" class="muted" role="alert" style="font-size:11px"></div><div class="modalfooter"><button id="cancelEdit" class="btn" type="button">Cancel</button><button class="btn primary" type="submit">Save experiment</button></div></form></div></div> | |
| <div id="toast" role="status" aria-live="polite" class="toast"></div><input type="file" id="jsonInput" accept=".json,application/json" hidden> | |
| <script> | |
| ; | |
| (function(){ | |
| const VERSION=1,KEY='hugdrigo-liftlab-project93-v1'; | |
| const EXAMPLES=[ | |
| {id:'checkout',name:'Streamlined checkout',channel:'E-commerce',a:8500,ac:1020,b:8520,bc:1150,archived:false}, | |
| {id:'headline',name:'Homepage headline refresh',channel:'Website',a:4200,ac:455,b:4180,bc:474,archived:false}, | |
| {id:'signup',name:'One-step onboarding',channel:'SaaS onboarding',a:7300,ac:1010,b:7250,bc:1098,archived:false}, | |
| {id:'email',name:'Welcome email CTA',channel:'Lifecycle marketing',a:2500,ac:260,b:2550,bc:249,archived:false}, | |
| {id:'pricing',name:'Annual plan highlight',channel:'Subscription pricing',a:6100,ac:506,b:6000,bc:570,archived:false}, | |
| {id:'banner',name:'Product page trust banner',channel:'Conversion UX',a:3900,ac:395,b:3930,bc:430,archived:false}, | |
| {id:'mobile',name:'Mobile form redesign',channel:'Mobile web',a:5400,ac:702,b:5480,bc:690,archived:false}, | |
| {id:'recommendations',name:'Personalized suggestions',channel:'Merchandising',a:4600,ac:432,b:4650,bc:498,archived:false} | |
| ]; | |
| const fresh=()=>({version:VERSION,experiments:JSON.parse(JSON.stringify(EXAMPLES)),selected:'checkout',page:'overview',theme:'light',alpha:.05,power:.8,baseline:12,lift:12,scenarioDelta:0,search:'',filter:'all'}); | |
| const $=id=>document.getElementById(id),copy=o=>JSON.parse(JSON.stringify(o)),esc=t=>String(t).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| const fmt=(n,d=0)=>Number(n).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d}),percent=(n,d=2)=>fmt(n*100,d)+'%', signed=(n,d=2)=>((n>0?'+':'')+fmt(n,d)), clamp=(v,l,h)=>Math.max(l,Math.min(h,v)); | |
| const csvSafe=s=>{const t=String(s??'');return '"'+(/^[\s]*[=+@\-\t\r]/.test(t)?"'":'')+t.replace(/"/g,'""')+'"'}; | |
| const goodInt=(v,lo,hi)=>Number.isInteger(v)&&v>=lo&&v<=hi; | |
| function validExp(e){return !!e&&typeof e==='object'&&!Array.isArray(e)&&typeof e.id==='string'&&/^[-\w]{1,90}$/.test(e.id)&&typeof e.name==='string'&&e.name.trim().length>=3&&e.name.length<=65&&typeof e.channel==='string'&&e.channel.trim().length>0&&e.channel.length<=45&&goodInt(e.a,1,2000000)&&goodInt(e.ac,0,e.a)&&goodInt(e.b,1,2000000)&&goodInt(e.bc,0,e.b)&&typeof e.archived==='boolean'} | |
| function validState(v){return !!v&&typeof v==='object'&&!Array.isArray(v)&&v.version===VERSION&&Array.isArray(v.experiments)&&v.experiments.length>0&&v.experiments.length<=100&&v.experiments.every(validExp)&&new Set(v.experiments.map(e=>e.id)).size===v.experiments.length&&typeof v.selected==='string'&&v.experiments.some(e=>e.id===v.selected&&!e.archived)&&['overview','lab','library','method'].includes(v.page)&&['light','dark'].includes(v.theme)&&[.01,.05,.1].includes(v.alpha)&&[.8,.9].includes(v.power)&&typeof v.baseline==='number'&&v.baseline>=.1&&v.baseline<=99&&goodInt(v.lift,2,50)&&goodInt(v.scenarioDelta,-150,150)&&typeof v.search==='string'&&v.search.length<=80&&['all','active','archived'].includes(v.filter)} | |
| let state=fresh(),editing=null,toastTimer; | |
| try{const v=JSON.parse(localStorage.getItem(KEY)||'null');if(validState(v))state=v;}catch(_e){} | |
| function save(){try{localStorage.setItem(KEY,JSON.stringify(state))}catch(_e){}} | |
| const selected=()=>state.experiments.find(e=>e.id===state.selected)||state.experiments.find(e=>!e.archived); | |
| function erf(x){const s=x<0?-1:1;x=Math.abs(x);const t=1/(1+.3275911*x);return s*(1-(((((1.061405429*t-1.453152027)*t)+1.421413741)*t-.284496736)*t+.254829592)*t*Math.exp(-x*x));} | |
| function normalCDF(x){return .5*(1+erf(x/Math.SQRT2));} | |
| function normalInv(p){if(!(p>0&&p<1))throw Error('Invalid normal quantile');let lo=-10,hi=10;for(let k=0;k<60;k++){const mid=(lo+hi)/2;if(normalCDF(mid)<p)lo=mid;else hi=mid;}return(lo+hi)/2;} | |
| function wilson(x,n,z){const p=x/n,z2=z*z,base=1+z2/n,c=(p+z2/(2*n))/base,w=(z/base)*Math.sqrt(p*(1-p)/n+z2/(4*n*n));return [clamp(c-w,0,1),clamp(c+w,0,1)];} | |
| function analyze(e,alpha=.05){const p1=e.ac/e.a,p2=e.bc/e.b,diff=p2-p1,pooled=(e.ac+e.bc)/(e.a+e.b),se=Math.sqrt(pooled*(1-pooled)*(1/e.a+1/e.b)),z=se>0?diff/se:0,pval=se>0?clamp(2*(1-normalCDF(Math.abs(z))),0,1):1,q=normalInv(1-alpha/2),w1=wilson(e.ac,e.a,q),w2=wilson(e.bc,e.b,q),lower=diff-Math.sqrt((p2-w2[0])**2+(w1[1]-p1)**2),upper=diff+Math.sqrt((w2[1]-p2)**2+(p1-w1[0])**2),adequate=Math.min(e.a*pooled,e.a*(1-pooled),e.b*pooled,e.b*(1-pooled))>=5;return {p1,p2,diff,relative:p1>0?diff/p1:null,pval,z,ci:[lower,upper],w1,w2,adequate,decision:adequate&&pval<alpha?'signal':'inconclusive',total:e.a+e.b};} | |
| function sampleSize(p,relative,alpha,power){const p1=p/100,p2=p1*(1+relative/100);if(p2>=1||p1<=0)return null;const zA=normalInv(1-alpha/2),zB=normalInv(power),pbar=(p1+p2)/2,num=(zA*Math.sqrt(2*pbar*(1-pbar))+zB*Math.sqrt(p1*(1-p1)+p2*(1-p2)))**2;return Math.ceil(num/((p2-p1)**2));} | |
| function notify(t){$('toast').textContent=t;$('toast').classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').classList.remove('show'),3100);} | |
| function metric(t,v,caption,icon){return `<article class="card metric"><div class="metric-label"><span>${t}</span><i>${icon}</i></div><div class="metric-value">${v}</div><div class="metric-note">${caption}</div></article>`} | |
| function status(m){if(!m.adequate)return ['Insufficient counts','warn'];if(m.pval<state.alpha)return ['Statistical signal',''];return ['Inconclusive','warn']} | |
| function chart(e,m){const w=620,H=260,left=57,right=25,top=24,bottom=53,high=Math.min(1,Math.max(m.w1[1],m.w2[1]) * 1.37+.015),ticks=[0,.25,.5,.75,1];const Y=x=>top+(H-top-bottom)*(1-x/high);let out=`<svg viewBox="0 0 ${w} ${H}" role="img" aria-label="Conversion rates and 95 percent confidence intervals"><desc>Control ${percent(m.p1)}, treatment ${percent(m.p2)}. Error bars show Wilson intervals at the configured confidence level.</desc>`;for(const tick of ticks){const v=tick*high;const y=Y(v);out+=`<line x1="${left}" x2="${w-right}" y1="${y}" y2="${y}" stroke="var(--line)" stroke-dasharray="4 5"/><text x="${left-8}" y="${y+4}" font-size="11" text-anchor="end" fill="var(--muted)">${(v*100).toFixed(1)}%</text>`;}[m.p1,m.p2].forEach((p,i)=>{const x=195+i*250,base=Y(0),barY=Y(p),ci=i?m.w2:m.w1,color=i?'#0aa79e':'#7069e5';out+=`<rect x="${x-65}" y="${barY}" width="130" height="${Math.max(0,base-barY)}" rx="5" fill="${color}" opacity=".84"/><line x1="${x}" x2="${x}" y1="${Y(ci[1])}" y2="${Y(ci[0])}" stroke="var(--text)" stroke-width="2.4"/><line x1="${x-17}" x2="${x+17}" y1="${Y(ci[1])}" y2="${Y(ci[1])}" stroke="var(--text)" stroke-width="2"/><line x1="${x-17}" x2="${x+17}" y1="${Y(ci[0])}" y2="${Y(ci[0])}" stroke="var(--text)" stroke-width="2"/><text x="${x}" y="${Math.max(14,barY-15)}" text-anchor="middle" font-size="15" font-weight="800" fill="var(--text)">${percent(p)}</text><text x="${x}" y="${H-17}" text-anchor="middle" font-size="12" fill="var(--muted)">${i?'Treatment B':'Control A'}</text>`;});return out+'</svg>';} | |
| function renderOverview(){const e=selected(),m=analyze(e,state.alpha),all=state.experiments.filter(x=>!x.archived),sc=all.map(x=>({e:x,m:analyze(x,state.alpha)})),signals=sc.filter(x=>x.m.decision==='signal').length,visitors=sc.reduce((a,x)=>a+x.m.total,0);$('heroLift').textContent=m.relative===null?'N/A':signed(m.relative*100,1)+'%';$('heroName').textContent=e.name;$('overviewMetrics').innerHTML=metric('Active experiments',fmt(all.length),'Across sample portfolio','◫')+metric('Visitors assigned',fmt(visitors),'Both variants combined','◉')+metric('Signals at alpha',fmt(signals),'Unadjusted exploratory counts','↗')+metric('Selected lift',signed(m.diff*100,2)+' pp','Treatment minus control','◎');$('overviewChart').innerHTML=chart(e,m);const message=m.decision==='signal'?`The observed data are inconsistent with the equal-rate null at α = ${state.alpha}. This is not proof of a causal effect.`:m.adequate?'The difference does not meet the selected significance threshold. This is not evidence of equivalence.':'Expected outcome counts are low, so the normal approximation is unreliable.';$('overviewInsight').innerHTML=`<strong>${status(m)[0]}</strong><p>${message} Two-sided p-value: ${m.pval<.0001?'<0.0001':fmt(m.pval,4)}. Estimated absolute lift: ${signed(m.diff*100,2)} percentage points.</p>`;$('activeExperiment').innerHTML=all.map(x=>`<option value="${esc(x.id)}" ${e.id===x.id?'selected':''}>${esc(x.name)}</option>`).join('');$('overviewCounts').innerHTML=`<div class="cohort a"><h4>Control A</h4><strong>${percent(m.p1)}</strong><small>${fmt(e.ac)} / ${fmt(e.a)} conversions</small></div><div class="cohort b"><h4>Treatment B</h4><strong>${percent(m.p2)}</strong><small>${fmt(e.bc)} / ${fmt(e.b)} conversions</small></div>`;$('overviewDetails').innerHTML=`<div class="statrow" style="display:flex;justify-content:space-between;font-size:12px"><span class="muted">Relative lift</span><strong>${m.relative===null?'N/A':signed(m.relative*100,2)+'%'}</strong></div><div class="statrow" style="display:flex;justify-content:space-between;font-size:12px;margin-top:10px"><span class="muted">${fmt((1-state.alpha)*100,0)}% difference CI</span><strong>${signed(m.ci[0]*100,2)} to ${signed(m.ci[1]*100,2)} pp</strong></div><div style="margin-top:14px"><button class="btn primary" type="button" data-action="go-lab">Analyze in lab →</button></div>`;$('snapshotRows').innerHTML=sc.sort((a,b)=>(b.m.relative??-999)-(a.m.relative??-999)).slice(0,6).map(({e,m})=>`<tr><td><div class="name">${esc(e.name)}</div><div class="meta">${esc(e.channel)}</div></td><td>${fmt(m.total)}</td><td class="${m.diff>=0?'positive':'negative'}">${signed(m.diff*100,2)} pp</td><td>${fmt(m.pval,4)}</td><td><span class="chip ${m.decision==='signal'?'':'warn'}">${status(m)[0]}</span></td></tr>`).join('');} | |
| function renderLab(){const e=selected(),m=analyze(e,state.alpha),base=state.baseline,sz=sampleSize(base,state.lift,state.alpha,state.power);$('labExperiment').innerHTML=state.experiments.filter(x=>!x.archived).map(x=>`<option value="${esc(x.id)}" ${x.id===e.id?'selected':''}>${esc(x.name)}</option>`).join('');$('alpha').value=String(state.alpha);$('planPower').value=String(state.power);$('planBaseline').value=String(state.baseline);$('planLift').value=String(state.lift);$('liftOut').textContent=state.lift+'%';$('sampleResult').textContent=sz===null?'Not feasible':fmt(sz);$('sampleNote').textContent=sz===null?'Choose a smaller lift or baseline.':`About ${fmt(sz*2)} visitors total, assuming equal allocation.`;const rel=m.relative===null?'N/A':signed(m.relative*100,2)+'%';$('labResults').innerHTML=`<div class="result"><span>Relative lift</span><strong>${rel}</strong><small>B vs A</small></div><div class="result"><span>Absolute lift</span><strong>${signed(m.diff*100,2)} pp</strong><small>Conversion-point change</small></div><div class="result"><span>Two-sided p-value</span><strong>${m.pval<.0001?'<0.0001':fmt(m.pval,4)}</strong><small>Pooled z-test</small></div><div class="result"><span>Difference interval</span><strong style="font-size:15px">${signed(m.ci[0]*100,2)} to ${signed(m.ci[1]*100,2)}</strong><small>Newcombe interval in pp</small></div><div class="result"><span>Total visitors</span><strong>${fmt(m.total)}</strong><small>Across both variants</small></div><div class="result"><span>Outcome</span><strong style="font-size:15px">${status(m)[0]}</strong><small>At α=${state.alpha}</small></div>`;$('labBars').innerHTML=`<div class="barline"><span>Control A</span><div class="track"><div class="fill" style="width:${m.p1*100}%"></div></div><b>${percent(m.p1)}</b></div><div class="barline"><span>Treatment B</span><div class="track"><div class="fill b" style="width:${m.p2*100}%"></div></div><b>${percent(m.p2)}</b></div>`;$('labInterpretation').innerHTML=`<strong>${status(m)[0]}</strong><p>${m.adequate?(m.pval<state.alpha?'The pre-specified null would be rejected at this threshold. Effect size, interval width and guardrails still matter.':'The interval contains values compatible with small improvements or declines. Avoid interpreting a non-significant result as “no effect.”'):'Expected cell counts are below five. Treat the normal-approximation test as unreliable.'}</p>`;const lo=-Math.min(150,e.bc),hi=Math.min(150,e.b-e.bc);state.scenarioDelta=clamp(state.scenarioDelta,lo,hi);$('deltaSlider').min=String(lo);$('deltaSlider').max=String(hi);$('deltaSlider').value=String(state.scenarioDelta);$('deltaOutput').textContent=signed(state.scenarioDelta,0);const sim={...e,bc:e.bc+state.scenarioDelta},sm=analyze(sim,state.alpha);$('scenarioNumbers').innerHTML=`<div class="result"><span>Hypothetical B rate</span><strong>${percent(sm.p2)}</strong><small>${fmt(sim.bc)} conversions</small></div><div class="result"><span>Hypothetical lift</span><strong>${signed(sm.diff*100,2)} pp</strong><small>Absolute change</small></div><div class="result"><span>Hypothetical p</span><strong>${fmt(sm.pval,4)}</strong><small>Exploratory only</small></div>`;$('scenarioChart').innerHTML=chart(sim,sm);} | |
| function renderLibrary(){const q=state.search.toLowerCase(),list=state.experiments.filter(e=>(state.filter==='all'||(state.filter==='archived')===e.archived)&&(!q||e.name.toLowerCase().includes(q)||e.channel.toLowerCase().includes(q)));$('filterSearch').value=state.search;$('filterState').value=state.filter;$('studyCards').innerHTML=list.length?list.map(e=>{const m=analyze(e,state.alpha),st=status(m),cls=st[1];return `<article class="card study"><div style="display:flex;justify-content:space-between;gap:6px"><span class="eyebrow">${esc(e.channel)}</span><span class="chip ${e.archived?'bad':cls}">${e.archived?'Archived':st[0]}</span></div><h3>${esc(e.name)}</h3><p>${fmt(m.total)} visitors · ${fmt(e.ac+e.bc)} conversions · Sample data</p><div class="statrow"><span class="muted">Observed absolute lift</span><strong class="${m.diff>=0?'positive':'negative'}">${signed(m.diff*100,2)} pp</strong></div><div class="statrow"><span class="muted">Two-sided p-value</span><strong>${fmt(m.pval,4)}</strong></div><div class="buttons"><button class="btn small" data-open="${esc(e.id)}" ${e.archived?'disabled':''}>Analyze</button><button class="btn small" data-edit="${esc(e.id)}">Edit</button><button class="btn small" data-archive="${esc(e.id)}">${e.archived?'Restore':'Archive'}</button></div></article>`}).join(''):'<div class="card pad">No experiments match the current search.</div>';} | |
| function render(){if(!state.experiments.some(e=>e.id===state.selected&&!e.archived)){state.selected=state.experiments.find(e=>!e.archived)?.id||state.experiments[0].id;}document.body.classList.toggle('dark',state.theme==='dark');$('themeBtn').textContent=state.theme==='dark'?'☀ Theme':'☾ Theme';document.querySelectorAll('[data-view]').forEach(e=>e.classList.toggle('hidden',e.dataset.view!==state.page));document.querySelectorAll('#nav [data-page]').forEach(e=>{e.classList.toggle('active',e.dataset.page===state.page);e.setAttribute('aria-current',e.dataset.page===state.page?'page':'false');});renderOverview();renderLab();renderLibrary();save();} | |
| function go(p){state.page=p;render();window.scrollTo(0,0);} | |
| function editor(id){editing=id||null;const e=id?state.experiments.find(v=>v.id===id):null;$('editorTitle').textContent=e?'Edit experiment':'New experiment';for(const [k,val] of [['editName',e?.name||''],['editChannel',e?.channel||''],['editA',e?.a??1000],['editAC',e?.ac??100],['editB',e?.b??1000],['editBC',e?.bc??110]])$(k).value=String(val);$('editError').textContent='';$('editShade').classList.remove('hidden');$('editName').focus();} | |
| function closeEditor(){editing=null;$('editShade').classList.add('hidden');} | |
| function fileDownload(filename,txt,type){const u=URL.createObjectURL(new Blob([txt],{type:type+';charset=utf-8'}));const a=document.createElement('a');a.href=u;a.download=filename;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),1500);} | |
| function downloadCSV(){const rows=[['experiment','channel','control_visitors','control_conversions','treatment_visitors','treatment_conversions','absolute_lift_pp','relative_lift_pct','two_sided_p','status','archived']];for(const e of state.experiments){const m=analyze(e,state.alpha);rows.push([e.name,e.channel,e.a,e.ac,e.b,e.bc,(m.diff*100).toFixed(4),m.relative===null?'':(m.relative*100).toFixed(4),m.pval.toFixed(6),status(m)[0],e.archived]);}fileDownload('liftlab-project-93-experiments.csv',rows.map(r=>r.map(csvSafe).join(',')).join('\r\n'),'text/csv');notify('CSV exported');} | |
| function downloadJSON(){fileDownload('liftlab-project-93-backup.json',JSON.stringify({...state,page:'overview'},null,2),'application/json');notify('JSON backup exported');} | |
| function action(a){switch(a){case 'new':editor(null);break;case 'edit':editor(selected().id);break;case 'go-lab':go('lab');break;case 'go-library':go('library');break;case 'export-csv':downloadCSV();break;case 'export-json':downloadJSON();break;case 'import-json':$('jsonInput').click();break;case 'reset':if(confirm('Reset all local experiments to synthetic demonstration data? Export a backup first if necessary.')){const theme=state.theme;state=fresh();state.theme=theme;render();notify('Demo restored');}break;}} | |
| document.addEventListener('click',e=>{const act=e.target.closest('[data-action]');if(act)action(act.dataset.action);});$('nav').addEventListener('click',e=>{const b=e.target.closest('[data-page]');if(b)go(b.dataset.page);});$('themeBtn').addEventListener('click',()=>{state.theme=state.theme==='dark'?'light':'dark';render();}); | |
| for(const id of ['activeExperiment','labExperiment'])$(id).addEventListener('change',e=>{if(state.experiments.some(x=>x.id===e.target.value&&!x.archived)){state.selected=e.target.value;state.scenarioDelta=0;render();}}); | |
| $('alpha').addEventListener('change',e=>{state.alpha=Number(e.target.value);render();});$('planPower').addEventListener('change',e=>{state.power=Number(e.target.value);render();});$('planBaseline').addEventListener('change',e=>{const v=Number(e.target.value);if(Number.isFinite(v)&&v>=.1&&v<=99)state.baseline=v;else notify('Baseline must be between 0.1% and 99%.');render();});$('planLift').addEventListener('input',e=>{state.lift=Number(e.target.value);renderLab();save();});$('deltaSlider').addEventListener('input',e=>{state.scenarioDelta=Number(e.target.value);renderLab();save();});$('filterSearch').addEventListener('input',e=>{state.search=e.target.value.slice(0,80);renderLibrary();save();});$('filterState').addEventListener('change',e=>{state.filter=e.target.value;renderLibrary();save();}); | |
| $('studyCards').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;if(b.dataset.open){const x=state.experiments.find(y=>y.id===b.dataset.open);if(x&&!x.archived){state.selected=x.id;state.scenarioDelta=0;go('lab');}}else if(b.dataset.edit)editor(b.dataset.edit);else if(b.dataset.archive){const x=state.experiments.find(y=>y.id===b.dataset.archive);if(!x)return;if(!x.archived&&state.experiments.filter(t=>!t.archived).length<=1){notify('At least one active experiment is required.');return;}x.archived=!x.archived;if(x.archived&&state.selected===x.id)state.selected=state.experiments.find(y=>!y.archived).id;render();notify(x.archived?'Experiment archived':'Experiment restored');}}); | |
| $('cancelEdit').addEventListener('click',closeEditor);$('editShade').addEventListener('click',e=>{if(e.target===$('editShade'))closeEditor();});document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!$('editShade').classList.contains('hidden'))closeEditor();}); | |
| $('editForm').addEventListener('submit',e=>{e.preventDefault();const old=editing?state.experiments.find(v=>v.id===editing):null;const item={id:old?.id||'experiment-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,7),name:$('editName').value.trim(),channel:$('editChannel').value.trim(),a:Number($('editA').value),ac:Number($('editAC').value),b:Number($('editB').value),bc:Number($('editBC').value),archived:old?.archived||false};if(!validExp(item)){$('editError').textContent='Please enter valid names and integer counts. Conversions cannot exceed visitors.';return;}if(old)Object.assign(old,item);else{if(state.experiments.length>=100){$('editError').textContent='Maximum 100 experiments. Archive or export existing entries.';return;}state.experiments.push(item);state.selected=item.id;}closeEditor();render();notify(old?'Experiment updated':'Experiment created');}); | |
| $('jsonInput').addEventListener('change',async e=>{const f=e.target.files?.[0];e.target.value='';if(!f)return;if(f.size>350000){notify('Import rejected: maximum file size is 350 KB.');return;}try{const v=JSON.parse(await f.text());if(!validState(v))throw Error('Invalid backup schema or ranges');if(!confirm('Replace all current local experiments with this valid JSON backup?'))return;state=copy(v);render();notify('Backup restored');}catch(err){notify('Import rejected: '+String(err.message).slice(0,90));}}); | |
| window.LiftLab={fresh,analyze,wilson,normalCDF,normalInv,sampleSize,validExp,validState,getState:()=>copy(state),getSelected:()=>copy(selected()),getAnalysis:()=>analyze(selected(),state.alpha)}; | |
| render(); | |
| })(); | |
| </script> | |
| </body></html> | |