$sloprun.dev

Focus Timer

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

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

▶ 7 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

The classic pomodoro — 25 on, 5 off, a long rest every fourth round — built around a ring that earns the space it takes: the arc drains as the stretch runs, a lit head marks the boundary, and a three-note chime synthesised in Web Audio marks the flip. It opens mid-flow, a fourth stretch paused at 16:30 with a sample log behind it, so you can see the whole shape before touching anything. Press space and the sample clears — the clock and the count become yours. Then push the focus stretch to 50 for deep work or cut the rest to 3, and the ring redraws to the new length without ever cheating you out of the stretch you are already in.

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/pomodoro" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Focus Timer — 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/pomodoro

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1135 lines 41.5 KB index.html
demos/pomodoro/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>Focus Timer — 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 additions: alpha values and on-color pairs the tokens don't carry */
:root {
  --on-accent: #FFFFFF;        /* on solid --accent #C05600 → contrast 4.6 */
  --on-run: #FFFFFF;           /* on solid --run #1A7F37 → contrast 5.1 */
  --accent-ink: #9A4500;       /* accent as SMALL TEXT: 6.3 on --dial, 6.0 on --accent-soft */
  --ring-a: #C2500C;           /* ring gradient, warm end */
  --ring-b: #C05600;
  --ring-c: #D48C52;           /* ring gradient, lit end */
  --shadow: 0 1px 2px rgba(26,29,33,.06), 0 8px 24px rgba(26,29,33,.05);
  --tick: rgba(26,29,33,.16);
  --tick-major: rgba(26,29,33,.30);
  --dial: #FBFBF9;             /* inner dial face, a touch off the surface */
  --hair: rgba(26,29,33,.06);
  --glow-op: .30;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --on-accent: #14161A;      /* on solid --accent #FFB454 → contrast 10.9 */
    --on-run: #071B0C;         /* on solid --run #3FB950 → contrast 9.8 */
    --accent-ink: #FFB454;     /* on dark surfaces the accent already clears AA */
    --ring-a: #FDA558; --ring-b: #FFB454; --ring-c: #FFCC8B;
    --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 26px rgba(0,0,0,.35);
    --tick: rgba(232,230,225,.16);
    --tick-major: rgba(232,230,225,.34);
    --dial: #191C22;
    --hair: rgba(232,230,225,.06);
    --glow-op: .42;
  }
}
:root[data-theme="dark"] {
  --on-accent: #14161A;
  --on-run: #071B0C;
  --accent-ink: #FFB454;
  --ring-a: #FDA558; --ring-b: #FFB454; --ring-c: #FFCC8B;
  --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 26px rgba(0,0,0,.35);
  --tick: rgba(232,230,225,.16);
  --tick-major: rgba(232,230,225,.34);
  --dial: #191C22;
  --hair: rgba(232,230,225,.06);
  --glow-op: .42;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  min-height: 100%;
  -webkit-text-size-adjust: 100%;
  line-height: 1.5;
  overflow-x: hidden;
}
.wrap {
  max-width: 1020px;
  margin: 0 auto;
  padding: 22px 16px 28px;
}

/* ── header ─────────────────────────────────────────────────────────────── */
header { margin-bottom: 18px; }
h1 {
  margin: 0 0 4px;
  font-size: 1.35rem;
  letter-spacing: -0.015em;
  font-weight: 620;
}
.lede { margin: 0; color: var(--muted); font-size: .95rem; max-width: 62ch; }

/* ── layout ─────────────────────────────────────────────────────────────── */
.grid { display: grid; gap: 16px; grid-template-columns: 1fr; align-items: start; }
@media (min-width: 860px) {
  .grid { grid-template-columns: minmax(0,1.05fr) minmax(300px,.95fr); gap: 18px; }
}
.side { display: grid; gap: 16px; grid-template-columns: 1fr; }

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}
.card > h2 {
  margin: 0;
  padding: 11px 14px;
  border-bottom: 1px solid var(--line);
  font-family: var(--font-mono);
  font-size: .68rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.card .body { padding: 14px; }

/* ── hero / ring ────────────────────────────────────────────────────────── */
.hero .body { padding: 18px 14px 16px; display: grid; justify-items: center; gap: 14px; }

.ringbtn {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  cursor: pointer;
  display: block;
  position: relative;
  width: min(320px, 78vw);
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  color: inherit;
  touch-action: manipulation;
}
.ringbtn svg { display: block; width: 100%; height: 100%; overflow: visible; }
.ringbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 6px; }

