$sloprun.dev

Bézier Playground

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A cubic bézier is the little curve that decides whether an animation feels crisp, sluggish or springy: time goes in on one axis, progress comes out on the other. Drag the four points and the cubic-bezier() string rewrites itself ready to copy, while two boxes race on a loop — one moving on your curve, one at a constant speed — so you feel the difference instead of reading about it. The comb of ticks underneath is the part worth staring at: every tick is an equal slice of time, so where they bunch the motion is crawling and where they spread it is sprinting. It opens on bounce-ish, so try anticipate next to watch a box wind up backwards before it goes, or drag the top control point straight up to find out how little it takes to make motion look expensive.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/bezier-playground" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Bézier Playground — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/bezier-playground

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 940 lines 44.3 KB index.html
demos/bezier-playground/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Bézier Playground — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local addition (shadows need alpha; tokens are opaque hex) */
:root { --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --shadow: 0 1px 2px rgba(0,0,0,.35); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; -webkit-font-smoothing: antialiased; }

.wrap { max-width: 1160px; margin: 0 auto; padding: 22px 16px 26px; display: flex; flex-direction: column; gap: 16px; }

/* header */
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 72ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede code { font-family: var(--font-mono); font-size: .87em; color: var(--accent); background: var(--accent-soft); border-radius: 4px; padding: 1px 5px; white-space: nowrap; }

/* layout */
.stage { display: grid; grid-template-columns: minmax(280px, 396px) minmax(0, 1fr); gap: 16px; align-items: start; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0, 1fr); } }
.col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }

.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; box-shadow: var(--shadow); min-width: 0; }
.card h2 { margin: 0 0 11px; font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); font-weight: 600; }
.card h2 .sub { text-transform: none; letter-spacing: 0; opacity: .8; margin-left: 8px; }

/* focus */
a:focus-visible, button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* plot */
.plot { position: relative; width: 100%; max-width: 396px; margin: 0 auto; aspect-ratio: 5 / 7; touch-action: pan-y; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); }
.plot canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.handle { position: absolute; width: 40px; height: 40px; margin: 0; padding: 0; border: 0; background: none; transform: translate(-50%, -50%); cursor: grab; touch-action: none; }
.handle:active { cursor: grabbing; }
.handle::before {
  content: ""; position: absolute; left: 50%; top: 50%; width: 15px; height: 15px;
  transform: translate(-50%, -50%); background: var(--accent); border: 2px solid var(--surface);
  border-radius: 50%; box-shadow: 0 0 0 1px var(--accent); transition: width .12s ease, height .12s ease;
}
.handle.anchor::before { width: 13px; height: 13px; border-radius: 3px; background: var(--surface); border: 2px solid var(--surface); box-shadow: 0 0 0 1.5px var(--muted); }
.handle:hover::before, .handle.drag::before { width: 20px; height: 20px; }
.handle:focus { outline: none; }
.handle:focus-visible::before { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
.note { margin: 11px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--muted); }
.note kbd { font-family: var(--font-mono); font-size: 11px; border: 1px solid var(--line); border-radius: 4px; padding: 0 4px; color: var(--ink); }

/* css string row */
.cssrow { display: flex; gap: 8px; align-items: stretch; }
.code {
  flex: 1 1 auto; min-width: 0; font-family: var(--font-mono);
  font-size: clamp(12.5px, 2.4vw, 15px); letter-spacing: -.01em;
  color: var(--ink); background: var(--bg); border: 1px solid var(--line);
  border-radius: 8px; padding: 10px 11px; -webkit-appearance: none; appearance: none;
}
.code:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
.code.bad { border-color: var(--danger); }
@media (max-width: 430px) { .cssrow { flex-wrap: wrap; } .cssrow .btn { flex: 1 1 100%; } }
.cssmeta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 9px; }
.msg { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
.msg.bad { color: var(--danger); }
.msg.ok { color: var(--run); }

/* pills */
.pill { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 11.5px; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); background: var(--bg); }
/* accent-on-accent-soft is the suite's "selected" pairing; 600 keeps small mono
   text legible on it in the light theme, where the pair is the tighter of the two */
.pill.hot { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }

/* buttons */
button { font: inherit; color: inherit; cursor: pointer; }
.btn { font-family: var(--font-mono); font-size: 12.5px; padding: 9px 13px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--ink); transition: border-color .15s ease, background .15s ease; }
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
/* "Pause" is wider than "Play" — hold the width so the duration slider beside it
   does not jump every time the button is pressed */
#playBtn { min-width: 74px; }

/* preset chips */
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
  display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px 6px 8px;
  border: 1px solid var(--line); border-radius: 999px; background: var(--bg); color: var(--muted);
  font-family: var(--font-mono); font-size: 12px; transition: border-color .15s ease, color .15s ease, background .15s ease;
}
.chip svg { width: 22px; height: 22px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }
.chip:hover { border-color: var(--accent); color: var(--ink); }
.chip[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); font-weight: 600; }

