{
  "schema": "app-studio/canvas@1",
  "name": "Base Rate Calculator.html",
  "code": "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Base Rate Calculator — a KIT App by Halison</title>\n<!--\n  Base Rate Calculator · a KIT App by Halison\n\n  Bayes' theorem for diagnostic tests, shown as a probability and as a\n  population of 10,000 people, because almost everyone gets it right the\n  second way and almost nobody gets it right the first.\n\n  Self-contained: one file, no dependencies, no network. ?embed=1 skips the\n  splash for the in-article preview.\n-->\n<style>\n  :root {\n    --bg:#07070a; --panel:#101018; --line:rgba(255,255,255,0.09);\n    --fg:#f4f4f6; --dim:#9a9aa8; --indigo:#6E97FF;\n    --true:#4ade80; --false:#f87171;\n    --font: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n    --mono: var(--font);\n  }\n  * { box-sizing:border-box; }\n  html { color-scheme: dark; }\n  * { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.2) transparent; }\n  ::-webkit-scrollbar { width: 8px; height: 8px; }\n  ::-webkit-scrollbar-track { background: transparent; }\n  ::-webkit-scrollbar-thumb {\n    background: rgba(255,255,255,0.16); border-radius: 99px;\n    border: 2px solid transparent; background-clip: content-box;\n  }\n  ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.28); background-clip: content-box; }\n  ::-webkit-scrollbar-corner { background: transparent; }\n  html.embed, html.embed body { font-variant-numeric: tabular-nums; height: auto; min-height: 0; overflow: visible; }\n  html.embed #splash { display: none !important; }\n  html.embed header { display: none; }\n  html.embed .app, html.embed .app.on { min-height: 0; height: auto; }\n  html.embed main { min-height: 0; }\n  html.embed .results, html.embed .controls { overflow: visible; }\n  @media (max-width: 760px) {\n    html.embed .controls { padding: 14px 16px 12px; }\n    html.embed .group { margin-bottom: 14px; }\n    html.embed .results { padding: 16px; }\n  }\n  html, body { height:100%; }\n  body { margin:0; background:var(--bg); color:var(--fg); font-family:var(--font); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }\n  button { font:inherit; color:inherit; cursor:pointer; }\n\n  #splash { position:fixed; inset:0; z-index:50; display:grid; place-items:center; text-align:center; padding:24px;\n    background: radial-gradient(900px 500px at 50% 22%, rgba(110,151,255,0.16), transparent 70%), var(--bg); }\n  #splash h1 { font-size:clamp(24px,5vw,34px); font-weight:800; letter-spacing:-0.03em; text-transform:uppercase; margin:0 0 14px; }\n  #splash p { color:var(--dim); max-width:460px; margin:0 auto 30px; }\n  .by { font-size:11px; letter-spacing:0.22em; text-transform:uppercase; color:var(--dim); margin-bottom:24px; }\n  .btn-primary { background:var(--indigo); color:#07070a; border:0; font-weight:700; padding:11px 30px; border-radius:999px; }\n  .btn-primary:hover { filter:brightness(1.1); }\n\n  .app { display:none; min-height:100%; flex-direction:column; }\n  .app.on { display:flex; }\n  header { display:flex; align-items:center; gap:16px; padding:14px 20px; border-bottom:1px solid var(--line); flex-wrap:wrap; }\n  header h2 { margin:0; font-size:15px; font-weight:700; }\n  header .sub { color:var(--dim); font-size:12px; }\n  .spacer { flex:1 1 auto; }\n  .attrib { font-size:10px; letter-spacing:0.18em; text-transform:uppercase; color:var(--dim); background:none; border:1px solid var(--line); padding:5px 11px; border-radius:999px; }\n  .attrib:hover { color:var(--fg); border-color:rgba(255,255,255,0.25); }\n\n  main { display:grid; grid-template-columns:280px 1fr; flex:1 1 auto; min-height:0; }\n  @media (max-width:760px) { main { grid-template-columns:1fr; } }\n  .controls { border-right:1px solid var(--line); padding:18px; }\n  @media (max-width:760px) { .controls { border-right:0; border-bottom:1px solid var(--line); } }\n  .group { margin-bottom:22px; }\n  .group > label { display:block; font-size:10px; letter-spacing:0.2em; text-transform:uppercase; color:var(--dim); margin-bottom:8px; }\n  .hint { font-size:11.5px; color:var(--dim); margin-top:6px; }\n  .row { display:flex; align-items:center; gap:10px; }\n  input[type=range] { width:100%; accent-color:var(--indigo); }\n  .val { font-family:var(--mono); font-size:12px; min-width:58px; text-align:right; }\n  .presets { display:flex; flex-wrap:wrap; gap:6px; }\n  .presets button { background:var(--panel); border:1px solid var(--line); color:var(--dim); padding:6px 11px; border-radius:999px; font-size:12px; }\n  .presets button:hover { color:var(--fg); border-color:rgba(255,255,255,0.25); }\n\n  .results { padding:22px; overflow:auto; }\n  .answer { font-size:clamp(30px,6vw,46px); font-weight:800; letter-spacing:-0.03em; line-height:1.05; margin:0 0 6px; }\n  .answer small { display:block; font-size:13px; font-weight:500; letter-spacing:0; color:var(--dim); margin-top:10px; max-width:52ch; line-height:1.55; }\n\n  .people { margin-top:26px; }\n  .people h3, .bd h3 { font-size:10px; letter-spacing:0.2em; text-transform:uppercase; color:var(--dim); margin:0 0 12px; font-weight:600; }\n  .grid { display:grid; grid-template-columns:repeat(50, 1fr); gap:2px; max-width:520px; }\n  .grid i { display:block; aspect-ratio:1; border-radius:1px; background:rgba(255,255,255,0.07); }\n  .grid i.tp { background:var(--true); }\n  .grid i.fp { background:var(--false); }\n  .legend { display:flex; flex-wrap:wrap; gap:16px; margin-top:12px; font-size:12px; color:var(--dim); }\n  .legend span { display:inline-flex; align-items:center; gap:7px; }\n  .dot { width:9px; height:9px; border-radius:2px; }\n\n  .bd { margin-top:26px; padding-top:18px; border-top:1px solid var(--line); }\n  table { border-collapse:collapse; font-size:13px; }\n  td, th { padding:7px 18px 7px 0; text-align:left; }\n  th { font-size:10px; letter-spacing:0.18em; text-transform:uppercase; color:var(--dim); font-weight:600; }\n  td.num { font-family:var(--mono); }\n  .note { margin-top:22px; color:var(--dim); font-size:12.5px; max-width:62ch; }\n\n  #about { position:fixed; inset:0; z-index:60; display:none; place-items:center; background:rgba(0,0,0,0.72); padding:20px; }\n  #about.on { display:grid; }\n  .card { background:#16161f; border:1px solid var(--line); border-radius:18px; padding:26px; max-width:440px; width:100%; }\n  .card h3 { margin:0 0 4px; font-size:17px; }\n  .card .kicker { font-size:10px; letter-spacing:0.22em; text-transform:uppercase; color:var(--dim); margin-bottom:14px; }\n  .card p { color:var(--dim); font-size:13px; margin:0 0 14px; }\n  .card dl { display:grid; grid-template-columns:auto 1fr; gap:6px 18px; font-size:12.5px; margin:0 0 18px; }\n  .card dt { color:var(--dim); } .card dd { margin:0; font-family:var(--mono); }\n  .card a { color:var(--indigo); text-decoration:none; } .card a:hover { text-decoration:underline; }\n  .actions { display:flex; gap:10px; flex-wrap:wrap; }\n  .btn-ghost { background:none; border:1px solid var(--line); padding:8px 16px; border-radius:999px; font-size:13px; }\n</style>\n</head>\n<body>\n\n<div id=\"splash\">\n  <div>\n    <div class=\"by\">A KIT App by Halison</div>\n    <h1>Base Rate Calculator</h1>\n    <p>A test is 99% accurate and your result is positive. Work out what that actually means.</p>\n    <button class=\"btn-primary\" id=\"start\">Start</button>\n  </div>\n</div>\n\n<div class=\"app\" id=\"app\">\n  <header>\n    <div>\n      <h2>Base Rate Calculator</h2>\n      <div class=\"sub\">Bayes' theorem for diagnostic tests</div>\n    </div>\n    <div class=\"spacer\"></div>\n    <button class=\"attrib\" id=\"openAbout\">KIT Apps · Halison</button>\n  </header>\n\n  <main>\n    <section class=\"controls\">\n      <div class=\"group\">\n        <label for=\"prev\">How common the condition is</label>\n        <div class=\"row\"><input type=\"range\" id=\"prev\" min=\"1\" max=\"5000\" value=\"100\" /><span class=\"val\" id=\"prevVal\"></span></div>\n        <div class=\"hint\" id=\"prevHint\"></div>\n      </div>\n\n      <div class=\"group\">\n        <label for=\"sens\">Sensitivity — finds it when present</label>\n        <div class=\"row\"><input type=\"range\" id=\"sens\" min=\"50\" max=\"100\" step=\"0.1\" value=\"99\" /><span class=\"val\" id=\"sensVal\"></span></div>\n      </div>\n\n      <div class=\"group\">\n        <label for=\"spec\">Specificity — stays quiet when absent</label>\n        <div class=\"row\"><input type=\"range\" id=\"spec\" min=\"50\" max=\"100\" step=\"0.1\" value=\"99\" /><span class=\"val\" id=\"specVal\"></span></div>\n      </div>\n\n      <div class=\"group\">\n        <label>Try a scenario</label>\n        <div class=\"presets\">\n          <button data-p=\"100\" data-se=\"99\" data-sp=\"99\">Rare disease</button>\n          <button data-p=\"2\" data-se=\"99.9\" data-sp=\"99.9\">Very rare</button>\n          <button data-p=\"1000\" data-se=\"85\" data-sp=\"90\">Common, rough test</button>\n          <button data-p=\"20\" data-se=\"95\" data-sp=\"95\">Screening</button>\n        </div>\n      </div>\n    </section>\n\n    <section class=\"results\" id=\"results\"></section>\n  </main>\n</div>\n\n<div id=\"about\" role=\"dialog\" aria-modal=\"true\">\n  <div class=\"card\">\n    <div class=\"kicker\">About this KIT App</div>\n    <h3>Base Rate Calculator</h3>\n    <p>Positive predictive value, shown as a probability and as a population of 10,000, because the second form is the one intuition can actually hold on to.</p>\n    <dl><dt>Version</dt><dd>1.0.0</dd><dt>Publisher</dt><dd>Halison</dd><dt>Format</dt><dd>.kitapp</dd><dt>Runs</dt><dd>locally</dd></dl>\n    <div class=\"actions\">\n      <a class=\"btn-ghost\" href=\"https://halison.io/resources/tools\" target=\"_blank\" rel=\"noopener\">More KIT Apps</a>\n      <button class=\"btn-ghost\" id=\"closeAbout\">Close</button>\n    </div>\n  </div>\n</div>\n\n<script>\n(() => {\n  \"use strict\";\n  const $ = (id) => document.getElementById(id);\n  const POP = 10000;\n\n  if (new URLSearchParams(location.search).has(\"embed\")) {\n    document.documentElement.classList.add(\"embed\");\n    $(\"splash\").style.display = \"none\";\n    $(\"app\").classList.add(\"on\");\n    const sendHeight = () => {\n      const h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);\n      parent.postMessage({ type: \"kitapp:height\", height: Math.ceil(h) }, \"*\");\n    };\n    if (typeof ResizeObserver !== \"undefined\") new ResizeObserver(sendHeight).observe(document.body);\n    window.addEventListener(\"load\", sendHeight);\n    sendHeight();\n    setTimeout(sendHeight, 60);\n    setTimeout(sendHeight, 250);\n    setTimeout(sendHeight, 800);\n  }\n  $(\"start\").addEventListener(\"click\", () => { $(\"splash\").style.display = \"none\"; $(\"app\").classList.add(\"on\"); });\n  $(\"openAbout\").addEventListener(\"click\", () => $(\"about\").classList.add(\"on\"));\n  $(\"closeAbout\").addEventListener(\"click\", () => $(\"about\").classList.remove(\"on\"));\n  $(\"about\").addEventListener(\"click\", (e) => { if (e.target.id === \"about\") $(\"about\").classList.remove(\"on\"); });\n  document.addEventListener(\"keydown\", (e) => { if (e.key === \"Escape\") $(\"about\").classList.remove(\"on\"); });\n\n  // The prevalence slider is \"1 in N\", which is how prevalence is actually\n  // quoted, and it runs backwards so dragging right makes the condition rarer.\n  const oneIn = () => +$(\"prev\").value;\n\n  function calc() {\n    const n = oneIn();\n    const prevalence = 1 / n;\n    const sens = +$(\"sens\").value / 100;\n    const spec = +$(\"spec\").value / 100;\n\n    const withIt = POP * prevalence;\n    const without = POP - withIt;\n    const truePos = withIt * sens;\n    const falsePos = without * (1 - spec);\n    const falseNeg = withIt - truePos;\n    const trueNeg = without - falsePos;\n    const ppv = truePos + falsePos > 0 ? truePos / (truePos + falsePos) : 0;\n    const npv = trueNeg + falseNeg > 0 ? trueNeg / (trueNeg + falseNeg) : 0;\n    return { n, prevalence, sens, spec, withIt, without, truePos, falsePos, falseNeg, trueNeg, ppv, npv };\n  }\n\n  const pct = (v) => (v * 100).toFixed(1) + \"%\";\n  const round = (v) => Math.round(v).toLocaleString();\n\n  function render() {\n    const r = calc();\n    $(\"prevVal\").textContent = \"1 in \" + r.n.toLocaleString();\n    $(\"prevHint\").textContent = round(r.withIt) + \" of every 10,000 people have it.\";\n    $(\"sensVal\").textContent = (+$(\"sens\").value).toFixed(1) + \"%\";\n    $(\"specVal\").textContent = (+$(\"spec\").value).toFixed(1) + \"%\";\n\n    // The dot grid is scaled to 500 marks, not 10,000: at one dot per person\n    // the true positives in a rare-disease scenario round away to nothing.\n    const MARKS = 500, scale = MARKS / POP;\n    const tp = Math.round(r.truePos * scale), fp = Math.round(r.falsePos * scale);\n    let dots = \"\";\n    for (let i = 0; i < MARKS; i++) dots += '<i class=\"' + (i < tp ? \"tp\" : i < tp + fp ? \"fp\" : \"\") + '\"></i>';\n\n    $(\"results\").innerHTML =\n      '<p class=\"answer\">' + pct(r.ppv) +\n      '<small>That is the chance the condition is really present, given a positive result. ' +\n      'Of the ' + round(r.truePos + r.falsePos) + ' people who test positive, ' + round(r.truePos) +\n      ' actually have it and ' + round(r.falsePos) + ' do not.</small></p>' +\n\n      '<div class=\"people\"><h3>Everyone who tests positive, out of 10,000 screened</h3>' +\n      '<div class=\"grid\">' + dots + '</div>' +\n      '<div class=\"legend\">' +\n      '<span><span class=\"dot\" style=\"background:var(--true)\"></span>True positive — ' + round(r.truePos) + '</span>' +\n      '<span><span class=\"dot\" style=\"background:var(--false)\"></span>False positive — ' + round(r.falsePos) + '</span>' +\n      '<span><span class=\"dot\" style=\"background:rgba(255,255,255,0.07)\"></span>Negative — ' + round(r.trueNeg + r.falseNeg) + '</span>' +\n      '</div></div>' +\n\n      '<div class=\"bd\"><h3>All four outcomes</h3><table>' +\n      '<tr><th></th><th>Has it</th><th>Does not</th></tr>' +\n      '<tr><td>Tests positive</td><td class=\"num\">' + round(r.truePos) + '</td><td class=\"num\">' + round(r.falsePos) + '</td></tr>' +\n      '<tr><td>Tests negative</td><td class=\"num\">' + round(r.falseNeg) + '</td><td class=\"num\">' + round(r.trueNeg) + '</td></tr>' +\n      '</table></div>' +\n\n      '<p class=\"note\">' +\n      (r.ppv < 0.5\n        ? \"A positive result here is still more likely to be wrong than right. The false positives are drawn from the very large group who do not have the condition, and that group is big enough to swamp the true positives however good the test is.\"\n        : \"The condition is common enough here that a positive result carries real weight. Drag the first slider to make it rarer and watch the same test become much less informative.\") +\n      ' A negative result is right ' + pct(r.npv) + ' of the time.</p>';\n  }\n\n  for (const id of [\"prev\", \"sens\", \"spec\"]) $(id).addEventListener(\"input\", render);\n  document.querySelectorAll(\".presets button\").forEach((b) => {\n    b.addEventListener(\"click\", () => {\n      $(\"prev\").value = b.dataset.p; $(\"sens\").value = b.dataset.se; $(\"spec\").value = b.dataset.sp;\n      render();\n    });\n  });\n\n  render();\n})();\n</script>\n</body>\n</html>\n",
  "packaging": {
    "appName": "Base Rate Calculator",
    "windowTitle": "Base Rate Calculator",
    "bundleId": "com.halisonworks.baseratecalculator",
    "iconPath": null
  },
  "category": {
    "name": "Probability",
    "tint": "indigo"
  },
  "sharedAt": "2026-09-16T00:00:00.000Z",
  "watermark": "Made with KIT Apps by Halison.io"
}