.dialface { fill: var(--dial); stroke: var(--hair); stroke-width: 1; }
.track { fill: none; stroke: var(--line); stroke-width: 12; }
.prog {
  fill: none;
  stroke-width: 12;
  stroke-linecap: round;
  transition: stroke-dashoffset .22s linear;
}
.prog.focus { stroke: url(#gradFocus); }
.prog.rest  { stroke: var(--muted); stroke-width: 9; opacity: .85; }
.glow {
  fill: none;
  stroke: var(--accent);
  stroke-width: 14;
  stroke-linecap: round;
  filter: url(#soften);
  opacity: 0;
  transition: stroke-dashoffset .22s linear, opacity .3s ease;
}
[data-phase="focus"] .glow { opacity: var(--glow-op); }
.head { fill: var(--accent); stroke: var(--surface); stroke-width: 3; }
[data-phase="rest"] .head { fill: var(--muted); }
[data-phase="rest"] .headring { opacity: 0; }
.headring { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: .45; }
.tickline { stroke: var(--tick); stroke-width: 1.5; stroke-linecap: round; }
.tickline.maj { stroke: var(--tick-major); stroke-width: 2.5; }

[data-phase="focus"] .ringbtn.running .headring { animation: ping 2.4s ease-out infinite; }
@keyframes ping {
  0%   { r: 10; opacity: .5; }
  70%  { r: 20; opacity: 0; }
  100% { r: 20; opacity: 0; }
}

.center {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 2px;
  pointer-events: none;
  padding: 0 14%;
  text-align: center;
}
.phase {
  font-family: var(--font-mono);
  font-size: .68rem;
  letter-spacing: .18em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--accent-ink);
}
[data-phase="rest"] .phase { color: var(--muted); }
.time {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: clamp(2.5rem, 15.5vw, 3.5rem);
  line-height: 1.02;
  font-weight: 640;
  letter-spacing: -0.03em;
  color: var(--ink);
}
.subline {
  font-family: var(--font-mono);
  font-size: .7rem;
  color: var(--muted);
  letter-spacing: .02em;
  white-space: nowrap;
}

/* ── dots ───────────────────────────────────────────────────────────────── */
.dots { display: flex; gap: 8px; align-items: center; justify-content: center; min-height: 12px; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--line); flex: none; transition: background .25s ease, box-shadow .25s ease; }
.dot.done { background: var(--run); }
.dot.now { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }

/* ── controls ───────────────────────────────────────────────────────────── */
.controls { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; width: 100%; }
.btn {
  font: inherit;
  font-size: .875rem;
  padding: 9px 14px;
  border-radius: 8px;
  border: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  transition: background .15s ease, border-color .15s ease, transform .06s ease;
  touch-action: manipulation;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn .k {
  font-family: var(--font-mono);
  font-size: .66rem;
  color: var(--muted);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 0 4px;
  line-height: 1.45;
}
.btn.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
  font-weight: 600;
  min-width: 132px;
  justify-content: center;
}
.btn.primary:hover { filter: brightness(1.06); background: var(--accent); }
.btn.primary .k { color: var(--on-accent); border-color: currentColor; opacity: .65; }
.preset[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); }
.btn.hushed { color: var(--muted); }
.btn .ico { width: 15px; height: 15px; flex: none; }

.status {
  font-family: var(--font-mono);
  font-size: .74rem;
  color: var(--muted);
  text-align: center;
  min-height: 1.35em;
  display: flex;
  gap: 7px;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
}
.status.good { color: var(--run); }
.chip {
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: 999px;
  border: 1px solid var(--line);
  color: var(--muted);
  font-weight: 600;
  white-space: nowrap;
}
.chip.on { border-color: var(--run); color: var(--run); background: var(--run-soft); }

/* ── stats ──────────────────────────────────────────────────────────────── */
.stats { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 10px; }
.stat { display: grid; gap: 1px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 1.28rem;
  font-weight: 620;
  letter-spacing: -0.02em;
  overflow-wrap: anywhere;
}
.stat .l {
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ── settings ───────────────────────────────────────────────────────────── */
.presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.preset { font-size: .78rem; padding: 6px 10px; }
.preset .n { font-family: var(--font-mono); color: var(--muted); font-size: .72rem; }
.preset[aria-pressed="true"] .n { color: var(--accent-ink); }
.fields { display: grid; gap: 9px; }
.field { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; }
.field label { font-size: .85rem; min-width: 0; }
.field .unit { font-family: var(--font-mono); font-size: .68rem; color: var(--muted); }
.spin { display: flex; align-items: center; gap: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--surface); }
.spin button {
  font: inherit;
  width: 30px;
  height: 32px;
  border: 0;
  background: none;
  color: var(--muted);
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
  display: grid;
  place-items: center;
  touch-action: manipulation;
}
.spin button:hover { background: var(--accent-soft); color: var(--accent-ink); }
.spin button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.spin input {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: .9rem;
  font-weight: 600;
  width: 44px;
  height: 32px;
  text-align: center;
  border: 0;
  border-left: 1px solid var(--line);
  border-right: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  padding: 0;
  -moz-appearance: textfield;
}
.spin input::-webkit-outer-spin-button,
.spin input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.spin input:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── log ────────────────────────────────────────────────────────────────── */
.log { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
.log li {
  display: grid;
  grid-template-columns: auto auto 1fr auto;
  align-items: center;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px dashed var(--line);
  font-size: .85rem;
}
.log li:last-child { border-bottom: 0; padding-bottom: 0; }
.log li:first-child { padding-top: 0; }
.log .when { font-family: var(--font-mono); font-size: .74rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.log .what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log .len { font-family: var(--font-mono); font-size: .76rem; font-variant-numeric: tabular-nums; color: var(--ink); }
.log li.sample .when, .log li.sample .what, .log li.sample .len { opacity: .62; }
.mark { width: 7px; height: 7px; border-radius: 50%; background: var(--run); flex: none; }
.empty { color: var(--muted); font-size: .85rem; margin: 0; }

.keys {
  font-family: var(--font-mono);
  font-size: .68rem;
  color: var(--muted);
  display: flex;
  flex-wrap: wrap;
  gap: 4px 12px;
  justify-content: center;
}
.keys kbd {
  font-family: inherit;
  border: 1px solid var(--line);
  border-bottom-width: 2px;
  border-radius: 4px;
  padding: 0 4px;
  color: var(--ink);
  background: var(--bg);
}

footer {
  margin-top: 18px;
  padding-top: 12px;
  border-top: 1px solid var(--line);
  display: flex;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  font-family: var(--font-mono);
  font-size: .7rem;
  color: var(--muted);
}

@media (max-width: 400px) {
  .wrap { padding: 16px 12px 22px; }
  .btn.primary { min-width: 118px; }
  .stats { gap: 8px; }
  .stat .v { font-size: 1.1rem; }
}

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

  <header>
    <h1>Focus Timer</h1>
    <p class="lede">Work for 25 minutes, rest for 5, repeat. The ring is the whole story: how much of this stretch is
      left, and a chime when it flips. Change the minutes to whatever actually suits you.</p>
  </header>

  <main class="grid">

    <section class="card hero" id="hero" data-phase="focus">
      <h2><span>timer</span><span class="chip" id="sampleChip">sample state</span></h2>
      <div class="body">

        <button class="ringbtn" id="ringBtn" type="button" aria-label="Start the timer">
          <svg viewBox="0 0 280 280" aria-hidden="true" focusable="false">
            <defs>
              <linearGradient id="gradFocus" x1="0" y1="1" x2="1" y2="0">
                <stop offset="0"   stop-color="#C2500C" style="stop-color: var(--ring-a)"/>
                <stop offset=".55" stop-color="#C05600" style="stop-color: var(--ring-b)"/>
                <stop offset="1"   stop-color="#D48C52" style="stop-color: var(--ring-c)"/>
              </linearGradient>
              <filter id="soften" x="-25%" y="-25%" width="150%" height="150%">
                <feGaussianBlur stdDeviation="7"/>
              </filter>
            </defs>
            <circle class="dialface" cx="140" cy="140" r="104"/>
            <g id="ticks"></g>
            <circle class="track" cx="140" cy="140" r="112"/>
            <circle class="glow" id="glowArc" cx="140" cy="140" r="112" transform="rotate(-90 140 140)"/>
            <circle class="prog focus" id="progArc" cx="140" cy="140" r="112" transform="rotate(-90 140 140)"/>
            <g id="headG" transform="rotate(-90 140 140)">
              <circle class="headring" cx="252" cy="140" r="10"/>
              <circle class="head" cx="252" cy="140" r="7"/>
            </g>
          </svg>
          <span class="center">
            <span class="phase" id="phaseLabel">Focus</span>
            <span class="time" id="timeLabel">16:30</span>
            <span class="subline" id="subLabel">sample · press space</span>
          </span>
        </button>

        <div class="dots" id="dots" role="img" aria-label="Cycle progress"></div>

        <div class="controls">
          <button class="btn primary" id="startBtn" type="button">
            <span id="startIco"></span>
            <span id="startText">Start</span>
            <span class="k">space</span>
          </button>
          <button class="btn" id="skipBtn" type="button" title="Jump to the next stretch">
            <svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5.5v13l9-6.5-9-6.5z" fill="currentColor"/><rect x="16.5" y="5.5" width="2.4" height="13" rx="1" fill="currentColor"/></svg>
            Skip
          </button>
          <button class="btn" id="resetBtn" type="button" title="Back to the start of a focus stretch">
            <svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" aria-hidden="true"><path d="M4.5 12a7.5 7.5 0 1 0 2.4-5.5"/><path d="M4 4v4h4"/></svg>
            Reset
          </button>
          <button class="btn" id="soundBtn" type="button" aria-pressed="true" aria-label="Chime is on. Turn it off.">
            <span id="soundIco"></span>
            <span id="soundText">Chime on</span>
          </button>
        </div>

        <p class="status" id="status" role="status" aria-live="polite"></p>

        <p class="keys">
          <span><kbd>space</kbd> start / pause</span>
          <span><kbd>s</kbd> skip</span>
          <span><kbd>r</kbd> reset</span>
          <span><kbd>m</kbd> chime</span>
        </p>
      </div>
    </section>

    <div class="side">

      <section class="card">
        <h2><span>today</span><span id="setLabel">3/4 to long rest</span></h2>
        <div class="body">
          <div class="stats">
            <div class="stat"><span class="v" id="statSessions">3</span><span class="l">sessions</span></div>
            <div class="stat"><span class="v" id="statTime">1h 15m</span><span class="l">focused</span></div>
            <div class="stat"><span class="v" id="statNext">15m</span><span class="l">next rest</span></div>
          </div>
        </div>
      </section>

      <section class="card">
        <h2><span>lengths</span><span id="cycleSum">25 / 5 / 15</span></h2>
        <div class="body">
          <div class="presets" id="presets"></div>
          <div class="fields" id="fields"></div>
        </div>
      </section>

      <section class="card">
        <h2><span>finished stretches</span><span id="logCount">3 logged</span></h2>
        <div class="body">
          <ul class="log" id="log"></ul>
          <p class="empty" id="logEmpty" hidden>Nothing finished yet — the first completed stretch lands here.</p>
        </div>
      </section>

    </div>
  </main>

  <footer>
    <span>demo · sloprun.dev</span>
    <span id="engine">pomodoro · web audio chime · no storage</span>
  </footer>
</div>

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

  /* ---------- platform theme bridge ---------- */
  addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  /* ---------- elements ---------- */
  var $ = function (id) { return document.getElementById(id); };
  var hero = $("hero"), ringBtn = $("ringBtn"), progArc = $("progArc"), glowArc = $("glowArc");
  var headG = $("headG"), ticksG = $("ticks");
  var phaseLabel = $("phaseLabel"), timeLabel = $("timeLabel"), subLabel = $("subLabel");
  var dotsEl = $("dots"), statusEl = $("status"), sampleChip = $("sampleChip");
  var startBtn = $("startBtn"), startText = $("startText"), startIco = $("startIco"), skipBtn = $("skipBtn");
  var resetBtn = $("resetBtn"), soundBtn = $("soundBtn"), soundText = $("soundText"), soundIco = $("soundIco");
  var statSessions = $("statSessions"), statTime = $("statTime"), statNext = $("statNext");
  var setLabel = $("setLabel"), cycleSum = $("cycleSum"), presetsEl = $("presets"), fieldsEl = $("fields");
  var logEl = $("log"), logEmpty = $("logEmpty"), logCount = $("logCount");

  var C = 2 * Math.PI * 112;            /* ring circumference, r = 112 */

  /* ---------- state ---------- */
  var LEN = { focus: 25, rest: 5, longRest: 15, perSet: 4 };   /* minutes / count */
  var LIMITS = { focus: [1, 90], rest: [1, 30], longRest: [1, 60], perSet: [2, 8] };

  var st = {
    phase: "focus",       /* focus | rest | longRest */
    running: false,
    elapsed: 0,           /* ms into the current stretch */
    startedAt: 0,         /* performance.now() minus elapsed */
    done: 0,              /* finished focus stretches */
    inSet: 0,             /* finished focus stretches since the last long rest */
    focusedMs: 0,
    muted: false,
    sample: true,
    log: []
  };

  /* ---------- preloaded sample so the demo opens mid-flow ---------- */
  (function seed() {
    var now = Date.now();
    var mins = [36, 66, 96];        /* newest first, same order live entries land in */
    for (var i = 0; i < mins.length; i++) {
      st.log.push({ label: "Focus", min: 25, at: now - mins[i] * 60000, sample: true });
    }
    st.done = 3;
    st.inSet = 3;
    st.focusedMs = 75 * 60000;
    st.elapsed = 8 * 60000 + 30000;     /* opens at 16:30 left on the fourth stretch */
  })();

  /* ---------- helpers ---------- */
  function totalMs() {
    if (st.phase === "focus") return LEN.focus * 60000;
    if (st.phase === "rest") return LEN.rest * 60000;
    return LEN.longRest * 60000;
  }
  function phaseName(p) {
    return p === "focus" ? "Focus" : p === "rest" ? "Short rest" : "Long rest";
  }
  function clampInt(v, lo, hi) {
    v = Math.round(Number(v));
    if (!isFinite(v)) v = lo;
    return Math.max(lo, Math.min(hi, v));
  }
  function mmss(ms) {
    var s = Math.max(0, Math.ceil(ms / 1000));
    var m = Math.floor(s / 60);
    var r = s % 60;
    return (m < 10 ? "0" : "") + m + ":" + (r < 10 ? "0" : "") + r;
  }
  function human(ms) {
    var m = Math.round(ms / 60000);
    if (m < 60) return m + "m";
    var h = Math.floor(m / 60);
    return h + "h " + (m % 60) + "m";
  }
  function clockAt(ms) {
    var d = new Date(Date.now() + ms);
    try {
      return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
    } catch (e) {
      var hh = d.getHours(), mm = d.getMinutes();
      return (hh < 10 ? "0" : "") + hh + ":" + (mm < 10 ? "0" : "") + mm;
    }
  }
  function clockOf(t) {
    var d = new Date(t);
    try {
      return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
    } catch (e) {
      var hh = d.getHours(), mm = d.getMinutes();
      return (hh < 10 ? "0" : "") + hh + ":" + (mm < 10 ? "0" : "") + mm;
    }
  }

  /* ---------- chime (web audio, built from scratch, no files) ---------- */
  var ac = null;
  function audio() {
    if (!ac) {
      var Ctx = window.AudioContext || window.webkitAudioContext;
      if (!Ctx) return null;
      try { ac = new Ctx(); } catch (e) { return null; }
    }
    if (ac.state === "suspended" && ac.resume) { try { ac.resume(); } catch (e) {} }
    return ac;
  }
  function bell(a, out, freq, at, dur, gain) {
    var o = a.createOscillator(), g = a.createGain();
    o.type = "sine";
    o.frequency.setValueAtTime(freq, at);
    g.gain.setValueAtTime(0.0001, at);
    g.gain.exponentialRampToValueAtTime(gain, at + 0.012);
    g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
    o.connect(g); g.connect(out);
    o.start(at);
    o.stop(at + dur + 0.05);
  }
  function chime(kind) {
    if (st.muted) return true;
    var a = audio();
    if (!a) return false;
    var out = a.createGain();
    out.gain.value = 0.9;
    var lp = a.createBiquadFilter();
    lp.type = "lowpass";
    lp.frequency.value = 3200;
    lp.Q.value = 0.4;
    lp.connect(out);
    out.connect(a.destination);
    /* rising three-note figure to end focus, softer falling pair to end a rest */
    var notes = kind === "focus" ? [523.25, 659.25, 987.77] : [659.25, 493.88];
    var t0 = a.currentTime + 0.03;
    for (var i = 0; i < notes.length; i++) {
      var at = t0 + i * 0.135;
      bell(a, lp, notes[i], at, 1.9, kind === "focus" ? 0.16 : 0.12);
      bell(a, lp, notes[i] * 2, at, 0.7, 0.045);          /* a little shimmer on top */
    }
    /* let the graph fall out of scope once it has rung out */
    setTimeout(function () { try { out.disconnect(); lp.disconnect(); } catch (e) {} }, 3200);
    return true;
  }

  /* ---------- ticks around the dial ---------- */
  (function drawTicks() {
    var frag = document.createDocumentFragment();
    for (var i = 0; i < 60; i++) {
      var maj = i % 5 === 0;
      var a = (i / 60) * Math.PI * 2 - Math.PI / 2;
      var r1 = maj ? 124 : 127, r2 = maj ? 133 : 132;
      var ln = document.createElementNS("http://www.w3.org/2000/svg", "line");
      ln.setAttribute("x1", (140 + Math.cos(a) * r1).toFixed(2));
      ln.setAttribute("y1", (140 + Math.sin(a) * r1).toFixed(2));
      ln.setAttribute("x2", (140 + Math.cos(a) * r2).toFixed(2));
      ln.setAttribute("y2", (140 + Math.sin(a) * r2).toFixed(2));
      ln.setAttribute("class", "tickline" + (maj ? " maj" : ""));
      frag.appendChild(ln);
    }
    ticksG.appendChild(frag);
  })();

  /* ---------- settings UI ---------- */
  var FIELDS = [
    { key: "focus", label: "Focus stretch", unit: "min" },
    { key: "rest", label: "Short rest", unit: "min" },
    { key: "longRest", label: "Long rest", unit: "min" },
    { key: "perSet", label: "Stretches before a long rest", unit: "" }
  ];
  var inputs = {};
  FIELDS.forEach(function (f) {
    var row = document.createElement("div");
    row.className = "field";
    var lab = document.createElement("label");
    lab.setAttribute("for", "in_" + f.key);
    lab.textContent = f.label;
    if (f.unit) {
      var u = document.createElement("span");
      u.className = "unit";
      u.textContent = " (" + f.unit + ")";
      lab.appendChild(u);
    }
    var spin = document.createElement("div");
    spin.className = "spin";

    var minus = document.createElement("button");
    minus.type = "button";
    minus.textContent = "−";
    minus.setAttribute("aria-label", "Decrease " + f.label.toLowerCase());

    var inp = document.createElement("input");
    inp.type = "number";
    inp.id = "in_" + f.key;
    inp.min = String(LIMITS[f.key][0]);
    inp.max = String(LIMITS[f.key][1]);
    inp.step = "1";
    inp.value = String(LEN[f.key]);
    inp.setAttribute("inputmode", "numeric");

    var plus = document.createElement("button");
    plus.type = "button";
    plus.textContent = "+";
    plus.setAttribute("aria-label", "Increase " + f.label.toLowerCase());

    minus.addEventListener("click", function () { bump(f.key, -1); });
    plus.addEventListener("click", function () { bump(f.key, 1); });
    inp.addEventListener("input", function () {
      var raw = inp.value;
      if (raw === "") return;                     /* let them clear it mid-typing */
      setLen(f.key, clampInt(raw, LIMITS[f.key][0], LIMITS[f.key][1]), false);
    });
    inp.addEventListener("blur", function () {
      setLen(f.key, clampInt(inp.value, LIMITS[f.key][0], LIMITS[f.key][1]), true);
    });

    spin.appendChild(minus); spin.appendChild(inp); spin.appendChild(plus);
    row.appendChild(lab); row.appendChild(spin);
    fieldsEl.appendChild(row);
    inputs[f.key] = inp;
  });

  function bump(key, d) {
    setLen(key, clampInt(LEN[key] + d, LIMITS[key][0], LIMITS[key][1]), true);
  }
  /* A length change must never *complete* the stretch you are in. If the new
     length is already behind the time served, the stretch restarts on the new
     clock — otherwise the very next tick would bank a session and ring the
     chime just because you retyped a number. */
  function reflowStretch(announce) {
    if (st.elapsed < totalMs()) return;
    st.elapsed = 0;
    st.startedAt = performance.now();
    if (announce && st.running) {
      say("shorter than the time already served — this stretch restarts at " +
          Math.round(totalMs() / 60000) + " min", false);
    }
  }
  function setLen(key, val, writeBack) {
    if (LEN[key] === val && !writeBack) return;
    LEN[key] = val;
    if (writeBack) inputs[key].value = String(val);
    if (key === "perSet" && st.inSet > val) st.inSet = val;
    reflowStretch(true);
    render();
  }

  var PRESETS = [
    { name: "Classic", focus: 25, rest: 5, longRest: 15, perSet: 4 },
    { name: "Deep", focus: 50, rest: 10, longRest: 20, perSet: 2 },
    { name: "Quick", focus: 15, rest: 3, longRest: 12, perSet: 4 }
  ];
  PRESETS.forEach(function (p) {
    var b = document.createElement("button");
    b.type = "button";
    b.className = "btn preset";
    b.setAttribute("aria-pressed", "false");
    b.innerHTML = "";
    b.appendChild(document.createTextNode(p.name + " "));
    var n = document.createElement("span");
    n.className = "n";
    n.textContent = p.focus + "/" + p.rest;
    b.appendChild(n);
    b.addEventListener("click", function () {
      ["focus", "rest", "longRest", "perSet"].forEach(function (k) {
        LEN[k] = p[k];
        inputs[k].value = String(p[k]);
      });
      if (!st.running) st.elapsed = 0;
      if (st.inSet > LEN.perSet) st.inSet = LEN.perSet;
      reflowStretch(false);
      say(p.name.toLowerCase() + " lengths: " + p.focus + " min focus, " + p.rest + " min rest", false);
      render();
    });
    presetsEl.appendChild(b);
  });

  /* ---------- transport ---------- */
  /* returns true when it actually wiped the seeded state, so callers can say so */
  function clearSample() {
    if (!st.sample) return false;
    st.sample = false;
    st.log = [];
    st.done = 0;
    st.inSet = 0;
    st.focusedMs = 0;
    st.elapsed = 0;
    st.phase = "focus";
    return true;
  }

  function start() {
    if (st.running) return;
    var cleared = clearSample();
    audio();                                  /* unlock inside the click/key gesture */
    st.running = true;
    st.startedAt = performance.now() - st.elapsed;
    loop();
    say((cleared ? "sample cleared · " : "") +
        phaseName(st.phase).toLowerCase() + " running · ends " + clockAt(totalMs() - st.elapsed), false);
    render();
  }
  function pause() {
    if (!st.running) return;
    st.running = false;
    st.elapsed = performance.now() - st.startedAt;
    stopLoop();
    say("paused at " + mmss(totalMs() - st.elapsed) + " left", false);
    render();
  }
  function toggle() { st.running ? pause() : start(); }

  function reset() {
    var wasSample = st.sample;
    clearSample();
    st.running = false;
    stopLoop();
    st.elapsed = 0;
    st.phase = "focus";
    st.inSet = 0;
    say(wasSample ? "sample cleared — clean slate, " + LEN.focus + " min on the clock"
                  : "reset to a fresh " + LEN.focus + " min focus stretch", false);
    render();
  }

  function skip() {
    advance(false, clearSample() ? "sample cleared" : "");
  }

  function advance(natural, note) {
    var finished = st.phase;
    var mins = Math.round(totalMs() / 60000);
    var pre = note ? note + " · " : "";

    if (finished === "focus") {
      if (natural) {
        st.done++;
        st.inSet++;
        st.focusedMs += totalMs();
        st.log.unshift({ label: "Focus", min: mins, at: Date.now(), sample: false });
        if (st.log.length > 8) st.log.pop();
      }
      st.phase = (st.inSet >= LEN.perSet) ? "longRest" : "rest";
    } else {
      if (finished === "longRest") st.inSet = 0;
      st.phase = "focus";
    }

    st.elapsed = 0;
    st.startedAt = performance.now();

    if (natural) {
      chime(finished === "focus" ? "focus" : "rest");
      if (finished === "focus") {
        say(pre + "focus stretch done · " + mins + " min banked · " + phaseName(st.phase).toLowerCase() + " next", true);
      } else {
        say(pre + "rest over · back to " + LEN.focus + " min of focus", false);
      }
    } else {
      say(pre + "skipped to " + phaseName(st.phase).toLowerCase() + (finished === "focus" ? " — that stretch was not counted" : ""), false);
    }
    render();
  }

  /* ---------- clock loop ---------- */
  var raf = 0;
  function tick() {
    if (!st.running) return;
    st.elapsed = performance.now() - st.startedAt;
    var total = totalMs();
    if (st.elapsed >= total) {
      st.elapsed = total;
      renderClock();
      advance(true);
      return;
    }
    renderClock();
  }
  function frame() {
    tick();
    if (st.running) raf = requestAnimationFrame(frame);
  }
  function loop() {
    if (raf) cancelAnimationFrame(raf);
    raf = requestAnimationFrame(frame);
  }
  function stopLoop() {
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
  }
  /* rAF gets throttled in a hidden tab; this keeps the flip honest either way */
  setInterval(function () { if (st.running) tick(); }, 1000);
  document.addEventListener("visibilitychange", function () {
    if (!document.hidden && st.running) { tick(); loop(); }
  });

  /* ---------- status line ---------- */
  var statusTimer = 0;
  function say(text, good) {
    statusEl.textContent = text;
    statusEl.className = "status" + (good ? " good" : "");
    if (statusTimer) clearTimeout(statusTimer);
    if (good) {
      statusTimer = setTimeout(function () {
        statusEl.className = "status";
      }, 6000);
    }
  }

  /* ---------- render ---------- */
  var ICO_PLAY = '<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l11-6.5-11-6.5z" fill="currentColor"/></svg>';
  var ICO_PAUSE = '<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="5.5" width="3.6" height="13" rx="1.1" fill="currentColor"/><rect x="13.4" y="5.5" width="3.6" height="13" rx="1.1" fill="currentColor"/></svg>';
  var SPEAKER_ON = '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z" fill="currentColor" stroke="none"/><path d="M15.5 9.2a4 4 0 0 1 0 5.6"/><path d="M18.2 6.6a7.6 7.6 0 0 1 0 10.8"/></svg>';
  var SPEAKER_OFF = '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 9.5h3.5L12 5.5v13L7.5 14.5H4z" fill="currentColor" stroke="none"/><path d="M16 9.5l5 5M21 9.5l-5 5"/></svg>';
  soundIco.innerHTML = SPEAKER_ON;
  startIco.innerHTML = ICO_PLAY;

  progArc.setAttribute("stroke-dasharray", C.toFixed(2));
  glowArc.setAttribute("stroke-dasharray", C.toFixed(2));

  var lastClock = "";          /* mm:ss last painted — text work is once a second */
  var lastRunning = null;      /* so the play/pause glyph is only re-parsed on change */

  /* every frame: ring geometry, plus the digits when the second actually rolls */
  function renderClock() {
    var total = totalMs();
    var left = Math.max(0, total - st.elapsed);
    var remain = total > 0 ? left / total : 0;
    var off = C * (1 - remain);
    progArc.setAttribute("stroke-dashoffset", off.toFixed(2));
    glowArc.setAttribute("stroke-dashoffset", off.toFixed(2));
    headG.setAttribute("transform", "rotate(" + (-90 + 360 * remain).toFixed(2) + " 140 140)");

    var t = mmss(left);
    if (t === lastClock) return;
    lastClock = t;
    timeLabel.textContent = t;
    if (st.running) subLabel.textContent = "ends " + clockAt(left);
    ringBtn.setAttribute("aria-label",
      (st.running ? "Pause" : "Start") + " the timer. " + phaseName(st.phase) + ", " + t + " left.");
    startBtn.setAttribute("aria-label", startText.textContent + " the timer, " + t + " left");
  }

  /* only on real state changes: phase, counters, settings, log */
  function renderChrome() {
    var total = totalMs();
    var left = Math.max(0, total - st.elapsed);
    var isFocus = st.phase === "focus";

    progArc.setAttribute("class", "prog " + (isFocus ? "focus" : "rest"));
    hero.setAttribute("data-phase", isFocus ? "focus" : "rest");
    ringBtn.className = "ringbtn" + (st.running ? " running" : "");

    /* centre */
    phaseLabel.textContent = phaseName(st.phase);
    if (!st.running) {
      subLabel.textContent = st.sample
        ? "sample · press space"
        : "paused · " + Math.round(total / 60000) + " min stretch";
    }

    /* dots */
    var want = LEN.perSet;
    while (dotsEl.children.length > want) dotsEl.removeChild(dotsEl.lastChild);
    while (dotsEl.children.length < want) {
      var d = document.createElement("span");
      d.className = "dot";
      dotsEl.appendChild(d);
    }
    for (var i = 0; i < want; i++) {
      var cls = "dot";
      if (i < st.inSet) cls += " done";
      else if (i === st.inSet && isFocus) cls += " now";
      dotsEl.children[i].className = cls;
    }
    dotsEl.setAttribute("aria-label", st.inSet + " of " + want + " focus stretches done before the long rest");

    /* transport */
    startText.textContent = st.running ? "Pause" : (st.elapsed > 0 && !st.sample ? "Resume" : "Start");
    if (lastRunning !== st.running) {
      startIco.innerHTML = st.running ? ICO_PAUSE : ICO_PLAY;
      lastRunning = st.running;
    }
    ringBtn.setAttribute("aria-label",
      (st.running ? "Pause" : "Start") + " the timer. " + phaseName(st.phase) + ", " + mmss(left) + " left.");
    startBtn.setAttribute("aria-label", startText.textContent + " the timer, " + mmss(left) + " left");

    /* stats */
    statSessions.textContent = String(st.done);
    statTime.textContent = st.focusedMs ? human(st.focusedMs) : "0m";
    var nextRest = (isFocus && st.inSet + 1 >= LEN.perSet) ? LEN.longRest : LEN.rest;
    statNext.textContent = isFocus ? nextRest + "m" : "now";
    setLabel.textContent = st.inSet + "/" + LEN.perSet + " to long rest";
    cycleSum.textContent = LEN.focus + " / " + LEN.rest + " / " + LEN.longRest;

    /* presets */
    for (var p = 0; p < PRESETS.length; p++) {
      var pr = PRESETS[p];
      var on = pr.focus === LEN.focus && pr.rest === LEN.rest && pr.longRest === LEN.longRest && pr.perSet === LEN.perSet;
      presetsEl.children[p].setAttribute("aria-pressed", on ? "true" : "false");
    }

    /* sample chip */
    sampleChip.hidden = !st.sample;

    /* log */
    logEl.textContent = "";
    for (var j = 0; j < st.log.length; j++) {
      var e = st.log[j];
      var li = document.createElement("li");
      if (e.sample) li.className = "sample";
      var mk = document.createElement("span");
      mk.className = "mark";
      var wh = document.createElement("span");
      wh.className = "what";
      wh.textContent = e.label + (e.sample ? " (sample)" : "");
      var ln = document.createElement("span");
      ln.className = "len";
      ln.textContent = e.min + "m";
      var wn = document.createElement("span");
      wn.className = "when";
      wn.textContent = clockOf(e.at);
      li.appendChild(mk); li.appendChild(wn); li.appendChild(wh); li.appendChild(ln);
      logEl.appendChild(li);
    }
    logEmpty.hidden = st.log.length > 0;
    logCount.textContent = st.log.length ? st.log.length + " logged" : "none yet";

    lastClock = "";        /* phase/length may have moved — let the digits repaint */
  }

  function render() { renderChrome(); renderClock(); }

  /* ---------- wiring ---------- */
  startBtn.addEventListener("click", toggle);
  ringBtn.addEventListener("click", toggle);
  skipBtn.addEventListener("click", skip);
  resetBtn.addEventListener("click", reset);
  soundBtn.addEventListener("click", function () {
    st.muted = !st.muted;
    soundBtn.setAttribute("aria-pressed", st.muted ? "false" : "true");
    soundText.textContent = st.muted ? "Chime off" : "Chime on";
    soundIco.innerHTML = st.muted ? SPEAKER_OFF : SPEAKER_ON;
    soundBtn.setAttribute("aria-label", st.muted ? "Chime is off. Turn it on." : "Chime is on. Turn it off.");
    soundBtn.className = "btn" + (st.muted ? " hushed" : "");
    if (!st.muted) {
      say(chime("rest")
        ? "chime on — that is what you will hear"
        : "this browser gives us no web audio — the timer still flips on its own, silently", false);
    } else {
      say("chime off", false);
    }
  });

  document.addEventListener("keydown", function (ev) {
    if (ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat) return;
    var t = ev.target, tag = t && t.tagName ? t.tagName.toLowerCase() : "";
    var typing = tag === "input" || tag === "textarea" || tag === "select" || (t && t.isContentEditable);
    var onButton = tag === "button";
    var k = ev.key;
    if (k === " " || k === "Spacebar") {
      if (typing || onButton) return;            /* let the focused control have it */
      ev.preventDefault();
      toggle();
      return;
    }
    if (typing) return;
    if (k === "s" || k === "S") { ev.preventDefault(); skip(); }
    else if (k === "r" || k === "R") { ev.preventDefault(); reset(); }
    else if (k === "m" || k === "M") { ev.preventDefault(); soundBtn.click(); }
  });

  /* ---------- go ---------- */
  say("sample state: a fourth focus stretch, paused with 16:30 left — press space to clear it and run a real 25 min", false);
  render();
})();
</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