/* preview */
.lanes { display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 9px 10px; align-items: center; }
.lane-k { font-family: var(--font-mono); font-size: 11px; color: var(--muted); text-align: right; }
.rail { position: relative; height: 40px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); overflow: hidden; }
.guide { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--line); }
.guide.end { background: transparent; border-left: 1px dashed var(--muted); opacity: .65; }
.tile { position: absolute; top: 50%; left: 0; width: 30px; height: 24px; margin-top: -12px; border-radius: 7px; background: var(--accent); will-change: transform; }
.tile.ghost { background: transparent; border: 1.5px dashed var(--muted); }
/* margin matches the rail's 1px border so a tick and a tile agree on where 0 is */
.comb { grid-column: 2; position: relative; height: 22px; margin: 0 1px; }
.comb i { position: absolute; top: 4px; width: 2px; height: 13px; margin-left: -1px; border-radius: 1px; background: var(--accent); }
.cap { grid-column: 2; margin: 0; font-family: var(--font-mono); font-size: 11px; color: var(--muted); line-height: 1.45; }
@media (max-width: 420px) {
  .lanes { grid-template-columns: 40px minmax(0, 1fr); }
  .lane-k { font-size: 10px; }
}

/* controls */
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.ctl { flex: 1 1 190px; min-width: 0; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.ctl-top label { font-size: 12.5px; color: var(--muted); }
.val { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); }
input[type=range] { -webkit-appearance: none; appearance: none; width: 100%; height: 22px; background: transparent; margin: 0; display: block; }
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; margin-top: -6px; border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent); }
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent); }
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }

/* stats */
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; }
@media (max-width: 560px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.stat { border: 1px solid var(--line); border-radius: 8px; padding: 8px 9px; background: var(--bg); min-width: 0; }
.stat .v { display: block; font-family: var(--font-mono); font-size: 15px; letter-spacing: -.01em; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat .k { display: block; font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-top: 2px; }

footer { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); text-align: center; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Bézier Playground</h1>
    <p class="lede">
      Every smooth animation on the web is one little curve: <b>time goes in, progress comes out</b>.
      Drag the four points to reshape it — the <code>cubic-bezier()</code> string rewrites itself as you
      go, and the two boxes below race your curve against a constant speed. Loaded with
      <b>bounce-ish</b> to start.
    </p>
  </header>

  <main class="stage">

    <section class="card" aria-labelledby="plotH">
      <h2 id="plotH">Curve <span class="sub">time → progress</span></h2>
      <div class="plot" id="plot">
        <canvas id="c" aria-hidden="true"></canvas>
        <button class="handle anchor" id="h0" type="button" data-i="0" aria-describedby="plotHelp" aria-label="Start anchor, time 0.00, progress 0.00"></button>
        <button class="handle" id="h1" type="button" data-i="1" aria-describedby="plotHelp" aria-label="Control point 1, time 0.34, progress 1.56"></button>
        <button class="handle" id="h2" type="button" data-i="2" aria-describedby="plotHelp" aria-label="Control point 2, time 0.64, progress 1.00"></button>
        <button class="handle anchor" id="h3" type="button" data-i="3" aria-describedby="plotHelp" aria-label="End anchor, time 1.00, progress 1.00"></button>
      </div>
      <p class="note" id="plotHelp">
        Drag the round points to bend the curve. The square anchors set where the motion starts and
        finishes — everything between them is what CSS gets. Arrow keys nudge by 0.01,
        <kbd>Shift</kbd>&nbsp;+&nbsp;arrow by 0.1.
      </p>
    </section>

    <div class="col">

      <section class="card" aria-labelledby="cssH">
        <h2 id="cssH">CSS <span class="sub">editable — paste a curve in</span></h2>
        <div class="cssrow">
          <input id="cssIn" class="code" type="text" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" aria-label="CSS timing function — edit or paste a cubic-bezier value" value="cubic-bezier(0.34, 1.56, 0.64, 1)">
          <button class="btn primary" id="copyBtn" type="button">Copy</button>
        </div>
        <div class="cssmeta">
          <span class="pill" id="badge">custom curve</span>
          <span class="msg" id="msg"></span>
        </div>
      </section>

      <section class="card" aria-labelledby="presetH">
        <h2 id="presetH">Presets</h2>
        <div class="chips" id="chips" role="group" aria-label="Curve presets">
          <button class="chip" type="button" data-p="ease" aria-pressed="false">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 17.5 C7 16.4 7 6.5 22 6.5"/></svg><span>ease</span>
          </button>
          <button class="chip" type="button" data-p="ease-in-out" aria-pressed="false">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 17.5 C10.4 17.5 13.6 6.5 22 6.5"/></svg><span>ease-in-out</span>
          </button>
          <button class="chip" type="button" data-p="bounce-ish" aria-pressed="true">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 17.5 C8.8 1.6 14.8 6.5 22 6.5"/></svg><span>bounce-ish</span>
          </button>
          <button class="chip" type="button" data-p="anticipate" aria-pressed="false">
            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 17.5 C16 22.4 9 6.5 22 6.5"/></svg><span>anticipate</span>
          </button>
        </div>
      </section>

      <section class="card" aria-labelledby="prevH">
        <h2 id="prevH">Preview <span class="sub">your curve vs. a constant speed</span></h2>
        <div class="lanes">
          <span class="lane-k" id="laneA">curve</span>
          <div class="rail" id="railA">
            <div class="guide" id="gA0"></div><div class="guide end" id="gA1"></div>
            <div class="tile" id="tileA"></div>
          </div>
          <span class="lane-k" id="laneB">linear</span>
          <div class="rail" id="railB">
            <div class="guide" id="gB0"></div><div class="guide end" id="gB1"></div>
            <div class="tile ghost" id="tileB"></div>
          </div>
          <div class="comb" id="comb" aria-hidden="true"></div>
          <p class="cap">Ticks above are equal slices of time. Where they bunch up, the motion is slow; where they spread, it is fast.</p>
        </div>
        <div class="row" style="margin-top:12px">
          <button class="btn primary" id="playBtn" type="button">Pause</button>
          <span class="msg" id="prevMsg"></span>
          <div class="ctl">
            <div class="ctl-top"><label for="dur">Duration</label><span class="val" id="durV">900 ms</span></div>
            <input type="range" id="dur" min="200" max="2400" step="50" value="900">
          </div>
        </div>
        <div class="ctl" style="margin-top:8px">
          <div class="ctl-top"><label for="scrub">Scrub time</label><span class="val" id="scrubV">t 0.00</span></div>
          <input type="range" id="scrub" min="0" max="1000" step="1" value="0">
        </div>
      </section>

      <section class="card" aria-labelledby="readH">
        <h2 id="readH">Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sPeak">—</span><span class="k">peak speed</span></div>
          <div class="stat"><span class="v" id="sOver">—</span><span class="k">overshoot</span></div>
          <div class="stat"><span class="v" id="sBack">—</span><span class="k">back-swing</span></div>
          <div class="stat"><span class="v" id="sProg">—</span><span class="k">progress now</span></div>
        </div>
      </section>

    </div>
  </main>

  <footer>demo · sloprun.dev</footer>
</div>

<div class="sr-only" role="status" aria-live="polite" id="live"></div>

<script>
(function () {
  "use strict";

  /* ── elements ───────────────────────────────────────────────────────────── */
  var root    = document.documentElement;
  var plot    = document.getElementById("plot");
  var c       = document.getElementById("c");
  var ctx     = c.getContext("2d");
  var handles = [0, 1, 2, 3].map(function (i) { return document.getElementById("h" + i); });
  var cssIn   = document.getElementById("cssIn");
  var copyBtn = document.getElementById("copyBtn");
  var badge   = document.getElementById("badge");
  var msgEl   = document.getElementById("msg");
  var chips   = document.getElementById("chips");
  var railA   = document.getElementById("railA");
  var tileA   = document.getElementById("tileA");
  var tileB   = document.getElementById("tileB");
  var comb    = document.getElementById("comb");
  var guides  = ["gA0", "gA1", "gB0", "gB1"].map(function (id) { return document.getElementById(id); });
  var playBtn = document.getElementById("playBtn");
  var durEl   = document.getElementById("dur");
  var durV    = document.getElementById("durV");
  var scrubEl = document.getElementById("scrub");
  var prevMsg = document.getElementById("prevMsg");
  var scrubV  = document.getElementById("scrubV");
  var sPeak   = document.getElementById("sPeak");
  var sOver   = document.getElementById("sOver");
  var sBack   = document.getElementById("sBack");
  var sProg   = document.getElementById("sProg");
  var live    = document.getElementById("live");

  /* ── view + state ───────────────────────────────────────────────────────── */
  var PAD = 20, VY0 = -0.62, VY1 = 1.62;
  var cssW = 1, cssH = 1;
  var P = [{ x: 0, y: 0 }, { x: 0.34, y: 1.56 }, { x: 0.64, y: 1 }, { x: 1, y: 1 }];
  var N = [0.34, 1.56, 0.64, 1];         /* normalized easing */
  var cssText = "cubic-bezier(0.34, 1.56, 0.64, 1)";
  var stats = { peak: 1, over: 0, back: 0, vMin: 0, vMax: 1 };

  var dur = 900, HOLD = 420;
  var t = 0, playing = false, startT = 0, raf = 0;
  var reduced = false;
  try { reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}

  var PRESETS = {
    "ease":        [0.25, 0.1, 0.25, 1],
    "ease-in-out": [0.42, 0, 0.58, 1],
    "bounce-ish":  [0.34, 1.56, 0.64, 1],
    "anticipate":  [0.68, -0.55, 0.27, 1.55]
  };
  var KEYWORDS = [
    ["linear",      [0, 0, 1, 1]],
    ["ease",        [0.25, 0.1, 0.25, 1]],
    ["ease-in",     [0.42, 0, 1, 1]],
    ["ease-out",    [0, 0, 0.58, 1]],
    ["ease-in-out", [0.42, 0, 0.58, 1]]
  ];

  /* ── math ───────────────────────────────────────────────────────────────── */
  function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
  function bez(s, a, b, cc, d) { var m = 1 - s; return m * m * m * a + 3 * m * m * s * b + 3 * m * s * s * cc + s * s * s * d; }
  function bx(s, x1, x2) { var m = 1 - s; return 3 * m * m * s * x1 + 3 * m * s * s * x2 + s * s * s; }
  function by(s, y1, y2) { var m = 1 - s; return 3 * m * m * s * y1 + 3 * m * s * s * y2 + s * s * s; }
  function dbx(s, x1, x2) { var m = 1 - s; return 3 * m * m * x1 + 6 * m * s * (x2 - x1) + 3 * s * s * (1 - x2); }

  function solveS(tt, x1, x2) {
    if (!(tt > 0)) return 0;
    if (tt >= 1) return 1;
    var s = tt, i, d, sl;
    for (i = 0; i < 8; i++) {
      d = bx(s, x1, x2) - tt;
      if (Math.abs(d) < 1e-6) return clamp(s, 0, 1);
      sl = dbx(s, x1, x2);
      if (Math.abs(sl) < 1e-6) break;
      s -= d / sl;
      if (s < 0 || s > 1) break;
    }
    var a = 0, b = 1; s = tt;
    for (i = 0; i < 26; i++) {
      if (bx(s, x1, x2) > tt) b = s; else a = s;
      s = (a + b) / 2;
    }
    return s;
  }
  function easeAt(tt) { return by(solveS(tt, N[0], N[2]), N[1], N[3]); }

  function normalize() {
    var fx = P[3].x - P[0].x, fy = P[3].y - P[0].y;
    if (Math.abs(fx) < 1e-6) fx = 1e-6;
    if (Math.abs(fy) < 1e-6) fy = 1e-6;
    return [
      clamp((P[1].x - P[0].x) / fx, 0, 1),
      clamp((P[1].y - P[0].y) / fy, -6, 6),
      clamp((P[2].x - P[0].x) / fx, 0, 1),
      clamp((P[2].y - P[0].y) / fy, -6, 6)
    ];
  }
  function fmt(v) { var r = Math.round(v * 100) / 100; if (r === 0) r = 0; return String(r); }

  /* ── points ─────────────────────────────────────────────────────────────── */
  function setPoint(i, x, y) {
    if (i === 0) { x = clamp(x, 0, P[3].x - 0.15); y = clamp(y, VY0, P[3].y - 0.4); }
    else if (i === 3) { x = clamp(x, P[0].x + 0.15, 1); y = clamp(y, P[0].y + 0.4, VY1); }
    else { x = clamp(x, P[0].x, P[3].x); y = clamp(y, VY0, VY1); }
    P[i].x = x; P[i].y = y;
    if (i === 0 || i === 3) {
      P[1].x = clamp(P[1].x, P[0].x, P[3].x);
      P[2].x = clamp(P[2].x, P[0].x, P[3].x);
    }
  }
  function setFromEasing(v) {
    P[0].x = 0; P[0].y = 0; P[3].x = 1; P[3].y = 1;
    P[1].x = clamp(v[0], 0, 1); P[1].y = clamp(v[1], VY0, VY1);
    P[2].x = clamp(v[2], 0, 1); P[2].y = clamp(v[3], VY0, VY1);
  }

  /* ── colours ────────────────────────────────────────────────────────────── */
  var COL = {};
  /* Fallbacks matching the token block: if a custom property is not readable yet
     (very early paint), an empty fillStyle would silently leave the canvas black. */
  var FALLBACK = {
    light: { bg: "#F6F6F3", surface: "#FFFFFF", ink: "#1A1D21", muted: "#5A6068", line: "#E3E2DC", accent: "#C05600" },
    dark:  { bg: "#14161A", surface: "#1C1F26", ink: "#E8E6E1", muted: "#9AA0A8", line: "#2A2E36", accent: "#FFB454" }
  };
  function tok(name) { var v = getComputedStyle(root).getPropertyValue(name); return v ? v.trim() : ""; }
  function isDark() {
    var a = root.getAttribute("data-theme");
    if (a === "dark") return true;
    if (a === "light") return false;
    try { return window.matchMedia("(prefers-color-scheme: dark)").matches; } catch (e) { return false; }
  }
  function rgba(hex, a) {
    var h = (hex || "").replace("#", "");
    if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
    if (h.length !== 6) return hex || "#888";
    var n = parseInt(h, 16);
    if (isNaN(n)) return hex;
    return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
  }
  function readColors() {
    var fb = FALLBACK[isDark() ? "dark" : "light"], k;
    for (k in fb) if (Object.prototype.hasOwnProperty.call(fb, k)) COL[k] = tok("--" + k) || fb[k];
  }

  /* ── canvas ─────────────────────────────────────────────────────────────── */
  function X(x) { return PAD + x * (cssW - 2 * PAD); }
  function Y(y) { return PAD + (VY1 - y) / (VY1 - VY0) * (cssH - 2 * PAD); }
  function invX(px) { return (px - PAD) / Math.max(1, cssW - 2 * PAD); }
  function invY(py) { return VY1 - (py - PAD) / Math.max(1, cssH - 2 * PAD) * (VY1 - VY0); }

  function resize() {
    var r = plot.getBoundingClientRect();
    if (!r.width || !r.height) return;
    cssW = r.width; cssH = r.height;
    var dpr = Math.min(window.devicePixelRatio || 1, 2.5);
    c.width = Math.round(cssW * dpr);
    c.height = Math.round(cssH * dpr);
    ctx.setTransform(c.width / cssW, 0, 0, c.height / cssH, 0, 0);
  }

  function drawPlot() {
    ctx.clearRect(0, 0, cssW, cssH);
    ctx.fillStyle = COL.bg; ctx.fillRect(0, 0, cssW, cssH);

    /* unit square = the CSS world */
    ctx.fillStyle = COL.surface;
    ctx.fillRect(X(0), Y(1), X(1) - X(0), Y(0) - Y(1));

    /* grid */
    ctx.lineWidth = 1; ctx.strokeStyle = rgba(COL.line, 0.9);
    var gx = [0.25, 0.5, 0.75], gy = [-0.5, 0.5, 1.5], i;
    ctx.beginPath();
    for (i = 0; i < gx.length; i++) { ctx.moveTo(Math.round(X(gx[i])) + 0.5, Y(VY1)); ctx.lineTo(Math.round(X(gx[i])) + 0.5, Y(VY0)); }
    for (i = 0; i < gy.length; i++) { ctx.moveTo(X(0), Math.round(Y(gy[i])) + 0.5); ctx.lineTo(X(1), Math.round(Y(gy[i])) + 0.5); }
    ctx.stroke();

    /* unit square border — the CSS world, kept legible in both themes */
    ctx.strokeStyle = rgba(COL.muted, 0.38);
    ctx.strokeRect(Math.round(X(0)) + 0.5, Math.round(Y(1)) + 0.5, Math.round(X(1) - X(0)), Math.round(Y(0) - Y(1)));

    /* frame rectangle when the anchors have been moved */
    var moved = Math.abs(P[0].x) > 1e-3 || Math.abs(P[0].y) > 1e-3 || Math.abs(P[3].x - 1) > 1e-3 || Math.abs(P[3].y - 1) > 1e-3;
    if (moved) {
      ctx.save();
      ctx.setLineDash([2, 4]); ctx.strokeStyle = rgba(COL.accent, 0.45); ctx.lineWidth = 1;
      ctx.strokeRect(X(P[0].x), Y(P[3].y), X(P[3].x) - X(P[0].x), Y(P[0].y) - Y(P[3].y));
      ctx.restore();
    }

    /* axis captions */
    ctx.fillStyle = rgba(COL.muted, 0.95);
    ctx.font = '10px ' + (tok("--font-mono") || "ui-monospace, Menlo, Consolas, monospace");
    ctx.textAlign = "right"; ctx.textBaseline = "alphabetic";
    ctx.fillText("time →", X(1), cssH - 6);
    ctx.save();
    ctx.translate(11, Y(0.5)); ctx.rotate(-Math.PI / 2);
    ctx.textAlign = "center"; ctx.fillText("progress →", 0, 0);
    ctx.restore();

    /* handle arms */
    ctx.save();
    ctx.setLineDash([4, 4]); ctx.lineWidth = 1.5; ctx.strokeStyle = rgba(COL.muted, 0.6);
    ctx.beginPath();
    ctx.moveTo(X(P[0].x), Y(P[0].y)); ctx.lineTo(X(P[1].x), Y(P[1].y));
    ctx.moveTo(X(P[3].x), Y(P[3].y)); ctx.lineTo(X(P[2].x), Y(P[2].y));
    ctx.stroke();
    ctx.restore();

    /* the curve */
    ctx.beginPath();
    var STEPS = 180;
    for (i = 0; i <= STEPS; i++) {
      var s = i / STEPS;
      var px = bez(s, P[0].x, P[1].x, P[2].x, P[3].x);
      var py = bez(s, P[0].y, P[1].y, P[2].y, P[3].y);
      if (i === 0) ctx.moveTo(X(px), Y(py)); else ctx.lineTo(X(px), Y(py));
    }
    ctx.lineWidth = 2.6; ctx.lineJoin = "round"; ctx.lineCap = "round";
    ctx.strokeStyle = COL.accent;
    ctx.stroke();

    /* playhead */
    var ps = solveS(t, N[0], N[2]);
    var hx = bez(ps, P[0].x, P[1].x, P[2].x, P[3].x);
    var hy = bez(ps, P[0].y, P[1].y, P[2].y, P[3].y);
    ctx.save();
    ctx.setLineDash([3, 3]); ctx.lineWidth = 1; ctx.strokeStyle = rgba(COL.accent, 0.55);
    ctx.beginPath();
    ctx.moveTo(X(P[0].x), Y(hy)); ctx.lineTo(X(hx), Y(hy));
    ctx.moveTo(X(hx), Y(hy)); ctx.lineTo(X(hx), Y(P[0].y));
    ctx.stroke();
    ctx.restore();
    ctx.beginPath();
    ctx.arc(X(hx), Y(hy), 5, 0, Math.PI * 2);
    ctx.fillStyle = COL.accent; ctx.fill();
    ctx.lineWidth = 2; ctx.strokeStyle = COL.surface; ctx.stroke();
  }

  function layoutHandles() {
    for (var i = 0; i < 4; i++) {
      handles[i].style.left = X(P[i].x) + "px";
      handles[i].style.top = Y(P[i].y) + "px";
      var who = i === 0 ? "Start anchor" : i === 3 ? "End anchor" : "Control point " + i;
      handles[i].setAttribute("aria-label", who + ", time " + P[i].x.toFixed(2) + ", progress " + P[i].y.toFixed(2));
    }
  }

  /* ── preview rail ───────────────────────────────────────────────────────── */
  var railW = 300, tileW = 30, ticks = [];
  (function buildTicks() {
    var frag = document.createDocumentFragment();
    for (var i = 0; i <= 28; i++) { var el = document.createElement("i"); frag.appendChild(el); ticks.push(el); }
    comb.appendChild(frag);
  })();

  function measureRail() {
    /* clientWidth, not the border box — the tiles live inside the rail's border,
       so measuring the border box would let the far end slide under it */
    var w = railA.clientWidth || railA.getBoundingClientRect().width;
    if (w) railW = w;
    if (tileA.offsetWidth) tileW = tileA.offsetWidth;
  }
  function pxFor(v) {
    var span = stats.vMax - stats.vMin;
    if (span < 1e-6) span = 1;
    return (v - stats.vMin) / span * Math.max(0, railW - tileW);
  }
  function centerFor(v) { return pxFor(v) + tileW / 2; }

  function positionTiles() {
    var prog = easeAt(t);
    tileA.style.transform = "translateX(" + pxFor(prog).toFixed(2) + "px)";
    tileB.style.transform = "translateX(" + pxFor(t).toFixed(2) + "px)";
    var pct = Math.round(prog * 100) + "%";
    if (sProg.textContent !== pct) sProg.textContent = pct;
    var tv = "t " + t.toFixed(2);
    if (scrubV.textContent !== tv) scrubV.textContent = tv;
    var sv = String(Math.round(t * 1000));
    if (scrubEl.value !== sv) scrubEl.value = sv;
  }

  function updateRailFurniture() {
    var g0 = centerFor(0), g1 = centerFor(1);
    guides[0].style.left = g0 + "px"; guides[2].style.left = g0 + "px";
    guides[1].style.left = g1 + "px"; guides[3].style.left = g1 + "px";
    var vals = [], i;
    for (i = 0; i < ticks.length; i++) vals.push(easeAt(i / (ticks.length - 1)));
    var maxStep = 1e-6;
    for (i = 1; i < vals.length; i++) maxStep = Math.max(maxStep, Math.abs(vals[i] - vals[i - 1]));
    for (i = 0; i < ticks.length; i++) {
      var near = i === 0 ? Math.abs(vals[1] - vals[0]) : Math.abs(vals[i] - vals[i - 1]);
      ticks[i].style.left = centerFor(vals[i]).toFixed(2) + "px";
      ticks[i].style.opacity = (0.22 + 0.78 * (near / maxStep)).toFixed(2);
    }
  }

  /* ── stats + labels ─────────────────────────────────────────────────────── */
  function computeStats() {
    var STEPS = 240, prev = 0, peak = 0, mx = 1, mn = 0;
    for (var i = 1; i <= STEPS; i++) {
      var y = easeAt(i / STEPS);
      var v = (y - prev) * STEPS;
      if (v > peak) peak = v;
      if (y > mx) mx = y;
      if (y < mn) mn = y;
      prev = y;
    }
    stats.peak = peak; stats.over = mx - 1; stats.back = -mn;
    stats.vMin = mn; stats.vMax = mx;
  }

  function matchKeyword() {
    for (var i = 0; i < KEYWORDS.length; i++) {
      var k = KEYWORDS[i][1], ok = true;
      for (var j = 0; j < 4; j++) if (Math.abs(N[j] - k[j]) > 0.006) { ok = false; break; }
      if (ok) return KEYWORDS[i][0];
    }
    return null;
  }

  function updateChips() {
    var kids = chips.querySelectorAll(".chip");
    for (var i = 0; i < kids.length; i++) {
      var v = PRESETS[kids[i].getAttribute("data-p")], ok = true;
      for (var j = 0; j < 4; j++) if (Math.abs(N[j] - v[j]) > 0.006) { ok = false; break; }
      kids[i].setAttribute("aria-pressed", ok ? "true" : "false");
    }
  }

  /* ── the one update everything hangs off ────────────────────────────────── */
  /* `typing` is true only for the field's own input event — the one caller that
     must not have the text yanked out from under the caret. Every other path owns
     the field and writes the real curve back even if the caret is sitting in it:
     clicking a <button> does not move focus in every browser, so a preset chip can
     fire with the field still focused, and a focus test alone would strand a stale
     cubic-bezier() string on screen while the curve underneath had changed. */
  function refresh(typing) {
    N = normalize();
    cssText = "cubic-bezier(" + fmt(N[0]) + ", " + fmt(N[1]) + ", " + fmt(N[2]) + ", " + fmt(N[3]) + ")";
    if (!typing) {
      cssIn.value = cssText;
      cssIn.classList.remove("bad");
      cssIn.setAttribute("aria-invalid", "false");
    }
    computeStats();
    var kw = matchKeyword();
    badge.textContent = kw ? "same as " + kw : "custom curve";
    badge.className = kw ? "pill hot" : "pill";
    sPeak.textContent = "×" + stats.peak.toFixed(1);
    sOver.textContent = stats.over > 0.005 ? "+" + Math.round(stats.over * 100) + "%" : "none";
    sBack.textContent = stats.back > 0.005 ? "−" + Math.round(stats.back * 100) + "%" : "none";
    updateChips();
    measureRail();
    updateRailFurniture();
    layoutHandles();
    render();
  }
  function render() { drawPlot(); positionTiles(); }

  /* ── dragging ───────────────────────────────────────────────────────────── */
  var dragI = -1, offX = 0, offY = 0, GRAB = 44;
  function moveTo(px, py) {
    if (dragI < 0) return;
    setPoint(dragI, invX(px + offX), invY(py + offY));
    refresh();
  }
  plot.addEventListener("pointerdown", function (e) {
    /* left button only: a right- or middle-click never gets a matching pointerup
       on most platforms, which would leave a point stuck to the cursor */
    if (e.button > 0) return;
    var r = plot.getBoundingClientRect();
    var px = e.clientX - r.left, py = e.clientY - r.top;
    var best = -1, i;
    if (e.target && e.target.classList && e.target.classList.contains("handle")) {
      best = parseInt(e.target.getAttribute("data-i"), 10);
      if (!(best >= 0 && best < 4)) best = -1;
    }
    if (best < 0) {
      /* Touch grabs the 40px handles only, so a swipe over the plot still
         scrolls the page instead of yanking a control point. */
      if (e.pointerType === "touch") return;
      var bd = Infinity;
      for (i = 0; i < 4; i++) {
        var dx = px - X(P[i].x), dy = py - Y(P[i].y), d = dx * dx + dy * dy;
        if (d < bd) { bd = d; best = i; }
      }
      if (bd > GRAB * GRAB) return;
    }
    dragI = best;
    offX = X(P[best].x) - px;   /* grab it where you took hold — never teleport */
    offY = Y(P[best].y) - py;
    handles[best].classList.add("drag");
    try { handles[best].focus({ preventScroll: true }); } catch (err) { handles[best].focus(); }
    try { plot.setPointerCapture(e.pointerId); } catch (err) {}
    e.preventDefault();
    moveTo(px, py);
  });
  plot.addEventListener("pointermove", function (e) {
    if (dragI < 0) return;
    /* the button was released somewhere we never heard about — let go */
    if (e.pointerType === "mouse" && e.buttons === 0) { endDrag(e); return; }
    var r = plot.getBoundingClientRect();
    e.preventDefault();
    moveTo(e.clientX - r.left, e.clientY - r.top);
  });
  function endDrag(e) {
    if (dragI < 0) return;
    handles[dragI].classList.remove("drag");
    dragI = -1;
    try { plot.releasePointerCapture(e.pointerId); } catch (err) {}
  }
  plot.addEventListener("pointerup", endDrag);
  plot.addEventListener("pointercancel", endDrag);
  plot.addEventListener("lostpointercapture", endDrag);

  /* keyboard on each handle */
  var nudgeTimer = 0;
  handles.forEach(function (h, i) {
    h.addEventListener("keydown", function (e) {
      var step = e.shiftKey ? 0.1 : 0.01, dx = 0, dy = 0;
      if (e.key === "ArrowLeft") dx = -step;
      else if (e.key === "ArrowRight") dx = step;
      else if (e.key === "ArrowUp") dy = step;
      else if (e.key === "ArrowDown") dy = -step;
      else return;
      e.preventDefault();
      setPoint(i, Math.round((P[i].x + dx) * 1000) / 1000, Math.round((P[i].y + dy) * 1000) / 1000);
      refresh();
      clearTimeout(nudgeTimer);
      nudgeTimer = setTimeout(function () {
        announce(h.getAttribute("aria-label") + ", " + cssText);
      }, 260);
    });
  });

  /* ── css field ──────────────────────────────────────────────────────────── */
  function keyword(name) {
    for (var i = 0; i < KEYWORDS.length; i++) if (KEYWORDS[i][0] === name) return KEYWORDS[i][1].slice();
    return null;
  }
  function parseCurve(str) {
    var s = String(str).toLowerCase().trim();
    var exact = keyword(s);
    if (exact) return exact;
    /* A whole declaration is a fair thing to paste — "transition: transform .3s
       cubic-bezier(.4, 0, .2, 1);" — so read the numbers INSIDE the function and
       never the duration sitting in front of it. */
    var fn = s.match(/cubic-bezier\s*\(([^)]*)\)/);
    var nums = (fn ? fn[1] : s).match(/-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/g);
    if (!nums || nums.length < 4) {
      /* no numbers to take — but "transition: all .3s ease-in-out" still names a curve */
      var kw = s.match(/(?:^|[^a-z-])(ease-in-out|ease-in|ease-out|ease|linear)(?:[^a-z-]|$)/);
      return kw ? keyword(kw[1]) : null;
    }
    var out = [];
    for (var j = 0; j < 4; j++) {
      var v = parseFloat(nums[j]);
      if (!isFinite(v)) return null;
      out.push(v);
    }
    return out;
  }
  cssIn.addEventListener("input", function () {
    var v = parseCurve(cssIn.value);
    if (!v) {
      cssIn.classList.add("bad");
      cssIn.setAttribute("aria-invalid", "true");
      say("not a curve yet", "bad");
      return;
    }
    cssIn.classList.remove("bad");
    cssIn.setAttribute("aria-invalid", "false");
    var clipped = v[1] !== clamp(v[1], VY0, VY1) || v[3] !== clamp(v[3], VY0, VY1) ||
                  v[0] !== clamp(v[0], 0, 1) || v[2] !== clamp(v[2], 0, 1);
    setFromEasing(v);
    refresh(true);
    say(clipped ? "clamped to fit the plot" : "");
  });
  cssIn.addEventListener("blur", function () {
    /* leaving the field puts the real curve back, so the complaint about what was
       typed must go with it — otherwise a stale red note outlives its cause */
    cssIn.value = cssText;
    cssIn.classList.remove("bad");
    cssIn.setAttribute("aria-invalid", "false");
    if (msgEl.classList.contains("bad")) say("");
  });
  cssIn.addEventListener("keydown", function (e) {
    if (e.key === "Enter") { e.preventDefault(); cssIn.value = cssText; cssIn.select(); }
  });
  cssIn.addEventListener("focus", function () { cssIn.select(); });

  var msgTimer = 0;
  function say(text, kind) {
    clearTimeout(msgTimer);
    msgEl.textContent = text || "";
    msgEl.className = "msg" + (kind ? " " + kind : "");
  }
  function flash(text, kind, ms) {
    say(text, kind);
    msgTimer = setTimeout(function () { say(""); }, ms || 1900);
  }
  function announce(text) { live.textContent = text; }

  copyBtn.addEventListener("click", function () {
    var text = cssText;
    var done = function () { flash("copied ✓", "ok"); announce("copied " + text); };
    var fallback = function () {
      try {
        cssIn.value = text; cssIn.focus(); cssIn.select();
        if (document.execCommand && document.execCommand("copy")) { done(); return; }
      } catch (err) {}
      /* the sandbox can refuse the clipboard outright — say so out loud too, or a
         screen-reader user gets a silent button and no idea what to press */
      flash("selected — press Ctrl/⌘ + C", "", 3200);
      announce(text + " selected — press Control or Command C to copy");
    };
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(done, fallback);
    } else fallback();
  });

  /* ── presets ────────────────────────────────────────────────────────────── */
  chips.addEventListener("click", function (e) {
    var btn = e.target.closest ? e.target.closest(".chip") : null;
    if (!btn) return;
    var name = btn.getAttribute("data-p");
    var v = PRESETS[name];
    if (!v) return;
    setFromEasing(v);
    refresh();
    say("");
    announce(name + ", " + cssText);
    if (!playing && !reduced) togglePlay(true);
    else if (playing) { t = 0; startT = now(); }
  });

  /* ── playback ───────────────────────────────────────────────────────────── */
  function now() { return (window.performance && performance.now) ? performance.now() : Date.now(); }

  function loop() {
    raf = requestAnimationFrame(loop);
    if (!playing) return;
    var phase = (now() - startT) % (dur + HOLD);
    t = Math.min(1, phase / dur);
    render();
  }
  function start() { if (!raf) raf = requestAnimationFrame(loop); }
  function stop() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }

  function togglePlay(on) {
    playing = (typeof on === "boolean") ? on : !playing;
    if (playing) prevMsg.textContent = "";
    /* the label IS the state — no aria-pressed, which would read as the
       contradictory "Pause, pressed" */
    playBtn.textContent = playing ? "Pause" : "Play";
    if (playing) {
      if (t >= 1) t = 0;
      startT = now() - t * dur;
      start();
    } else {
      stop();
      render();
    }
  }
  playBtn.addEventListener("click", function () { togglePlay(); announce(playing ? "playing" : "paused"); });

  durEl.addEventListener("input", function () {
    dur = parseInt(durEl.value, 10) || 900;
    durV.textContent = dur + " ms";
    startT = now() - t * dur;
  });
  scrubEl.addEventListener("input", function () {
    var v = clamp(parseInt(scrubEl.value, 10) / 1000, 0, 1);   /* read before pausing */
    if (playing) togglePlay(false);
    t = v;
    render();
  });

  /* ── theme ──────────────────────────────────────────────────────────────── */
  window.addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      readColors();
      render();
    }
  });
  /* the canvas paints with token colours it has to read by hand, so any change of
     data-theme — however it arrives — has to repaint it or the plot keeps the old
     theme while the page around it has already flipped */
  if (window.MutationObserver) {
    new MutationObserver(function () { readColors(); render(); })
      .observe(root, { attributes: true, attributeFilter: ["data-theme"] });
  }
  try {
    var mq = window.matchMedia("(prefers-color-scheme: dark)");
    var onScheme = function () { readColors(); render(); };
    if (mq.addEventListener) mq.addEventListener("change", onScheme);
    else if (mq.addListener) mq.addListener(onScheme);
  } catch (e) {}

  try {
    var rm = window.matchMedia("(prefers-reduced-motion: reduce)");
    var onReduced = function () {
      reduced = rm.matches;
      if (reduced && playing) {
        togglePlay(false);
        prevMsg.textContent = "paused — your system asks for reduced motion";
      }
    };
    if (rm.addEventListener) rm.addEventListener("change", onReduced);
    else if (rm.addListener) rm.addListener(onReduced);
  } catch (e) {}

  /* ── lifecycle ──────────────────────────────────────────────────────────── */
  function onResize() {
    resize();
    measureRail();
    updateRailFurniture();
    layoutHandles();
    render();
  }
  if (window.ResizeObserver) {
    var ro = new ResizeObserver(onResize);
    ro.observe(plot); ro.observe(railA);
  }
  window.addEventListener("resize", onResize);
  window.addEventListener("load", function () { readColors(); onResize(); });
  document.addEventListener("visibilitychange", function () {
    if (document.hidden) stop();
    else if (playing) { startT = now() - t * dur; start(); }
  });

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readColors();
  resize();
  durV.textContent = dur + " ms";
  refresh();

  if (reduced) {
    t = 0.42;
    playing = false;
    playBtn.textContent = "Play";
    prevMsg.textContent = "paused — your system asks for reduced motion";
    render();
  } else {
    togglePlay(true);
  }
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong