[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"playground-scrollend-event-native-scroll-stop-detection":44,"search-suggestions":58},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"slug":45,"title":46,"summary":47,"html":48,"css":49,"js":50,"config":51,"doc":53},"scrollend-event-native-scroll-stop-detection","scrollend vs. the debounce guess — interactive playground",null,"\u003Cdiv class=\"pg\">\n    \u003Ch1 class=\"pg-title\">⚡ scrollend vs. the debounce guess\u003C\u002Fh1>\n    \u003Cp class=\"pg-sub\">\n      Scroll the box with a trackpad or touchscreen and pause mid-gesture, then watch which\n      detector gets fooled. No trackpad? Panel 3 draws the same moment as a model.\n    \u003C\u002Fp>\n\n    \u003Cdiv class=\"banner info\" id=\"supportBanner\">\u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">1\u003C\u002Fspan> Scroll this list\u003C\u002Fdiv>\n      \u003Cdiv class=\"scrollbox\" id=\"box\" tabindex=\"0\" aria-label=\"Scrollable list\">\u003C\u002Fdiv>\n      \u003Cp class=\"hint\">\n        Scroll with a trackpad or touchscreen, then pause for a beat without lifting your\n        fingers. The debounce guesses \"stopped\"; \u003Ccode>scrollend\u003C\u002Fcode> waits until you let go\n        and the momentum ends. Mouse only? Grab the scrollbar thumb, drag, and hold still\n        before you let go.\n      \u003C\u002Fp>\n      \u003Cdiv class=\"btn-row\" style=\"margin-top:12px\">\n        \u003Cbutton class=\"btn\" id=\"codeScroll\">▶ Smooth-scroll from code\u003C\u002Fbutton>\n        \u003Cbutton class=\"btn ghost sm\" id=\"resetBox\">Back to top\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"hint\">\n        The button makes one smooth \u003Ccode>scrollTo()\u003C\u002Fcode> call. \u003Ccode>scrollend\u003C\u002Fcode> fires for\n        that too, once, when it lands. That's the behavior behind the gotcha in panel 4.\n      \u003C\u002Fp>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">2\u003C\u002Fspan> Two ways to detect \"stopped\"\u003C\u002Fdiv>\n      \u003Cdiv class=\"field\">\n        \u003Clabel class=\"ctl-label\" for=\"delay\">Debounce delay: \u003Cspan id=\"delayVal\">150\u003C\u002Fspan>ms\u003C\u002Flabel>\n        \u003Cinput type=\"range\" id=\"delay\" min=\"30\" max=\"400\" step=\"10\" value=\"150\" \u002F>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"hint\">Lower it, then scroll and pause mid-gesture: the guess fires during the pause, while your fingers are still down. Raise it and every real stop gets reported late.\u003C\u002Fp>\n\n      \u003Cdiv class=\"race\">\n        \u003Cdiv class=\"card\">\n          \u003Ch4>🐢 Debounced guess\u003C\u002Fh4>\n          \u003Cdiv class=\"status\" id=\"statusDebounce\">\u003Cspan class=\"dot info\" id=\"dotDebounce\">\u003C\u002Fspan>\u003Cspan id=\"textDebounce\">Waiting for a scroll…\u003C\u002Fspan>\u003C\u002Fdiv>\n          \u003Cp class=\"count\">\"Stopped\" reports this scroll: \u003Cspan class=\"counter\" id=\"countDebounce\">0\u003C\u002Fspan>\u003C\u002Fp>\n        \u003C\u002Fdiv>\n        \u003Cdiv class=\"card\">\n          \u003Ch4>⚡ Native scrollend\u003C\u002Fh4>\n          \u003Cdiv class=\"status\" id=\"statusNative\">\u003Cspan class=\"dot info\" id=\"dotNative\">\u003C\u002Fspan>\u003Cspan id=\"textNative\">Waiting for a scroll…\u003C\u002Fspan>\u003C\u002Fdiv>\n          \u003Cp class=\"count\">\"Stopped\" reports this scroll: \u003Cspan class=\"counter\" id=\"countNative\">0\u003C\u002Fspan>\u003C\u002Fp>\n        \u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n\n      \u003Cdiv class=\"log\" id=\"log\" style=\"margin-top:14px\" aria-live=\"polite\">\u003C\u002Fdiv>\n      \u003Cdiv class=\"btn-row\" style=\"margin-top:10px\">\n        \u003Cbutton class=\"btn sm ghost\" id=\"clearLog\">Clear log\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">3\u003C\u002Fspan> Model: a trackpad scroll with a pause \u003Cspan class=\"pill\">model: drawn, not live\u003C\u002Fspan>\u003C\u002Fdiv>\n      \u003Cp class=\"hint\" style=\"margin-top:0\">\n        This is a drawn timeline, not events from your device. It shows what a trackpad sends\n        when you scroll, rest your fingers for a moment, scroll again, then lift and let the\n        momentum glide. The debounce uses the delay from panel 2.\n      \u003C\u002Fp>\n      \u003Cdiv class=\"two\" style=\"margin-top:10px\">\n        \u003Cdiv class=\"field\">\n          \u003Clabel class=\"ctl-label\" for=\"pause\">Fingers resting mid-gesture: \u003Cspan id=\"pauseVal\">250\u003C\u002Fspan>ms\u003C\u002Flabel>\n          \u003Cinput type=\"range\" id=\"pause\" min=\"0\" max=\"600\" step=\"10\" value=\"250\" \u002F>\n        \u003C\u002Fdiv>\n        \u003Cdiv class=\"field\">\n          \u003Cspan class=\"ctl-label\">Debounce delay (from panel 2): \u003Cspan id=\"delayVal2\">150\u003C\u002Fspan>ms\u003C\u002Fspan>\n        \u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n\n      \u003Cdiv class=\"tl\" id=\"tl\">\n        \u003Cdiv class=\"tl-row-label\">Fingers\u003C\u002Fdiv>\n        \u003Cdiv class=\"tl-row\" id=\"rowFingers\">\u003C\u002Fdiv>\n        \u003Cdiv class=\"tl-row-label\">scroll events\u003C\u002Fdiv>\n        \u003Cdiv class=\"tl-row\" id=\"rowTicks\">\u003C\u002Fdiv>\n        \u003Cdiv class=\"tl-row-label\">\"Stopped\" reports\u003C\u002Fdiv>\n        \u003Cdiv class=\"tl-row\" id=\"rowMarks\">\u003C\u002Fdiv>\n        \u003Cdiv class=\"axis\">\u003Cspan>0ms\u003C\u002Fspan>\u003Cspan id=\"axisMid\">\u003C\u002Fspan>\u003Cspan id=\"axisEnd\">\u003C\u002Fspan>\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"legend\">\n        \u003Cspan>\u003Ci class=\"sw move\">\u003C\u002Fi>fingers moving\u003C\u002Fspan>\n        \u003Cspan>\u003Ci class=\"sw rest\">\u003C\u002Fi>fingers resting, still down\u003C\u002Fspan>\n        \u003Cspan>\u003Ci class=\"sw lift\">\u003C\u002Fi>fingers lift\u003C\u002Fspan>\n        \u003Cspan>\u003Ci class=\"sw glide\">\u003C\u002Fi>momentum glide\u003C\u002Fspan>\n        \u003Cspan>\u003Ci class=\"sw tick\">\u003C\u002Fi>\u003Ccode>scroll\u003C\u002Fcode> event\u003C\u002Fspan>\n        \u003Cspan>\u003Ci class=\"sw early\">\u003C\u002Fi>debounce guess, too early\u003C\u002Fspan>\n        \u003Cspan>\u003Ci class=\"sw guess\">\u003C\u002Fi>debounce guess, late\u003C\u002Fspan>\n        \u003Cspan>\u003Ci class=\"sw end\">\u003C\u002Fi>\u003Ccode>scrollend\u003C\u002Fcode>\u003C\u002Fspan>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"output\" id=\"modelSummary\" style=\"margin-top:12px;font-size:0.88rem\">\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"panel\">\n      \u003Cdiv class=\"panel-label\">\u003Cspan class=\"n\">4\u003C\u002Fspan> The gotcha: scrollend fires on scrolls you trigger too\u003C\u002Fdiv>\n      \u003Cp class=\"hint\" style=\"margin-top:0\">\n        This box snaps to the nearest 40px section on \u003Ccode>scrollend\u003C\u002Fcode> with\n        \u003Ccode>scrollTo({ top, behavior: \"smooth\" })\u003C\u002Fcode>, so every correction is itself a scroll.\n        Pick a handler, then nudge the box or scroll it by hand.\n      \u003C\u002Fp>\n      \u003Cdiv class=\"seg\" role=\"group\" aria-label=\"Handler\" style=\"margin-top:10px\">\n        \u003Cbutton type=\"button\" data-mode=\"exact\" aria-pressed=\"true\">Unguarded, exact snap\u003C\u002Fbutton>\n        \u003Cbutton type=\"button\" data-mode=\"flawed\" aria-pressed=\"false\">Unguarded, flawed snap (model)\u003C\u002Fbutton>\n        \u003Cbutton type=\"button\" data-mode=\"guarded\" aria-pressed=\"false\">Guarded, flawed snap\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"hint\" id=\"modeHint\">\u003C\u002Fp>\n      \u003Cdiv class=\"scrollbox\" id=\"box2\" tabindex=\"0\" aria-label=\"Snapping list\">\u003C\u002Fdiv>\n      \u003Cdiv class=\"status\" style=\"margin-top:12px\">\n        \u003Cspan class=\"dot info\" id=\"dotLoop\">\u003C\u002Fspan>\n        \u003Cspan>scrollend handler runs: \u003Cspan class=\"counter\" id=\"runCount\">0\u003C\u002Fspan>\u003C\u002Fspan>\n        \u003Cspan class=\"muted\">·\u003C\u002Fspan>\n        \u003Cspan>scrollTo() calls: \u003Cspan class=\"counter\" id=\"callCount\">0\u003C\u002Fspan>\u003C\u002Fspan>\n      \u003C\u002Fdiv>\n      \u003Cp class=\"hint\" id=\"loopStatus\" aria-live=\"polite\">Nudge it, then watch the counters.\u003C\u002Fp>\n      \u003Cdiv class=\"btn-row\" style=\"margin-top:10px\">\n        \u003Cbutton class=\"btn sm\" id=\"nudge\">Nudge scroll off-snap\u003C\u002Fbutton>\n        \u003Cbutton class=\"btn sm ghost\" id=\"resetLoop\">Reset\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>","\u002F*\n * Shared playground design system — daily-post\n * ============================================\n * Link it from any post's playground\u002Findex.html (no per-post boilerplate):\n *\n *   \u003Clink rel=\"stylesheet\" href=\"..\u002F..\u002F..\u002Fplayground\u002Fassets\u002Fbase.css\" \u002F>\n *\n * The relative path resolves the same locally (posts\u002F\u003Cbase>\u002Fplayground\u002F) and on\n * the deployed host (public\u002Fposts\u002F\u003Cbase>\u002Fplayground\u002F), because build-site.js\n * mirrors this file to public\u002Fplayground\u002Fassets\u002Fbase.css. For a CodeSandbox\n * --embed, playground.js inlines this file so the single-file sandbox still styles.\n *\n * Then write ONLY the demo-specific CSS in your inline \u003Cstyle>. Everything below\n * — tokens, layout, panels, buttons, toggles, sliders, code blocks, status\u002Flog,\n * banners, presets — comes for free and looks consistent across every post.\n *\n * Theme-aware: dark by default, light via prefers-color-scheme AND a\n * [data-theme] override on \u003Chtml> (so a toggle can force either). Accessible:\n * real focus rings, adequate contrast, reduced-motion honored.\n *\u002F\n\n\u002F* ---------- design tokens ---------- *\u002F\n:root {\n  --bg: #0d1117;\n  --bg-grid: #ffffff0a;\n  --panel: #161b22;\n  --panel-2: #0b0e14;\n  --card: #0b0e14;\n  --ink: #e8ebf2;\n  --muted: #9aa4b2;\n  --faint: #6b7688;\n  --line: #232a35;\n  --line-soft: #ffffff12;\n  --accent: #7c9cff;\n  --accent-ink: #0b0e14;\n  --good: #3fb950;\n  --warn: #f0883e;\n  --bad: #f85149;\n  --info: #58a6ff;\n  --radius: 14px;\n  --radius-sm: 9px;\n  --shadow: 0 8px 30px #0006;\n  --shadow-sm: 0 4px 14px #0005;\n  --mono: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n  --sans: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif;\n  color-scheme: dark;\n}\n\n\u002F* Light theme — system preference, overridable by [data-theme] on \u003Chtml>. *\u002F\n@media (prefers-color-scheme: light) {\n  :root:not([data-theme=\"dark\"]) {\n    --bg: #f4f6fb;\n    --bg-grid: #00000008;\n    --panel: #ffffff;\n    --panel-2: #f0f3f9;\n    --card: #f7f9fc;\n    --ink: #131722;\n    --muted: #5b6472;\n    --faint: #8a93a3;\n    --line: #e2e7f0;\n    --line-soft: #00000010;\n    --accent: #2f6df0;\n    --accent-ink: #ffffff;\n    --good: #1a7f37;\n    --warn: #bc4c00;\n    --bad: #cf222e;\n    --info: #0969da;\n    --shadow: 0 8px 30px #0000001f;\n    --shadow-sm: 0 4px 14px #00000014;\n    color-scheme: light;\n  }\n}\n:root[data-theme=\"light\"] {\n  --bg: #f4f6fb;\n  --bg-grid: #00000008;\n  --panel: #ffffff;\n  --panel-2: #f0f3f9;\n  --card: #f7f9fc;\n  --ink: #131722;\n  --muted: #5b6472;\n  --faint: #8a93a3;\n  --line: #e2e7f0;\n  --line-soft: #00000010;\n  --accent: #2f6df0;\n  --accent-ink: #ffffff;\n  --good: #1a7f37;\n  --warn: #bc4c00;\n  --bad: #cf222e;\n  --info: #0969da;\n  --shadow: 0 8px 30px #0000001f;\n  --shadow-sm: 0 4px 14px #00000014;\n  color-scheme: light;\n}\n\n\u002F* ---------- reset & base ---------- *\u002F\n*, *::before, *::after { box-sizing: border-box; }\n\nbody {\n  margin: 0;\n  min-height: 100vh;\n  font-family: var(--sans);\n  color: var(--ink);\n  background:\n    radial-gradient(circle at 1px 1px, var(--bg-grid) 1px, transparent 0) 0 0 \u002F 24px 24px,\n    var(--bg);\n  display: flex;\n  justify-content: center;\n  align-items: flex-start;\n  padding: clamp(14px, 3vw, 26px);\n  line-height: 1.55;\n  -webkit-font-smoothing: antialiased;\n  text-rendering: optimizeLegibility;\n}\n\n\u002F* The single content column. *\u002F\n.pg {\n  width: 100%;\n  max-width: 860px;\n}\n\n\u002F* ---------- header ---------- *\u002F\n.pg-title {\n  font-size: clamp(1.15rem, 1rem + 1vw, 1.4rem);\n  font-weight: 800;\n  letter-spacing: -0.02em;\n  margin: 0 0 4px;\n  display: flex;\n  align-items: center;\n  gap: 9px;\n}\n.pg-sub {\n  color: var(--muted);\n  font-size: 0.92rem;\n  line-height: 1.5;\n  margin: 0 0 16px;\n  max-width: 66ch;\n}\n.pg-sub strong, .pg-sub b { color: var(--ink); font-weight: 700; }\n.pg-sub code { font-family: var(--mono); background: var(--line-soft); color: var(--accent);\n  padding: 1px 6px; border-radius: 6px; font-size: 0.85em; }\n\n\u002F* ---------- panels ---------- *\u002F\n.panel {\n  background: var(--panel);\n  border: 1px solid var(--line);\n  border-radius: var(--radius);\n  padding: 16px 18px;\n  margin-bottom: 14px;\n  box-shadow: var(--shadow-sm);\n}\n.panel-label {\n  font-size: 0.7rem;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  color: var(--muted);\n  font-weight: 800;\n  margin: 0 0 13px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n.panel-label .n {\n  display: inline-grid; place-items: center;\n  width: 18px; height: 18px; border-radius: 50%;\n  background: var(--accent); color: var(--accent-ink);\n  font-size: 0.66rem; font-weight: 800;\n}\n\n\u002F* ---------- layout helpers ---------- *\u002F\n.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }\n.row.between { justify-content: space-between; }\n.col { display: flex; flex-direction: column; gap: 10px; }\n.grow { flex: 1 1 0; min-width: 0; }\n.wrap-controls { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: flex-end; }\n.field { display: flex; flex-direction: column; gap: 7px; min-width: 0; }\n.field > .label, .ctl-label {\n  font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;\n  color: var(--muted); font-weight: 800;\n}\n\n\u002F* ---------- buttons ---------- *\u002F\n.btn {\n  font: inherit; font-weight: 650; font-size: 0.88rem;\n  cursor: pointer; user-select: none;\n  border: 1px solid var(--line);\n  background: var(--card); color: var(--ink);\n  padding: 9px 15px; border-radius: var(--radius-sm);\n  display: inline-flex; align-items: center; gap: 8px;\n  transition: background .15s ease, border-color .15s ease, transform .05s ease, color .15s ease;\n}\n.btn:hover { background: var(--line-soft); border-color: var(--muted); }\n.btn:active { transform: translateY(1px); }\n.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.btn[disabled] { opacity: 0.45; cursor: not-allowed; }\n.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }\n.btn.primary:hover { filter: brightness(1.08); background: var(--accent); }\n.btn.good { background: var(--good); border-color: var(--good); color: #04140a; }\n.btn.bad  { background: var(--bad);  border-color: var(--bad);  color: #fff; }\n.btn.ghost { background: transparent; }\n.btn.sm { padding: 6px 11px; font-size: 0.82rem; }\n\n.btn-row { display: flex; flex-wrap: wrap; gap: 9px; }\n\n\u002F* Segmented control — a row of buttons where one is active (aria-pressed). *\u002F\n.seg { display: inline-flex; background: var(--card); border: 1px solid var(--line);\n  border-radius: var(--radius-sm); padding: 3px; gap: 3px; }\n.seg button {\n  font: inherit; font-weight: 650; font-size: 0.84rem; cursor: pointer;\n  border: none; background: transparent; color: var(--muted);\n  padding: 7px 13px; border-radius: 7px;\n  transition: background .15s ease, color .15s ease;\n}\n.seg button:hover { color: var(--ink); }\n.seg button[aria-pressed=\"true\"] { background: var(--accent); color: var(--accent-ink); }\n.seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n\n\u002F* Preset chips — worked-example shortcuts. *\u002F\n.presets { display: flex; flex-wrap: wrap; gap: 8px; }\n.presets .btn { background: var(--panel-2); }\n\n\u002F* ---------- toggle switch ---------- *\u002F\n.toggle { display: inline-flex; align-items: center; gap: 10px; cursor: pointer;\n  font-size: 0.88rem; font-weight: 600; user-select: none; }\n.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }\n.switch { position: relative; width: 44px; height: 25px; border-radius: 999px; flex: none;\n  background: var(--line); border: 1px solid var(--line); transition: background .18s ease; }\n.switch::after { content: \"\"; position: absolute; top: 2px; left: 2px; width: 19px; height: 19px;\n  border-radius: 50%; background: #cfd6e4; transition: transform .18s ease; box-shadow: 0 1px 3px #0006; }\n.toggle input:checked + .switch { background: var(--good); border-color: var(--good); }\n.toggle input:checked + .switch::after { transform: translateX(19px); background: #fff; }\n.toggle input:focus-visible + .switch { outline: 2px solid var(--accent); outline-offset: 2px; }\n\n\u002F* ---------- range slider ---------- *\u002F\ninput[type=\"range\"] {\n  -webkit-appearance: none; appearance: none; width: 100%; height: 6px;\n  background: var(--line); border-radius: 999px; outline: none; cursor: pointer; margin: 8px 0;\n}\ninput[type=\"range\"]::-webkit-slider-thumb {\n  -webkit-appearance: none; appearance: none; width: 19px; height: 19px; border-radius: 50%;\n  background: var(--accent); border: 2px solid var(--panel); box-shadow: var(--shadow-sm); cursor: pointer;\n}\ninput[type=\"range\"]::-moz-range-thumb {\n  width: 19px; height: 19px; border-radius: 50%; background: var(--accent);\n  border: 2px solid var(--panel); box-shadow: var(--shadow-sm); cursor: pointer;\n}\ninput[type=\"range\"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }\n\n\u002F* ---------- text inputs \u002F selects ---------- *\u002F\ninput[type=\"text\"], input[type=\"number\"], input[type=\"search\"], select, textarea {\n  font: inherit; color: var(--ink); background: var(--card);\n  border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 11px; width: 100%;\n}\ntextarea { font-family: var(--mono); font-size: 0.85rem; line-height: 1.6; resize: vertical; }\ninput:focus-visible, select:focus-visible, textarea:focus-visible {\n  outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent);\n}\n\n\u002F* ---------- status \u002F output \u002F log ---------- *\u002F\n.status {\n  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;\n  font-size: 0.9rem; font-weight: 600;\n  padding: 11px 14px; border-radius: var(--radius-sm);\n  background: var(--panel-2); border: 1px solid var(--line); color: var(--ink);\n}\n.status.good { border-color: color-mix(in srgb, var(--good) 55%, var(--line)); }\n.status.warn { border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }\n.status.bad  { border-color: color-mix(in srgb, var(--bad) 55%, var(--line)); }\n.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; }\n.dot.good { background: var(--good); } .dot.warn { background: var(--warn); }\n.dot.bad { background: var(--bad); } .dot.info { background: var(--info); }\n\n.output { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius-sm);\n  padding: 13px 15px; }\n.log { font-family: var(--mono); font-size: 0.8rem; line-height: 1.7; background: var(--card);\n  border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 11px 14px;\n  max-height: 220px; overflow-y: auto; white-space: pre-wrap; }\n\n\u002F* ---------- code block + syntax tokens ---------- *\u002F\n.code {\n  font-family: var(--mono); font-size: 0.82rem; line-height: 1.7;\n  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--radius-sm);\n  padding: 13px 15px; overflow-x: auto; white-space: pre; tab-size: 2;\n}\n.code .tok-sel  { color: var(--accent); }\n.code .tok-prop { color: #86e29b; }\n.code .tok-kw   { color: #ff9bcb; }\n.code .tok-str  { color: #f0c674; }\n.code .tok-num  { color: #f0883e; }\n.code .tok-cmt  { color: var(--faint); font-style: italic; }\n.code .hl { background: color-mix(in srgb, var(--good) 22%, transparent); border-radius: 4px; padding: 0 3px; }\n\n\u002F* ---------- hint \u002F caption ---------- *\u002F\n.hint { font-size: 0.83rem; color: var(--muted); line-height: 1.55; margin: 10px 0 0; }\n.hint code { font-family: var(--mono); color: var(--ink); background: var(--line-soft);\n  padding: 1px 5px; border-radius: 5px; font-size: 0.86em; }\n.hint strong { color: var(--ink); }\n\n\u002F* ---------- banner (feature-support \u002F notices) ---------- *\u002F\n.banner {\n  display: none; margin-bottom: 14px; padding: 11px 14px; border-radius: 11px;\n  font-size: 0.85rem; line-height: 1.5;\n  color: var(--ink); background: color-mix(in srgb, var(--warn) 12%, var(--panel));\n  border: 1px solid color-mix(in srgb, var(--warn) 45%, var(--line));\n}\n.banner.show { display: block; }\n.banner.info { background: color-mix(in srgb, var(--info) 12%, var(--panel));\n  border-color: color-mix(in srgb, var(--info) 45%, var(--line)); }\n.banner code { font-family: var(--mono); background: var(--line-soft); padding: 1px 5px; border-radius: 5px; }\n\n\u002F* ---------- misc atoms ---------- *\u002F\n.pill { display: inline-flex; align-items: center; gap: 6px; font-size: 0.75rem; font-weight: 700;\n  padding: 3px 9px; border-radius: 999px; background: var(--line-soft); color: var(--muted); }\n.pill.good { color: var(--good); background: color-mix(in srgb, var(--good) 16%, transparent); }\n.pill.bad  { color: var(--bad);  background: color-mix(in srgb, var(--bad) 16%, transparent); }\n.mono { font-family: var(--mono); }\n.muted { color: var(--muted); }\n.big-num { font-family: var(--mono); font-size: 1.6rem; font-weight: 800; letter-spacing: -0.02em; }\n\n\u002F* Optional theme toggle button (top-right). Wire it in JS if you want it. *\u002F\n.theme-btn { position: fixed; top: 12px; right: 12px; z-index: 20; }\n\n\u002F* ---------- responsive ---------- *\u002F\n@media (max-width: 560px) {\n  .panel { padding: 14px; }\n  .wrap-controls { gap: 14px; }\n  .btn { padding: 9px 13px; }\n}\n\n\u002F* ---------- reduced motion ---------- *\u002F\n@media (prefers-reduced-motion: reduce) {\n  *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }\n}\n\n\n\u002F* Demo-specific CSS only — base.css owns tokens, layout, panels, buttons. *\u002F\n  .scrollbox {\n    height: 220px;\n    overflow-y: auto;\n    border: 1px solid var(--line);\n    border-radius: 10px;\n    background: var(--panel-2);\n    scroll-behavior: auto;\n    overscroll-behavior: contain;\n  }\n  .scrollbox .item {\n    padding: 14px 16px;\n    border-bottom: 1px dashed var(--line);\n    font-size: 14px;\n  }\n  .scrollbox .item:last-child { border-bottom: none; }\n  .scrollbox .idx {\n    display: inline-block;\n    min-width: 2.2em;\n    color: var(--faint);\n    margin-right: 8px;\n    font-variant-numeric: tabular-nums;\n  }\n  \u002F* The snap box: every section is exactly SNAP px tall, so snap points line up with rows. *\u002F\n  #box2 { height: 160px; margin-top: 10px; }\n  #box2 .item { height: 40px; padding: 0 16px; display: flex; align-items: center; box-sizing: border-box; }\n\n  .race {\n    display: grid;\n    grid-template-columns: 1fr;\n    gap: 10px;\n    margin-top: 14px;\n  }\n  @media (min-width: 560px) {\n    .race { grid-template-columns: 1fr 1fr; }\n  }\n  .race .card {\n    border: 1px solid var(--line);\n    border-radius: 10px;\n    padding: 12px 14px;\n    min-width: 0;\n  }\n  .race .card h4 { margin: 0 0 8px; font-size: 13px; letter-spacing: .02em; text-transform: uppercase; color: var(--faint); }\n  .race .card .count { margin: 8px 0 0; font-size: 0.8rem; color: var(--muted); }\n  .counter {\n    font-variant-numeric: tabular-nums;\n    font-weight: 700;\n    font-size: 20px;\n  }\n  .log-entry { padding: 2px 0; font-variant-numeric: tabular-nums; }\n  .log-entry.good { color: var(--good); }\n  .log-entry.bad { color: var(--bad); }\n  .log-entry.warn { color: var(--warn); }\n  .log-entry.muted { color: var(--faint); }\n\n  \u002F* ---- Model timeline ---- *\u002F\n  .tl { position: relative; margin-top: 12px; }\n  .tl-row { position: relative; height: 26px; margin: 6px 0; border-radius: 6px; background: var(--panel-2); border: 1px solid var(--line); overflow: hidden; }\n  .tl-row-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 800; margin-top: 10px; }\n  .seg-move, .seg-rest, .seg-glide { position: absolute; top: 0; bottom: 0; }\n  .seg-move  { background: color-mix(in srgb, var(--accent) 55%, transparent); }\n  .seg-rest  { background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--warn) 40%, transparent) 0 6px, transparent 6px 12px); }\n  .seg-glide { background: color-mix(in srgb, var(--accent) 22%, transparent); }\n  .tick { position: absolute; top: 4px; bottom: 4px; width: 2px; margin-left: -1px; background: var(--ink); opacity: .55; border-radius: 1px; }\n  .mark { position: absolute; top: 2px; bottom: 2px; width: 4px; margin-left: -2px; border-radius: 2px; }\n  .mark.guess { background: var(--warn); }\n  .mark.guess.early { background: var(--bad); }\n  .mark.end { background: var(--good); }\n  .vline { position: absolute; top: 0; bottom: 0; width: 0; border-left: 2px dashed var(--muted); }\n  .axis { display: flex; justify-content: space-between; font-size: 0.72rem; color: var(--faint); font-variant-numeric: tabular-nums; margin-top: 4px; }\n  .legend { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; font-size: 0.78rem; color: var(--muted); }\n  .legend span { display: inline-flex; align-items: center; gap: 6px; }\n  .sw { display: inline-block; width: 14px; height: 10px; border-radius: 3px; }\n  .sw.move { background: color-mix(in srgb, var(--accent) 55%, transparent); }\n  .sw.rest { background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--warn) 55%, transparent) 0 3px, transparent 3px 6px); border: 1px solid var(--line); }\n  .sw.glide { background: color-mix(in srgb, var(--accent) 22%, transparent); border: 1px solid var(--line); }\n  .sw.tick { position: static; width: 2px; height: 10px; margin: 0; background: var(--ink); opacity: .55; }\n  .sw.early { background: var(--bad); width: 4px; }\n  .sw.guess { background: var(--warn); width: 4px; }\n  .sw.end { background: var(--good); width: 4px; }\n  .sw.lift { width: 0; height: 12px; border-left: 2px dashed var(--muted); border-radius: 0; }\n  .two { display: grid; grid-template-columns: 1fr; gap: 12px 24px; }\n  @media (min-width: 560px) { .two { grid-template-columns: 1fr 1fr; } }\n  .seg { flex-wrap: wrap; }","\"use strict\";\n  const $ = (id) => document.getElementById(id);\n  const hasScrollend = \"onscrollend\" in window;\n\n  if (!hasScrollend) {\n    const b = $(\"supportBanner\");\n    b.classList.add(\"show\");\n    b.innerHTML = \"Your browser doesn't fire \u003Ccode>scrollend\u003C\u002Fcode> (in practice: Safari before 26.2). \" +\n      \"The native detector and panel 4 won't react, which is exactly the gap the post's fallback covers. \" +\n      \"The model in panel 3 still works.\";\n  }\n\n  function fillRows(el, n, label) {\n    for (let i = 1; i \u003C= n; i++) {\n      const row = document.createElement(\"div\");\n      row.className = \"item\";\n      row.innerHTML = `\u003Cspan class=\"idx\">#${i}\u003C\u002Fspan>${label(i)}`;\n      el.appendChild(row);\n    }\n  }\n\n  \u002F\u002F ---------- Panels 1 + 2: race the two detectors on real scrolling ----------\n  const box = $(\"box\");\n  fillRows(box, 40, (i) => `Item ${i} of 40. Keep scrolling to feed both detectors.`);\n\n  function fmtClock() {\n    const d = new Date();\n    return d.toLocaleTimeString([], { hour12: false }) + \".\" + String(d.getMilliseconds()).padStart(3, \"0\");\n  }\n  function log(msg, kind) {\n    const el = document.createElement(\"div\");\n    el.className = \"log-entry\" + (kind ? \" \" + kind : \"\");\n    el.textContent = `[${fmtClock()}] ${msg}`;\n    $(\"log\").prepend(el);\n    while ($(\"log\").childElementCount > 80) $(\"log\").lastElementChild.remove();\n  }\n  function setStatus(prefix, text, kind) {\n    $(\"text\" + prefix).textContent = text;\n    $(\"dot\" + prefix).className = \"dot \" + (kind || \"info\");\n  }\n\n  \u002F\u002F One \"session\" = one scroll, from its first scroll event to its scrollend.\n  let session = null;\n  let debounceTimer = null;\n\n  function startSession(now) {\n    session = { start: now, guesses: 0, guessedAt: null, ended: false, endedAt: null };\n    $(\"countDebounce\").textContent = \"0\";\n    $(\"countNative\").textContent = \"0\";\n    setStatus(\"Debounce\", \"Scrolling…\", \"warn\");\n    setStatus(\"Native\", \"Scrolling…\", \"warn\");\n  }\n\n  box.addEventListener(\"scroll\", () => {\n    const now = performance.now();\n    if (!session || session.ended) {\n      startSession(now);\n    } else if (session.guessedAt !== null) {\n      \u002F\u002F The debounce said \"stopped\", and here's another scroll event in the same scroll.\n      setStatus(\"Debounce\", `Wrong: scrolling resumed ${Math.round(now - session.guessedAt)}ms after it guessed \"stopped\"`, \"bad\");\n      log(`scroll resumed: the debounce's \"stopped\" was a false alarm`, \"bad\");\n      session.guessedAt = null;\n    }\n    clearTimeout(debounceTimer);\n    const delay = Number($(\"delay\").value);\n    const s = session;\n    debounceTimer = setTimeout(() => {\n      s.guesses++;\n      $(\"countDebounce\").textContent = String(s.guesses);\n      if (s.ended) {\n        const late = Math.round(performance.now() - s.endedAt);\n        setStatus(\"Debounce\", s.guesses > 1\n          ? `Said \"stopped\" ${s.guesses} times for one scroll, the last ${late}ms after scrollend already knew`\n          : `Guessed \"stopped\", ${late}ms after scrollend already knew`, \"warn\");\n        log(`debounce guessed \"stopped\" (no scroll event for ${delay}ms): ${late}ms late`, \"warn\");\n      } else {\n        s.guessedAt = performance.now();\n        setStatus(\"Debounce\", `Guessed \"stopped\" after ${delay}ms of silence`, \"warn\");\n        log(`debounce guessed \"stopped\" (no scroll event for ${delay}ms)`, \"warn\");\n        if (!hasScrollend) s.ended = true; \u002F\u002F no scrollend to end it: the guess is all we get\n      }\n    }, delay);\n  });\n\n  if (hasScrollend) {\n    box.addEventListener(\"scrollend\", () => {\n      const now = performance.now();\n      if (!session || session.ended) return;\n      const s = session;\n      s.ended = true;\n      s.endedAt = now;\n      $(\"countNative\").textContent = \"1\";\n      setStatus(\"Native\", `Actually stopped, ${Math.round(now - s.start)}ms after the scroll began`, \"good\");\n      log(\"scrollend: the browser says scrolling is done\", \"good\");\n      if (s.guessedAt !== null) {\n        const early = Math.round(now - s.guessedAt);\n        setStatus(\"Debounce\", `Too early: guessed \"stopped\" ${early}ms before the scroll was done`, \"bad\");\n        log(`…the debounce had guessed \"stopped\" ${early}ms too early`, \"bad\");\n      }\n    });\n  }\n\n  $(\"delay\").addEventListener(\"input\", () => {\n    $(\"delayVal\").textContent = $(\"delay\").value;\n    $(\"delayVal2\").textContent = $(\"delay\").value;\n    drawModel();\n  });\n\n  $(\"codeScroll\").addEventListener(\"click\", () => {\n    const top = box.scrollTop \u003C 400 ? 520 : 0;\n    log(`box.scrollTo({ top: ${top}, behavior: \"smooth\" }): a scroll from code`, \"muted\");\n    box.scrollTo({ top, behavior: \"smooth\" });\n  });\n\n  $(\"resetBox\").addEventListener(\"click\", () => {\n    log(\"box.scrollTo({ top: 0 }): also a scroll from code, so both detectors see it\", \"muted\");\n    box.scrollTo({ top: 0 });\n  });\n\n  $(\"clearLog\").addEventListener(\"click\", () => { $(\"log\").innerHTML = \"\"; });\n\n  \u002F\u002F ---------- Panel 3: a drawn model of the event timeline ----------\n  \u002F\u002F Not live events. Scroll events arrive once per frame while the position moves;\n  \u002F\u002F none arrive while the fingers rest; scrollend comes once the fingers have lifted\n  \u002F\u002F and the momentum has finished.\n  const FRAME = 1000 \u002F 60;\n  const MOVE1 = 320, MOVE2 = 260, GLIDE = 480;\n\n  function drawModel() {\n    const P = Number($(\"pause\").value);\n    const D = Number($(\"delay\").value);\n    $(\"pauseVal\").textContent = String(P);\n\n    const restStart = MOVE1, restEnd = MOVE1 + P;\n    const lift = restEnd + MOVE2;\n    const settle = lift + GLIDE;\n    const ticks = [];\n    for (let t = 0; t \u003C restStart; t += FRAME) ticks.push(t);\n    for (let t = restEnd; t \u003C settle; t += FRAME) ticks.push(t);\n    const scrollend = ticks[ticks.length - 1] + FRAME; \u002F\u002F the frame after the last position update\n\n    \u002F\u002F What a debounce with delay D reports: every gap of D or more after a scroll event.\n    const guesses = [];\n    for (let i = 0; i \u003C ticks.length; i++) {\n      const next = i + 1 \u003C ticks.length ? ticks[i + 1] : Infinity;\n      if (next - ticks[i] > D) guesses.push(ticks[i] + D);\n    }\n    const total = Math.max(scrollend, guesses[guesses.length - 1]) + 60;\n    const pct = (t) => (100 * t \u002F total).toFixed(3) + \"%\";\n\n    const fingers = $(\"rowFingers\");\n    fingers.innerHTML = \"\";\n    const addSeg = (cls, a, b) => {\n      if (b \u003C= a) return;\n      const el = document.createElement(\"div\");\n      el.className = cls; el.style.left = pct(a); el.style.width = (100 * (b - a) \u002F total).toFixed(3) + \"%\";\n      fingers.appendChild(el);\n    };\n    addSeg(\"seg-move\", 0, restStart);\n    addSeg(\"seg-rest\", restStart, restEnd);\n    addSeg(\"seg-move\", restEnd, lift);\n    addSeg(\"seg-glide\", lift, settle);\n\n    const rowTicks = $(\"rowTicks\");\n    rowTicks.innerHTML = \"\";\n    for (const t of ticks) {\n      const el = document.createElement(\"div\");\n      el.className = \"tick\"; el.style.left = pct(t);\n      rowTicks.appendChild(el);\n    }\n\n    const marks = $(\"rowMarks\");\n    marks.innerHTML = \"\";\n    for (const g of guesses) {\n      const el = document.createElement(\"div\");\n      el.className = \"mark guess\" + (g \u003C scrollend ? \" early\" : \"\");\n      el.style.left = pct(g);\n      el.title = `debounce guess at ${Math.round(g)}ms`;\n      marks.appendChild(el);\n    }\n    const end = document.createElement(\"div\");\n    end.className = \"mark end\"; end.style.left = pct(scrollend);\n    end.title = `scrollend at ${Math.round(scrollend)}ms`;\n    marks.appendChild(end);\n\n    for (const row of [fingers, rowTicks, marks]) {\n      const v = document.createElement(\"div\");\n      v.className = \"vline\"; v.style.left = pct(lift);\n      row.appendChild(v);\n    }\n\n    $(\"axisMid\").textContent = Math.round(total \u002F 2) + \"ms\";\n    $(\"axisEnd\").textContent = Math.round(total) + \"ms\";\n\n    const early = guesses.filter((g) => g \u003C scrollend);\n    const late = guesses.filter((g) => g >= scrollend);\n    let text;\n    if (early.length) {\n      text = `With a ${D}ms delay and a ${P}ms rest, the debounce says \"stopped\" at ${Math.round(early[0])}ms, ` +\n        `while the fingers are still down. The scroll isn't done for another ${Math.round(scrollend - early[0])}ms. ` +\n        `It then reports \"stopped\" again at ${Math.round(late[0])}ms, ${Math.round(late[0] - scrollend)}ms after the real stop. `;\n    } else {\n      text = `The ${P}ms rest is shorter than the ${D}ms delay, so this time the debounce doesn't fire early. ` +\n        `It still reports the stop at ${Math.round(late[0])}ms, ${Math.round(late[0] - scrollend)}ms after it happened. ` +\n        `Make the rest longer than the delay and it will fire early. `;\n    }\n    text += `scrollend fires once, at ${Math.round(scrollend)}ms, after the fingers lift and the momentum ends.`;\n    $(\"modelSummary\").textContent = text;\n  }\n  $(\"pause\").addEventListener(\"input\", drawModel);\n  drawModel();\n\n  \u002F\u002F ---------- Panel 4: the programmatic-scroll loop gotcha ----------\n  const box2 = $(\"box2\");\n  fillRows(box2, 30, (i) => `Section ${i}`);\n  const SNAP = 40;    \u002F\u002F every section is 40px tall\n  const CAP = 150;    \u002F\u002F stop the runaway demo here rather than let it spin forever\n  let mode = \"exact\";\n  let runs = 0, calls = 0;\n  let correcting = false;   \u002F\u002F the guard flag from the post\n  let ignoreNext = false;   \u002F\u002F lets Reset scroll the box without the handler reacting\n  let flip = 1;\n  let settleTimer = null;\n\n  const MODE_HINT = {\n    exact: \"The post's first snippet with a snap that lands exactly on target: after the correction, the second run asks for the position the box is already at. Nothing moves, so no third scrollend fires and it settles.\",\n    flawed: \"Model of buggy snap math: this snap never returns the same target twice in a row (it lands 1px above the section, then 1px below, and so on). Every correction moves the box, so every scrollend triggers another one.\",\n    guarded: \"The post's guarded code (the correcting flag plus the within-a-pixel check), running the same flawed snap. The flag ignores the scrollend its own correction causes, so it corrects once and stops.\",\n  };\n\n  function exactSnap() { return Math.round(box2.scrollTop \u002F SNAP) * SNAP; }\n  function flawedSnap() { flip = -flip; return exactSnap() + flip; } \u002F\u002F never the same target twice\n\n  function paint(kind) {\n    $(\"runCount\").textContent = String(runs);\n    $(\"callCount\").textContent = String(calls);\n    $(\"dotLoop\").className = \"dot \" + (kind || \"info\");\n  }\n\n  \u002F\u002F When the box has been quiet for a moment, say what happened.\n  function scheduleVerdict() {\n    clearTimeout(settleTimer);\n    settleTimer = setTimeout(() => {\n      if (!runs) return;\n      if (mode === \"exact\") {\n        $(\"loopStatus\").textContent = `Settled: ${runs} runs, ${calls} scrollTo() calls. The last call asked for the position the box was already at, so nothing moved and no further scrollend fired.`;\n        paint(\"good\");\n      } else if (mode === \"guarded\") {\n        $(\"loopStatus\").textContent = `Settled: ${runs} runs, ${calls} correction${calls === 1 ? \"\" : \"s\"}. The flag skipped the scrollend caused by its own correction.`;\n        paint(\"good\");\n      }\n    }, 900);\n  }\n\n  function correct(target) {\n    calls++;\n    box2.scrollTo({ top: target, behavior: \"smooth\" });\n  }\n\n  if (hasScrollend) {\n    box2.addEventListener(\"scrollend\", () => {\n      if (ignoreNext) return;\n      runs++;\n\n      if (mode === \"guarded\") {\n        \u002F\u002F Exactly the post's guarded handler.\n        if (correcting) { paint(\"good\"); scheduleVerdict(); return; }\n        const target = flawedSnap();\n        if (Math.abs(target - box2.scrollTop) \u003C 1) { paint(\"good\"); scheduleVerdict(); return; }\n        correcting = true;\n        correct(target);\n        box2.addEventListener(\"scrollend\", () => { correcting = false; }, { once: true });\n        paint(\"warn\");\n        scheduleVerdict();\n        return;\n      }\n\n      \u002F\u002F Unguarded: the post's first snippet, which always calls scrollTo.\n      if (runs >= CAP) {\n        $(\"loopStatus\").textContent = `Stopped the demo at ${CAP} runs. Left alone, this handler never settles.`;\n        paint(\"bad\");\n        return;\n      }\n      correct(mode === \"flawed\" ? flawedSnap() : exactSnap());\n      if (mode === \"flawed\") {\n        $(\"loopStatus\").textContent = \"Still going: every correction moved the box, so every scrollend triggered another one.\";\n        paint(runs > 3 ? \"bad\" : \"warn\");\n      } else {\n        paint(\"warn\");\n      }\n      scheduleVerdict();\n    });\n  }\n\n  function resetCounters() {\n    clearTimeout(settleTimer);\n    runs = 0; calls = 0; correcting = false; flip = 1;\n    $(\"loopStatus\").textContent = \"Nudge it, then watch the counters.\";\n    paint(\"info\");\n  }\n\n  function setMode(m) {\n    mode = m;\n    document.querySelectorAll(\"[data-mode]\").forEach((b) => b.setAttribute(\"aria-pressed\", String(b.dataset.mode === m)));\n    $(\"modeHint\").textContent = MODE_HINT[m];\n    resetCounters();\n  }\n  document.querySelectorAll(\"[data-mode]\").forEach((b) => b.addEventListener(\"click\", () => setMode(b.dataset.mode)));\n  setMode(\"exact\");\n\n  $(\"nudge\").addEventListener(\"click\", () => {\n    if (runs >= CAP) resetCounters();\n    box2.scrollTop = box2.scrollTop + 17; \u002F\u002F deliberately off-snap: this ends with a scrollend too\n  });\n\n  $(\"resetLoop\").addEventListener(\"click\", () => {\n    resetCounters();\n    if (box2.scrollTop >= 1 && hasScrollend) {\n      ignoreNext = true;\n      box2.addEventListener(\"scrollend\", () => { ignoreNext = false; }, { once: true });\n    }\n    box2.scrollTop = 0;\n  });",{"height":52,"autorun":17},520,{"slug":45,"title":54,"excerpt":55,"coverUrl":56,"vertical":57},"Stop Guessing When Scrolling Stops — Use scrollend","For years, 'has the user stopped scrolling?' was a guess: debounce the scroll event, pick a delay, hope. The scrollend event answers the question directly — and as of Safari 26.2, it finally works everywhere. Here's the bug the guess causes, and the one-line fix.","\u002Fmedia\u002Fcovers\u002Fscrollend-event-native-scroll-stop-detection.png",{"slug":6},[59,63,67,71,75,79,83,87,91,95,99,103],{"slug":60,"name":61,"articles":62},"webdev","Webdev",114,{"slug":64,"name":65,"articles":66},"javascript","Javascript",96,{"slug":68,"name":69,"articles":70},"frontend","Frontend",75,{"slug":72,"name":73,"articles":74},"tutorial","Tutorial",41,{"slug":76,"name":77,"articles":78},"css","Css",36,{"slug":80,"name":81,"articles":82},"typescript","Typescript",17,{"slug":84,"name":85,"articles":86},"performance","Performance",14,{"slug":88,"name":89,"articles":90},"react","React",13,{"slug":92,"name":93,"articles":94},"browser","Browser",11,{"slug":96,"name":97,"articles":98},"node","Node",10,{"slug":100,"name":101,"articles":102},"html","Html",8,{"slug":104,"name":105,"articles":106},"accessibility","Accessibility",7]