From bad66785ca1a94d6fe50c6bb9039d36ed1576e68 Mon Sep 17 00:00:00 2001 From: B129000 Date: Fri, 7 Aug 2026 14:11:17 -0400 Subject: [PATCH] Fix accessibility audit findings --- README.md | 19 +++-- css/style.css | 117 ++++++++++++++++++++++------- index.html | 105 +++++++++++++------------- js/script.js | 198 ++++++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 322 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 6477100..0f8852c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Inputs (4 numbers) │ ▼ compute() [line 678] - ├── Validation: flags convX > visX or any negative value (red border + clears results) + ├── Validation: requires safe whole numbers, visitors > 0, and conversions between 0 and visitors ├── Conversion rates: pA = xA / nA, pB = xB / nB ├── Relative improvement: (pB − pA) / pA ├── Absolute difference: pB − pA (percentage points) @@ -149,9 +149,9 @@ All UI strings are in the `STRINGS` object at lines 555–606. Two patterns are **Static labels (not touched by JS) — use HTML attributes:** ```html Your English text -Votre texte français +Votre texte français ``` -CSS hides the inactive language automatically via `[data-lang]` on ``. +CSS hides the inactive language automatically via `[data-lang]` on ``. JavaScript also updates the root `lang` attribute when the active language changes. **Dynamic values rendered by JS — use `s().yourKey`:** `s()` returns `STRINGS[currentLanguage]`, so `s().tie` gives the right string for the active language. Add your key to both `STRINGS.en` and `STRINGS.fr`, then call it from `compute()`. @@ -162,7 +162,7 @@ CSS hides the inactive language automatically via `[data-lang]` on ``. 1. Add a display element in the HTML results area: ```html - + ``` 2. Add strings to both `STRINGS.en` and `STRINGS.fr` 3. Compute the value inside `compute()` and assign it: @@ -171,6 +171,8 @@ CSS hides the inactive language automatically via `[data-lang]` on ``. ``` 4. Call `flash($('#my-metric'))` immediately after to trigger the update animation +Do not add live-region semantics to individual result values. After all visible results have been updated, update the existing `#confidence-status` live region once with a concise English or French summary of the completed result. + --- --- @@ -195,8 +197,8 @@ Importing jStat or stdlib for a single CDF call is unjustified in a zero-depende **Live calculation, no submit button** The audience forgets to click buttons. Live updates also make validation errors immediately visible and give instant feedback while entering numbers, which reduces data entry mistakes. -**NaN propagation is intentional** -When inputs are missing or invalid, `pA` and `pB` become `NaN` and every downstream result becomes `NaN`, which renders as `—`. Do not replace missing inputs with `0` — that would produce valid-looking but meaningless results (0% conversion rate is a real, distinct state). +**Invalid input stops calculation** +Missing or invalid input stops the calculation. The interface displays a specific, programmatically associated error for each affected field, sets `aria-invalid="true"`, clears downstream results to `—`, and updates the single result-summary live region with a concise validation summary. Do not replace missing inputs with `0` — that would produce valid-looking but meaningless results (0% conversion rate is a real, distinct state). --- @@ -247,9 +249,6 @@ The test always checks whether B differs from A in either direction. For users w **No correction for multiple comparisons** If a user checks significance daily during a two-week test, the actual false positive rate inflates well above the stated confidence level. The tool provides no peeking warning. -**Validation is visual, not blocking** -When conversions exceed visitors, inputs turn red and results blank out — but there is no error message explaining why. A user on a small screen may miss the red border entirely. - **No automated tests** All statistical logic is inside `compute()` mixed with DOM manipulation. There are no unit tests. Before modifying the math, manually verify against a reference calculator using the example values in this README. @@ -270,7 +269,7 @@ For |z| > 6 the polynomial approximation degrades. In practice, if z exceeds 6 t **Handle with care:** - `phi()` — do not rewrite without verifying `phi(1.96) ≈ 0.97500` and `phi(0) = 0.5` -- `compute()` NaN propagation — do not add default fallback values of `0` for missing inputs; that silently produces wrong results +- `compute()` validation and result announcements — preserve the field-specific errors, `aria-invalid` state, and single result-summary live region; do not add live semantics to individual metrics - CSS i18n rules — do not add inline `display:` styles to elements with `data-i18n-en` or `data-i18n-fr`; it will break language-specific rendering **Recommended next improvements (priority order):** diff --git a/css/style.css b/css/style.css index fbd9177..2e5471c 100644 --- a/css/style.css +++ b/css/style.css @@ -36,6 +36,13 @@ } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +.sr-only { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; +} html, body { background: var(--bg); color: var(--ink); font-family: var(--font); font-size: var(--text-lg); line-height: 1.6; @@ -70,7 +77,6 @@ html, body { border-color: var(--border-hover); } .language-toggle:focus { - outline: none; box-shadow: 0 0 0 3px var(--focus), var(--shadow-card); } #lang-toggle .lbl-to-fr { display: inline; } @@ -197,6 +203,8 @@ html, body { .field:last-child { margin-bottom: 0; } .field-label { display: block; font-size: var(--text-md); font-weight: 500; color: var(--ink); margin-bottom: 7px; } .field-hint { display: block; font-size: var(--text-sm); font-weight: 400; color: var(--ink-3); margin-top: 6px; line-height: 1.4; } +.field-error { display: block; font-size: var(--text-sm); font-weight: 500; color: var(--red); margin-top: 6px; line-height: 1.4; } +.field-error:empty { margin-top: 0; } input[type=number] { width: 100%; padding: 13px 16px; @@ -209,7 +217,7 @@ input[type=number] { input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; } input[type=number]:hover { border-color: var(--border-hover); } -input[type=number]:focus { outline: none; border-color: var(--brand); background: #fff; box-shadow: 0 0 0 3px var(--focus); } +input[type=number]:focus { border-color: var(--brand); background: #fff; box-shadow: 0 0 0 3px var(--focus); } input[type=number].err { border-color: rgba(220,38,38,.5); background: rgba(220,38,38,.03); box-shadow: 0 0 0 3px rgba(220,38,38,.12); } /* ── Rate badge ── */ @@ -253,7 +261,7 @@ input[type=number].err { border-color: rgba(220,38,38,.5); background: rgba(220, .adv-details summary::before { content: '▶'; font-size: 9px; transition: transform var(--tr); display: inline-block; } .adv-details[open] summary::before { transform: rotate(90deg); } .adv-details summary:hover { color: var(--ink-2); } -.adv-details summary:focus { outline: none; color: var(--brand); } +.adv-details summary:focus { color: var(--brand); } /* ── Stat grid ── */ .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); border-radius: var(--radius-md); overflow: visible; margin-top: 14px; } @@ -263,44 +271,48 @@ input[type=number].err { border-color: rgba(220,38,38,.5); background: rgba(220, .stat-cell-value { font-family: var(--mono); font-size: var(--text-lg); font-weight: 400; color: var(--ink); } /* ── Interpretation table ── */ -.interp-table { width: 100%; border-collapse: collapse; min-width: 1780px; } +.interp-table-scroll { width: 100%; max-width: 100%; overflow-x: auto; } +.interp-table { width: 100%; border-collapse: collapse; min-width: 1080px; } .interp-table thead th { padding: 13px 16px; text-align: left; font-size: var(--text-md); font-weight: 500; letter-spacing: 0; color: var(--ink-3); border-bottom: 2px solid var(--border); background: var(--surface-2); } .interp-table tbody tr { border-bottom: 1px solid var(--border); transition: background var(--tr); } .interp-table tbody tr:last-child { border-bottom: none; } .interp-table tbody tr:hover { background: var(--surface-2); } .interp-table th, -.interp-table td { white-space: nowrap; } +.interp-table td { white-space: normal; } .interp-table td { padding: 15px 16px; vertical-align: top; font-size: var(--text-md); color: var(--ink-2); line-height: 1.55; } -.interp-table td:first-child { font-family: var(--mono); font-size: var(--text-lg); font-weight: 500; color: var(--ink); white-space: nowrap; width: 72px; } -.interp-table td:nth-child(2) { width: 160px; } -.interp-table td:nth-child(3) { font-size: var(--text-md); } +.interp-table td:first-child { font-family: var(--mono); font-size: var(--text-lg); font-weight: 500; color: var(--ink); } +.interp-table th:nth-child(1), +.interp-table td:nth-child(1), +.interp-table th:nth-child(2), +.interp-table td:nth-child(2) { white-space: nowrap; } +.interp-table th:nth-child(3), +.interp-table td:nth-child(3) { width: 32%; } .interp-help { padding: 15px 18px; border-top: 1px solid var(--border); } .interp-help p { font-size: var(--text-md); color: var(--ink-3); line-height: 1.6; } .tsig { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font); font-size: var(--text-md); font-weight: 500; } .tsig.ok { color: var(--green); } .tsig.no { color: var(--red); } .tsig::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex-shrink: 0; } -@media (max-width: 1800px) { - .interp-table { - min-width: 1080px; - table-layout: fixed; - } - .interp-table th:nth-child(1), - .interp-table td:nth-child(1) { width: 96px; } - .interp-table th:nth-child(2), - .interp-table td:nth-child(2) { width: 150px; } - .interp-table th:nth-child(4), - .interp-table td:nth-child(4) { width: 210px; } - .interp-table td:nth-child(3), - .interp-table td:nth-child(4), - .interp-table td:nth-child(5) { - white-space: normal; - } -} /* ── Tooltips ── */ .tt { position: relative; border-bottom: 0; cursor: help; } -.tt:focus { outline: 2px solid var(--brand); border-radius: 2px; } +.tt:focus { border-radius: 2px; } +.language-toggle:focus, +input[type=number]:focus, +.adv-details summary:focus, +.tt:focus, +.interp-table-scroll:focus { + outline: 3px solid var(--brand); + outline-offset: 3px; +} +.language-toggle:focus-visible, +input[type=number]:focus-visible, +.adv-details summary:focus-visible, +.tt:focus-visible, +.interp-table-scroll:focus-visible { + outline: 3px solid var(--brand); + outline-offset: 3px; +} .tooltip { position: absolute; left: 0; top: calc(100% + 8px); background: var(--tooltip-bg); border: 1px solid var(--tooltip-bdr); border-radius: var(--radius-md); padding: 12px 14px; max-width: 360px; min-width: 240px; width: max-content; font-size: var(--text-xs); line-height: 1.6; color: var(--tooltip-ink); box-shadow: 0 4px 16px rgba(0,0,0,.1); z-index: 100; display: none; white-space: normal; font-family: var(--font); } .tooltip .title { font-weight: 600; color: var(--tooltip-ink-strong); display: block; margin-bottom: 4px; font-size: var(--text-xs); } .tooltip::before { content: ''; position: absolute; left: 12px; top: -5px; width: 8px; height: 8px; background: var(--tooltip-bg); border-left: 1px solid var(--tooltip-bdr); border-top: 1px solid var(--tooltip-bdr); transform: rotate(45deg); } @@ -318,3 +330,56 @@ input[type=number].err { border-color: rgba(220,38,38,.5); background: rgba(220, /* ── Flash ── */ @keyframes flash { 0%{opacity:.2} 100%{opacity:1} } .updated { animation: flash .3s ease; } + +@media (max-width: 620px) { + .container { + padding: 20px 16px 48px; + } + + .tool-section-header { + flex-direction: column; + align-items: stretch; + } + + .language-toggle { + align-self: flex-start; + } + + .rate-badge, + .result-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .rate-badge-value, + .result-val { + align-self: flex-end; + max-width: 100%; + overflow-wrap: anywhere; + } + + .result-key, + .card-title { + min-width: 0; + overflow-wrap: anywhere; + } + + .stat-grid { + grid-template-columns: 1fr; + } + + .stat-cell { + min-width: 0; + } + + .stat-cell-value { + overflow-wrap: anywhere; + } + + .tooltip { + min-width: 0; + max-width: min(360px, calc(100vw - 80px)); + overflow-wrap: anywhere; + } +} diff --git a/index.html b/index.html index 0a71742..6e3da10 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ -
+
@@ -18,13 +18,13 @@

A/B Test Statistical Significance Calculator

-

Calculateur de signification statistique de test A/B

+

Calculateur de signification statistique de test A/B

Enter visitor and conversion data for two versions. Get a statistical comparison to support your decision.

-

Saisissez les données de visiteurs et de conversions pour deux versions. Obtenez une comparaison statistique pour appuyer votre décision.

+

Saisissez les données de visiteurs et de conversions pour deux versions. Obtenez une comparaison statistique pour appuyer votre décision.

-
@@ -37,29 +37,31 @@

Calculateur de signification statistique de test A/B

Version A Control - Témoin + Témoin
- + Total visitors who saw this version - Total des visiteurs ayant vu cette version + Total des visiteurs ayant vu cette version +
- + Visitors who completed the goal - Visiteurs ayant atteint l'objectif + Visiteurs ayant atteint l'objectif +
Conversion rate - Taux de conversion - + Taux de conversion +
@@ -70,29 +72,31 @@

Calculateur de signification statistique de test A/B

Version B Variant - Variante + Variante
- + Total visitors who saw this version - Total des visiteurs ayant vu cette version + Total des visiteurs ayant vu cette version +
- + Visitors who completed the goal - Visiteurs ayant atteint l'objectif + Visiteurs ayant atteint l'objectif +
Conversion rate - Taux de conversion - + Taux de conversion +
@@ -104,11 +108,11 @@

Calculateur de signification statistique de test A/B

Results summary - Résumé des résultats + Résumé des résultats
-
+
@@ -119,10 +123,10 @@

Calculateur de signification statistique de test A/B

data-tip-title-fr="Amélioration relative" data-tip-fr="Dans quelle mesure la version B performe mieux (ou moins bien) que la version A. Formule : (Taux B − Taux A) ÷ Taux A × 100."> Relative improvement - Amélioration relative + Amélioration relative - +
@@ -132,15 +136,15 @@

Calculateur de signification statistique de test A/B

data-tip-title-fr="Différence absolue (points de pourcentage)" data-tip-fr="L'écart brut entre les taux de conversion. Exemple : si la version A convertit à 5 % et la version B à 7 %, la différence absolue est de +2,00 pp."> Absolute difference - Différence absolue + Différence absolue
- +
 Statistical Details -  Détails statistiques +  Détails statistiques
@@ -150,10 +154,10 @@

Calculateur de signification statistique de test A/B

data-tip-en="Combined conversion rate across both groups. Used internally to calculate the test statistic." data-tip-title-fr="Proportion groupée" data-tip-fr="Taux de conversion combiné des deux groupes. Utilisé en interne pour calculer la statistique de test."> - Pooled proportionProportion groupée + Pooled proportionProportion groupée
-
+
@@ -162,10 +166,10 @@

Calculateur de signification statistique de test A/B

data-tip-en="How much random variation we expect in the difference between the two conversion rates." data-tip-title-fr="Erreur standard" data-tip-fr="La variation aléatoire attendue dans la différence entre les deux taux de conversion."> - Standard errorErreur standard + Standard errorErreur standard
-
+
@@ -174,10 +178,10 @@

Calculateur de signification statistique de test A/B

data-tip-en="How many standard errors the observed difference is from zero. A larger absolute value means a more reliable result." data-tip-title-fr="Score Z" data-tip-fr="Le nombre d'erreurs standard qui séparent la différence observée de zéro. Une valeur absolue plus grande signifie un résultat plus fiable."> - Z‑scoreScore Z + Z‑scoreScore Z
-
+
@@ -186,10 +190,10 @@

Calculateur de signification statistique de test A/B

data-tip-en="The chance that a difference this large happened randomly. Below 0.05 is generally considered reliable." data-tip-title-fr="Valeur p" data-tip-fr="La probabilité que cette différence soit due au hasard. En dessous de 0,05 est généralement considéré comme fiable."> - P‑valueValeur p + P‑valueValeur p
-
+
@@ -197,12 +201,13 @@

Calculateur de signification statistique de test A/B

-
-
+
+ Are my results statistically significant? - Mes résultats sont-ils statistiquement significatifs ? + Mes résultats sont-ils statistiquement significatifs ?
-
+
+
@@ -212,13 +217,13 @@

Calculateur de signification statistique de test A/B

data-tip-en="How certain you want to be that the difference is real and not due to chance." data-tip-title-fr="Niveau de confiance" data-tip-fr="À quel point vous voulez être certain que la différence est réelle et non due au hasard."> - ConfidenceConfiance + ConfidenceConfiance - - - - + + + + @@ -227,7 +232,7 @@

Calculateur de signification statistique de test A/B

Confidence level tells you how certain you can be that the difference is real. At 95% confidence, there is only a 5% chance the result happened at random. - Le niveau de confiance indique à quel point vous pouvez être certain que la différence est réelle. À 95 % de confiance, il n'y a que 5 % de chance que le résultat soit dû au hasard. + Le niveau de confiance indique à quel point vous pouvez être certain que la différence est réelle. À 95 % de confiance, il n'y a que 5 % de chance que le résultat soit dû au hasard.

@@ -236,7 +241,7 @@

Calculateur de signification statistique de test A/B

- + diff --git a/js/script.js b/js/script.js index 3369009..7ac6aed 100644 --- a/js/script.js +++ b/js/script.js @@ -17,6 +17,33 @@ const STRINGS = { `At ${c}% confidence, there is only a ${chance}% chance this difference happened by chance. Your results are strong enough to act on. ${who}`, rowNot: (c, chance) => `At ${c}% confidence, there is still a ${chance}% chance this difference happened by chance. Collect more data before deciding.`, + resultLeadA: 'Version A performs better.', + resultLeadB: 'Version B performs better.', + resultTie: 'Both versions perform equally.', + resultSignificant: pValue => `The difference is statistically significant at 95% confidence (p-value ${pValue}).`, + resultNotSignificant: pValue => `The difference is not statistically significant at 95% confidence (p-value ${pValue}).`, + resultUnavailable: 'Statistical significance cannot be calculated from these values.', + resultSummary: (outcome, rateA, rateB, significance) => + `${outcome} Version A converts at ${rateA}; Version B at ${rateB}. ${significance}`, + fields: { + visA: 'Number of visitors for Version A', + convA: 'Number of conversions for Version A', + visB: 'Number of visitors for Version B', + convB: 'Number of conversions for Version B', + }, + errors: { + requiredVisitors: field => `${field} is required. Enter a whole number greater than zero.`, + requiredConversions: field => `${field} is required. Enter a whole number equal to or greater than zero.`, + numberVisitors: field => `${field} must be a valid number. Enter a whole number greater than zero.`, + numberConversions: field => `${field} must be a valid number. Enter a whole number equal to or greater than zero.`, + wholeVisitors: field => `${field} must be a whole number greater than zero; decimals are not accepted.`, + wholeConversions: field => `${field} must be a whole number equal to or greater than zero; decimals are not accepted.`, + positiveVisitors: field => `${field} must be greater than zero.`, + nonNegativeConversions: field => `${field} must be equal to or greater than zero.`, + tooLarge: field => `${field} is too large. Enter a whole number no greater than 9,007,199,254,740,991.`, + exceedsVisitors: (conversions, visitors) => `${conversions} cannot exceed ${visitors}.`, + }, + validationSummary: errors => `${errors.length} input error${errors.length === 1 ? '' : 's'}. ${errors.join(' ')}`, rec: { 80:'Quick or low-stakes tests', 90:'Most routine A/B tests', 95:'Important decisions', 99:'High-impact or high-risk changes' }, guide: { 80: 'You accept a 20% chance the result is random. Good for fast, directional tests where the cost of being wrong is low.', @@ -26,6 +53,25 @@ const STRINGS = { }, }, fr: { + fields: { + visA: 'Le nombre de visiteurs de la version A', + convA: 'Le nombre de conversions de la version A', + visB: 'Le nombre de visiteurs de la version B', + convB: 'Le nombre de conversions de la version B', + }, + errors: { + requiredVisitors: field => `${field} est obligatoire. Saisissez un nombre entier supérieur à zéro.`, + requiredConversions: field => `${field} est obligatoire. Saisissez un nombre entier supérieur ou égal à zéro.`, + numberVisitors: field => `${field} doit être un nombre valide. Saisissez un nombre entier supérieur à zéro.`, + numberConversions: field => `${field} doit être un nombre valide. Saisissez un nombre entier supérieur ou égal à zéro.`, + wholeVisitors: field => `${field} doit être un nombre entier supérieur à zéro; les décimales ne sont pas acceptées.`, + wholeConversions: field => `${field} doit être un nombre entier supérieur ou égal à zéro; les décimales ne sont pas acceptées.`, + positiveVisitors: field => `${field} doit être supérieur à zéro.`, + nonNegativeConversions: field => `${field} doit être supérieur ou égal à zéro.`, + tooLarge: field => `${field} est trop grand. Saisissez un nombre entier ne dépassant pas 9 007 199 254 740 991.`, + exceedsVisitors: (conversions, visitors) => `${conversions} ne peut pas dépasser ${visitors}.`, + }, + validationSummary: errors => `${errors.length} erreur${errors.length === 1 ? '' : 's'} de saisie. ${errors.join(' ')}`, tie: 'Égalité — pas de gagnant clair', vA: 'Version A (Témoin) en tête', vB: 'Version B (Variante) en tête', @@ -43,6 +89,14 @@ const STRINGS = { `À ${c} % de confiance, il n'y a que ${chance} % de chance que cette différence soit due au hasard. Vos résultats sont fiables. ${who}`, rowNot: (c, chance) => `À ${c} % de confiance, il y a encore ${chance} % de chance que cette différence soit due au hasard. Collectez plus de données avant de décider.`, + resultLeadA: 'La version A est plus performante.', + resultLeadB: 'La version B est plus performante.', + resultTie: 'Les deux versions sont aussi performantes.', + resultSignificant: pValue => `La différence est statistiquement significative au niveau de confiance de 95 % (valeur p : ${pValue}).`, + resultNotSignificant: pValue => `La différence n’est pas statistiquement significative au niveau de confiance de 95 % (valeur p : ${pValue}).`, + resultUnavailable: 'La signification statistique ne peut pas être calculée à partir de ces valeurs.', + resultSummary: (outcome, rateA, rateB, significance) => + `${outcome} Le taux de conversion de la version A est de ${rateA}; celui de la version B est de ${rateB}. ${significance}`, rec: { 80:'Tests rapides ou à faible risque', 90:'La plupart des tests A/B courants', 95:'Décisions importantes', 99:'Changements à fort impact ou à risque élevé' }, guide: { 80: 'Vous acceptez 20 % de chance que le résultat soit aléatoire. Bon pour des tests rapides où le coût d\'une erreur est faible.', @@ -58,6 +112,19 @@ const $ = s => document.querySelector(s); const lang = () => html.getAttribute('data-lang') || 'en'; const s = () => STRINGS[lang()]; const fmtPct = (x, dp=2) => isFinite(x) ? (100*x).toFixed(dp) + '%' : '—'; +const fmtAnnouncementPct = x => { + if (!isFinite(x)) return '—'; + const value = (100*x).toFixed(2); + return lang() === 'fr' ? value.replace('.', ',') + ' %' : value + '%'; +}; +const fmtAnnouncementP = x => { + if (!isFinite(x)) return ''; + const value = x < 0.0001 ? '< 0.0001' : x.toFixed(4); + return lang() === 'fr' ? value.replace('.', ',') : value; +}; +const setText = (el, value) => { + if (el && el.textContent !== value) el.textContent = value; +}; /* ── Apply saved language preference ── */ (function() { @@ -65,6 +132,7 @@ const fmtPct = (x, dp=2) => isFinite(x) ? (100*x).toFixed(dp) + '%' : '—'; if (saved === 'en' || saved === 'fr') { html.setAttribute('data-lang', saved); html.setAttribute('lang', saved); + $('#lang-toggle').setAttribute('aria-label', saved === 'fr' ? 'Passer à l’anglais' : 'Switch to French'); } })(); @@ -74,19 +142,75 @@ function flash(el) { el.classList.remove('updated'); void el.offsetWidth; el.classList.add('updated'); } -function updateRates() { - const nA = Number($('#visA').value), xA = Number($('#convA').value); - const nB = Number($('#visB').value), xB = Number($('#convB').value); - const pA = (nA > 0 && xA <= nA && xA >= 0) ? xA / nA : NaN; - const pB = (nB > 0 && xB <= nB && xB >= 0) ? xB / nB : NaN; - const rA = $('#rateA'), rB = $('#rateB'); - const newA = fmtPct(pA), newB = fmtPct(pB); - if (rA.textContent !== newA) { rA.textContent = newA; flash(rA); } - if (rB.textContent !== newB) { rB.textContent = newB; flash(rB); } +const FIELD_TYPES = { + visA: 'visitors', + convA: 'conversions', + visB: 'visitors', + convB: 'conversions', +}; +const touchedFields = new Set(); + +function validateInputs() { + const S = s(), states = {}; + + Object.entries(FIELD_TYPES).forEach(([id, type]) => { + const input = $('#'+id), raw = input.value.trim(), field = S.fields[id]; + const badInput = input.validity && input.validity.badInput; + const value = Number(raw); + let error = ''; + + if (badInput || !Number.isFinite(value)) { + error = type === 'visitors' ? S.errors.numberVisitors(field) : S.errors.numberConversions(field); + } else if (raw === '') { + error = type === 'visitors' ? S.errors.requiredVisitors(field) : S.errors.requiredConversions(field); + } else if (!Number.isInteger(value)) { + error = type === 'visitors' ? S.errors.wholeVisitors(field) : S.errors.wholeConversions(field); + } else if (type === 'visitors' && value <= 0) { + error = S.errors.positiveVisitors(field); + } else if (type === 'conversions' && value < 0) { + error = S.errors.nonNegativeConversions(field); + } else if (!Number.isSafeInteger(value)) { + error = S.errors.tooLarge(field); + } + + states[id] = { + value, + error, + comparable: raw !== '' && !badInput && Number.isSafeInteger(value) && value >= 0, + }; + }); + + [['convA','visA'], ['convB','visB']].forEach(([conversionId, visitorId]) => { + const conversion = states[conversionId], visitors = states[visitorId]; + if (!conversion.error && visitors.comparable && conversion.value > visitors.value) { + conversion.error = S.errors.exceedsVisitors(S.fields[conversionId], S.fields[visitorId]); + } + }); + + return states; +} + +function renderValidation(states) { + const errors = []; + + Object.keys(FIELD_TYPES).forEach(id => { + const input = $('#'+id); + const message = touchedFields.has(id) ? states[id].error : ''; + const invalid = Boolean(message); + setText($('#'+id+'-error'), message); + input.classList.toggle('err', invalid); + input.setAttribute('aria-invalid', String(invalid)); + if (message) errors.push(message); + }); + + return errors; } -['visA','convA','visB','convB'].forEach(id => { - document.getElementById(id).addEventListener('input', () => { updateRates(); compute(); }); +Object.keys(FIELD_TYPES).forEach(id => { + document.getElementById(id).addEventListener('input', () => { + touchedFields.add(id); + compute(); + }); }); function wireHoverTooltips() { @@ -98,6 +222,7 @@ function wireHoverTooltips() { const text = el.getAttribute(`data-tip-${L}`) || el.getAttribute('data-tip-en') || ''; if (!tip) { tip = document.createElement('div'); tip.className = 'tooltip'; el.appendChild(tip); } tip.innerHTML = `${title}${text}`; + tip.setAttribute('lang', L); tip.setAttribute('data-show', 'true'); }; const hide = () => { if (tip) tip.setAttribute('data-show', 'false'); }; @@ -113,6 +238,7 @@ $('#lang-toggle').addEventListener('click', () => { const newLang = lang() === 'fr' ? 'en' : 'fr'; html.setAttribute('data-lang', newLang); html.setAttribute('lang', newLang); + $('#lang-toggle').setAttribute('aria-label', newLang === 'fr' ? 'Passer à l’anglais' : 'Switch to French'); localStorage.setItem('ab-test-calculator-lang', newLang); compute(); }); @@ -126,33 +252,34 @@ function phi(x) { } function compute() { - const nA=Number($('#visA').value), xA=Number($('#convA').value), - nB=Number($('#visB').value), xB=Number($('#convB').value); - const errA = xA > nA || nA < 0 || xA < 0; - const errB = xB > nB || nB < 0 || xB < 0; - ['visA','convA','visB','convB'].forEach((id,i) => $('#'+id).classList.toggle('err', i<2 ? errA : errB)); - if (errA || errB) { + const validation = validateInputs(); + const validationErrors = renderValidation(validation); + const nA=validation.visA.value, xA=validation.convA.value, + nB=validation.visB.value, xB=validation.convB.value; + if (validationErrors.length) { + const errorSummary = s().validationSummary(validationErrors); ['#rateA','#rateB','#improve','#abs-diff','#pooled','#se','#z','#p'] - .forEach(id => $(id) && ($(id).textContent = '—')); - $('#winner-desc').textContent = s().awaitDesc; + .forEach(id => setText($(id), '—')); + setText($('#winner-desc'), errorSummary); $('#tbody').innerHTML = ''; + setText($('#confidence-status'), errorSummary); return; } const pA = nA > 0 ? xA / nA : NaN; const pB = nB > 0 ? xB / nB : NaN; - $('#rateA').textContent = fmtPct(pA); flash($('#rateA')); - $('#rateB').textContent = fmtPct(pB); flash($('#rateB')); + setText($('#rateA'), fmtPct(pA)); flash($('#rateA')); + setText($('#rateB'), fmtPct(pB)); flash($('#rateB')); const lift = (isFinite(pA) && pA > 0 && isFinite(pB)) ? (pB - pA) / pA : NaN; const absDiff = (isFinite(pA) && isFinite(pB)) ? pB - pA : NaN; const improveEl = $('#improve'), absDiffEl = $('#abs-diff'); if (isFinite(lift)) { - improveEl.textContent = (lift > 0 ? '+' : '') + (100*lift).toFixed(2) + '%'; + setText(improveEl, (lift > 0 ? '+' : '') + (100*lift).toFixed(2) + '%'); improveEl.className = 'result-val' + (lift > 0 ? ' positive' : lift < 0 ? ' negative' : ''); - } else { improveEl.textContent = '—'; improveEl.className = 'result-val'; } + } else { setText(improveEl, '—'); improveEl.className = 'result-val'; } if (isFinite(absDiff)) { - absDiffEl.textContent = (absDiff >= 0 ? '+' : '') + (absDiff*100).toFixed(2) + ' pp'; + setText(absDiffEl, (absDiff >= 0 ? '+' : '') + (absDiff*100).toFixed(2) + ' pp'); absDiffEl.className = 'result-val' + (absDiff > 0 ? ' positive' : absDiff < 0 ? ' negative' : ''); - } else { absDiffEl.textContent = '—'; absDiffEl.className = 'result-val'; } + } else { setText(absDiffEl, '—'); absDiffEl.className = 'result-val'; } flash(improveEl); flash(absDiffEl); // SE is undefined when pooled is 0 or 1 (no variance) or either group is empty. const pooled = (nA+nB) > 0 ? (xA+xB)/(nA+nB) : NaN; @@ -161,14 +288,14 @@ function compute() { const z = (isFinite(se) && se > 0 && isFinite(pA) && isFinite(pB)) ? (pB-pA)/se : NaN; const pval = isFinite(z) ? 2*(1-phi(Math.abs(z))) : NaN; [$('#pooled'),$('#se'),$('#z'),$('#p')].forEach(flash); - $('#pooled').textContent = fmtPct(pooled); - $('#se').textContent = isFinite(se) ? se.toFixed(4) : '—'; - $('#z').textContent = isFinite(z) ? z.toFixed(2) : '—'; - $('#p').textContent = isFinite(pval) ? (pval < 0.0001 ? '< 0.0001' : pval.toFixed(4)) : '—'; + setText($('#pooled'), fmtPct(pooled)); + setText($('#se'), isFinite(se) ? se.toFixed(4) : '—'); + setText($('#z'), isFinite(z) ? z.toFixed(2) : '—'); + setText($('#p'), isFinite(pval) ? (pval < 0.0001 ? '< 0.0001' : pval.toFixed(4)) : '—'); const S = s(), hasData = nA > 0 && nB > 0, isEmpty = !hasData, isTie = !isEmpty&&pA===pB, aWins = !isEmpty&&!isTie&&pA>pB; $('#winner-block').className = 'winner-block '+((isEmpty||isTie)?'tie':(aWins?'version-a-wins':'version-b-wins')); - $('#winner').textContent = isEmpty?S.empty:(isTie?S.tie:(aWins?S.vA:S.vB)); - $('#winner-desc').textContent = ''; + setText($('#winner'), isEmpty?S.empty:(isTie?S.tie:(aWins?S.vA:S.vB))); + setText($('#winner-desc'), ''); const tbody = $('#tbody'); tbody.innerHTML = ''; for (const conf of [0.80,0.90,0.95,0.99]) { @@ -184,6 +311,15 @@ function compute() { const td5=document.createElement('td'); td5.textContent=S.guide[pct100]; tr.append(td1,td2,td3,td4,td5); tbody.appendChild(tr); } + let resultSummary = ''; + if (hasData) { + const outcome = isTie ? S.resultTie : (aWins ? S.resultLeadA : S.resultLeadB); + const significance = !isFinite(pval) + ? S.resultUnavailable + : (pval < 0.05 ? S.resultSignificant(fmtAnnouncementP(pval)) : S.resultNotSignificant(fmtAnnouncementP(pval))); + resultSummary = S.resultSummary(outcome, fmtAnnouncementPct(pA), fmtAnnouncementPct(pB), significance); + } + setText($('#confidence-status'), resultSummary); } wireHoverTooltips();
Reliable?Fiable ?What it meansCe que cela signifieWhen to use itQuand l'utiliserGuidanceConseilsReliable?Fiable ?What it meansCe que cela signifieWhen to use itQuand l'utiliserGuidanceConseils