Habit Heatmap
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A year of days as 53 × 7 squares, opening not on an empty grid but on somebody's plausible year: thin patches, one long deliberate run, the break that kills it, and a smaller streak still alive today. Click or drag to paint a shade from 0 to 4 and every counter recounts under your cursor — current streak, longest streak, consistency, total effort — while the line under the board rewrites itself to say what the year now looks like. Hit ring the longest run to see where the good stretch actually was, then paint over the gap that ended it and watch the longest-streak number jump. It runs entirely from the keyboard too: arrows walk the grid, 0–4 set a shade, and Shift + arrow paints a whole run.
<!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>Habit Heatmap — sloprun.dev</title>
<link rel="icon" href="data:,">
<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: the 5-step shade ramp. Amber, because green is reserved for
"it worked" states only. Both ramps step down in lightness monotonically. */
:root {
--l0: #E7E5DE; --l1: #FADFBB; --l2: #F3B36A; --l3: #D97E14; --l4: #9E4A02;
--shadow: 0 1px 2px rgba(26, 29, 33, .05);
--ring: rgba(26, 29, 33, .55);
/* --accent on --accent-soft is only 4.2:1 — fine for the 25px hero number,
short of AA for small text. This is the same amber, darkened for copy. */
--accent-ink: #8F4100;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--l0: #262A32; --l1: #4A3413; --l2: #8A5A17; --l3: #C88526; --l4: #FFB454;
--shadow: 0 1px 2px rgba(0, 0, 0, .35);
--ring: rgba(232, 230, 225, .6);
--accent-ink: #FFB454;
}
}
:root[data-theme="dark"] {
--l0: #262A32; --l1: #4A3413; --l2: #8A5A17; --l3: #C88526; --l4: #FFB454;
--shadow: 0 1px 2px rgba(0, 0, 0, .35);
--ring: rgba(232, 230, 225, .6);
--accent-ink: #FFB454;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap { max-width: 1010px; margin: 0 auto; padding: 22px 16px 26px;
display: flex; flex-direction: column; gap: 14px; }
/* ── header ───────────────────────────────────────────────────────────────── */
.head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px 16px; }
.head > div { min-width: 0; flex: 1 1 320px; }
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.pill { flex: none; display: inline-flex; align-items: center; gap: 7px;
font-family: var(--font-mono); font-size: 11.5px; letter-spacing: .04em;
padding: 6px 11px; border-radius: 999px; border: 1px solid var(--line);
background: var(--surface); color: var(--muted); white-space: nowrap;
font-variant-numeric: tabular-nums; }
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: none; }
.pill[data-state="live"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
/* ── stat strip ───────────────────────────────────────────────────────────── */
.stats { margin: 0; display: grid; gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); }
.stat { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 10px 12px 11px; box-shadow: var(--shadow);
min-width: 0; }
.stat dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em;
text-transform: uppercase; color: var(--muted); font-weight: 600; }
.stat dd { margin: 4px 0 0; font-family: var(--font-mono); font-size: 25px;
line-height: 1.08; letter-spacing: -.02em; font-variant-numeric: tabular-nums; font-weight: 600; }
.stat dd .unit { font-size: 13px; font-weight: 500; color: var(--muted); margin-left: 3px; }
.stat small { display: block; margin-top: 4px; font-family: var(--font-mono);
font-size: 10.5px; color: var(--muted); letter-spacing: .02em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stat.hero dd { color: var(--accent); }
.stat.hero[data-live="1"] dd { color: var(--run); }
/* ── calendar card ────────────────────────────────────────────────────────── */
.card { background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 13px 14px 14px; box-shadow: var(--shadow); }
.chead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px; margin-bottom: 11px; }
.chead h2 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: -.01em; }
.chead .range { font-family: var(--font-mono); font-size: 11px; color: var(--muted);
letter-spacing: .04em; }
.legend { margin-left: auto; display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .05em; }
.legend i { width: 11px; height: 11px; border-radius: 2px; border: 1px solid rgba(128,128,128,.16); }
.cal { --cs: clamp(11px, calc((min(100vw, 1010px) - 104px) / 53 - 3px), 15px); --cg: 3px;
display: flex; gap: 8px; align-items: flex-start; }
.dows { flex: none; display: flex; flex-direction: column; gap: var(--cg);
padding-top: calc(15px + var(--cg)); font-family: var(--font-mono);
font-size: 9.5px; color: var(--muted); letter-spacing: .03em; }
.dows span { height: var(--cs); line-height: var(--cs); text-align: right; min-width: 20px; }
.scroll { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden;
padding-bottom: 2px; scrollbar-width: thin; }
.board { display: inline-flex; flex-direction: column; gap: var(--cg); }
.months { display: flex; gap: var(--cg); height: 15px; }
.months b { width: var(--cs); flex: none; position: relative; }
.months b span { position: absolute; left: 0; top: 0; font-family: var(--font-mono);
font-size: 10px; color: var(--muted); letter-spacing: .04em; white-space: nowrap; }
.grid { display: flex; flex-direction: column; gap: var(--cg);
-webkit-user-select: none; user-select: none; touch-action: pan-x; }
.row { display: flex; gap: var(--cg); }
.cell { width: var(--cs); height: var(--cs); flex: none; border-radius: 2px;
background: var(--l0); box-shadow: inset 0 0 0 1px rgba(128, 128, 128, .1);
cursor: crosshair; transition: background-color .16s ease, box-shadow .16s ease; }
.cell[data-l="1"] { background: var(--l1); }
.cell[data-l="2"] { background: var(--l2); }
.cell[data-l="3"] { background: var(--l3); }
.cell[data-l="4"] { background: var(--l4); }
.cell.future { background: transparent; box-shadow: inset 0 0 0 1px rgba(128,128,128,.09);
cursor: default; }
.cell.today { box-shadow: inset 0 0 0 1px rgba(128,128,128,.1), 0 0 0 1.5px var(--ring); }
.cell.best { box-shadow: inset 0 0 0 1px rgba(128,128,128,.1), 0 0 0 1.5px var(--accent); }
.cell.today.best { box-shadow: inset 0 0 0 1px rgba(128,128,128,.1), 0 0 0 1.5px var(--accent); }
.cell:focus { outline: none; }
.cell:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; position: relative; z-index: 3; }
.grid.sweep .cell { transition-delay: calc(var(--w) * 4ms); }
.readout { margin: 11px 0 0; padding: 7px 9px; border-radius: 8px;
background: var(--bg); border: 1px solid var(--line);
font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
letter-spacing: .02em; font-variant-numeric: tabular-nums;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.readout b { color: var(--ink); font-weight: 600; }
.hint { margin: 7px 0 0; font-family: var(--font-mono); font-size: 10.5px;
color: var(--muted); letter-spacing: .03em; }
/* ── story ────────────────────────────────────────────────────────────────── */
.story { background: var(--accent-soft); border: 1px solid var(--line);
border-left: 3px solid var(--accent); border-radius: var(--radius);
padding: 11px 13px; }
.story b { display: block; font-family: var(--font-mono); font-size: 10px;
letter-spacing: .1em; text-transform: uppercase; color: var(--accent-ink); margin-bottom: 4px; }
.story p { margin: 0; font-size: 14px; line-height: 1.55; }
.story p em { font-style: normal; font-family: var(--font-mono); font-weight: 600;
font-variant-numeric: tabular-nums; }
/* ── controls ─────────────────────────────────────────────────────────────── */
.ctl { display: flex; flex-wrap: wrap; gap: 14px 20px; align-items: center; }
fieldset { margin: 0; padding: 0; border: 0; display: flex; align-items: center; gap: 9px;
flex-wrap: wrap; min-width: 0; }
legend { float: left; padding: 0 8px 0 0; font-family: var(--font-mono); font-size: 10px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
line-height: 26px; }
.sws { display: flex; gap: 6px; align-items: center; }
.sw { position: relative; display: inline-flex; }
.sw input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.chip { display: block; width: 26px; height: 26px; border-radius: 6px;
border: 1px solid var(--line); background: var(--l0);
transition: transform .14s ease, border-color .14s ease, box-shadow .14s ease; }
.chip[data-l="1"] { background: var(--l1); } .chip[data-l="2"] { background: var(--l2); }
.chip[data-l="3"] { background: var(--l3); } .chip[data-l="4"] { background: var(--l4); }
.sw input:checked + .chip { border-color: var(--accent);
box-shadow: 0 0 0 2px var(--surface), 0 0 0 3.5px var(--accent); transform: translateY(-1px); }
.sw input:focus-visible + .chip { outline: 2px solid var(--ink); outline-offset: 3px; }
.sw:hover .chip { border-color: var(--muted); }
button { font: inherit; color: inherit; cursor: pointer; font-family: var(--font-mono);
font-size: 11.5px; letter-spacing: .03em; padding: 6px 11px; border-radius: 8px;
border: 1px solid var(--line); background: var(--bg); color: var(--muted);
transition: background-color .14s ease, color .14s ease, border-color .14s ease; }
button:hover { border-color: var(--muted); color: var(--ink); }
button:focus { outline: none; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary { background: var(--accent-soft); color: var(--accent-ink); border-color: var(--accent); }
button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent-ink); border-color: var(--accent); }
button.primary:hover, button[aria-pressed="true"]:hover { color: var(--accent-ink); border-color: var(--accent); }
.acts { display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.foot { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: space-between;
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .05em;
padding-top: 2px; }
.sr { 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) {
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important;
transition-duration: .001ms !important; transition-delay: 0ms !important; scroll-behavior: auto !important; }
}
@media (max-width: 560px) {
.wrap { padding: 16px 12px 22px; gap: 12px; }
.stat dd { font-size: 21px; }
.card { padding: 11px 11px 12px; }
.cal { --cs: clamp(11px, calc((100vw - 78px) / 53 - 3px), 15px); }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div>
<h1>Habit Heatmap</h1>
<p class="lede">One square per day for the last year, shaded by how hard you went.
<b>Click or drag to paint</b> — the streak counters recount themselves under your cursor.</p>
</div>
<span class="pill" id="pill" data-state="idle">
<span class="dot" aria-hidden="true"></span><span id="pillText">reading the year</span>
</span>
</header>
<dl class="stats">
<div class="stat hero" id="s-cur"><dt>Current streak</dt>
<dd><span id="v-cur">0</span><span class="unit">days</span><small id="d-cur"> </small></dd></div>
<div class="stat"><dt>Longest streak</dt>
<dd><span id="v-best">0</span><span class="unit">days</span><small id="d-best"> </small></dd></div>
<div class="stat"><dt>Days done</dt>
<dd><span id="v-done">0</span><small id="d-done"> </small></dd></div>
<div class="stat"><dt>Consistency</dt>
<dd><span id="v-rate">0</span><span class="unit">%</span><small id="d-rate"> </small></dd></div>
<div class="stat"><dt>Total effort</dt>
<dd><span id="v-sum">0</span><small id="d-sum"> </small></dd></div>
</dl>
<section class="card" aria-labelledby="calH">
<div class="chead">
<h2 id="calH">The year</h2>
<span class="range" id="range"></span>
<span class="legend" aria-hidden="true">less
<i style="background:var(--l0)"></i><i style="background:var(--l1)"></i><i style="background:var(--l2)"></i><i style="background:var(--l3)"></i><i style="background:var(--l4)"></i>
more</span>
</div>
<div class="cal">
<div class="dows" aria-hidden="true">
<span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span>
</div>
<div class="scroll" id="scroll">
<div class="board">
<div class="months" id="months" aria-hidden="true"></div>
<div class="grid" id="grid" role="grid"
aria-label="Year of days: 53 weeks across, 7 weekdays down. Arrow keys move, keys 0 to 4 set a day's shade, hold Shift while arrowing to paint a run."></div>
</div>
</div>
</div>
<p class="readout" id="readout">Hover a square — or press an arrow key to walk the year.</p>
<p class="hint" id="hint" hidden>scroll the board sideways for earlier weeks</p>
</section>
<section class="story">
<b>what the board says</b>
<p id="story"></p>
</section>
<section class="card">
<div class="ctl">
<fieldset>
<legend>Shade</legend>
<div class="sws" id="sws"></div>
</fieldset>
<div class="acts">
<button type="button" id="b-sample" class="primary">sample year</button>
<button type="button" id="b-random">randomize</button>
<button type="button" id="b-clear">clear</button>
<button type="button" id="b-best" aria-pressed="false">ring the longest run</button>
</div>
</div>
</section>
<footer class="foot">
<span>demo · sloprun.dev</span>
<span id="footR"></span>
</footer>
<p class="sr" id="live" role="status" aria-live="polite"></p>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var root = document.documentElement;
/* ── geometry: 53 columns of 7 days, last column is the week we're in ───── */
var WEEKS = 53, ROWS = 7, N = WEEKS * ROWS;
var MON = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
var DAY = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
var SHADE = ["nothing", "light", "steady", "strong", "all in"];
var today = new Date();
today.setHours(12, 0, 0, 0); /* noon: immune to DST shifts */
var start = new Date(today);
start.setDate(start.getDate() - today.getDay() - (WEEKS - 1) * 7); /* a Sunday */
var TODAY_I = (WEEKS - 1) * ROWS + today.getDay();
var VALID = TODAY_I + 1; /* paintable days, 365..371 */
var DATES = new Array(N);
(function () {
for (var i = 0; i < N; i++) { var d = new Date(start); d.setDate(d.getDate() + i); DATES[i] = d; }
})();
function fShort(i) { var d = DATES[i]; return d.getDate() + " " + MON[d.getMonth()]; }
function fFull(i) { var d = DATES[i]; return DAY[d.getDay()] + " " + d.getDate() + " " + MON[d.getMonth()] + " " + d.getFullYear(); }
var levels = new Uint8Array(N);
var cells = new Array(N);
/* ── a year that tells a story, not noise ───────────────────────────────── */
function mulberry32(a) {
return function () {
a = a + 0x6D2B79F5 | 0;
var t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function shade(rnd, mean) {
var v = Math.round(mean + (rnd() * 2 - 1) * 1.15);
return v < 1 ? 1 : v > 4 ? 4 : v;
}
/* Six stretches of the year, each with its own mood (how often, how hard),
then a deliberate long run, the break that ends it, and a live run today. */
function makeYear(seed) {
var rnd = mulberry32(seed >>> 0), i, k;
var a = new Uint8Array(N);
var moods = [[.24,1.4],[.55,2.0],[.90,3.1],[.09,1.2],[.66,2.4],[.80,2.8],[.38,1.7]];
for (k = moods.length - 1; k > 0; k--) {
var j = Math.floor(rnd() * (k + 1)), t = moods[k]; moods[k] = moods[j]; moods[j] = t;
}
var segs = 6, edge = [0];
for (k = 1; k < segs; k++) edge.push(Math.round(VALID * (k / segs + (rnd() - .5) * .07)));
edge.push(VALID);
for (k = 0; k < segs; k++) {
var m = moods[k];
for (i = edge[k]; i < edge[k + 1]; i++) if (rnd() < m[0]) a[i] = shade(rnd, m[1]);
}
var runLen = 46 + Math.floor(rnd() * 32);
var runAt = Math.floor(VALID * (.14 + rnd() * .24));
for (i = runAt; i < runAt + runLen && i < VALID; i++) a[i] = shade(rnd, 2.9);
if (runAt > 0) a[runAt - 1] = 0;
var gap = 9 + Math.floor(rnd() * 13);
for (i = runAt + runLen; i < runAt + runLen + gap && i < VALID; i++) a[i] = 0;
var cur = 5 + Math.floor(rnd() * 19);
for (i = VALID - cur; i < VALID; i++) if (i >= 0) a[i] = shade(rnd, 2.4);
if (VALID - cur - 1 >= 0) a[VALID - cur - 1] = 0;
return a;
}
/* ── build the board ────────────────────────────────────────────────────── */
var grid = $("grid"), months = $("months"), scroll = $("scroll");
(function build() {
var frag = document.createDocumentFragment(), d, w, i;
for (d = 0; d < ROWS; d++) {
var row = document.createElement("div");
row.className = "row"; row.setAttribute("role", "row");
for (w = 0; w < WEEKS; w++) {
i = w * ROWS + d;
var c = document.createElement("div");
c.className = "cell" + (i > TODAY_I ? " future" : "") + (i === TODAY_I ? " today" : "");
c.setAttribute("role", "gridcell");
c.style.setProperty("--w", w);
c.dataset.i = i;
if (i > TODAY_I) { c.setAttribute("aria-disabled", "true"); c.setAttribute("aria-label", "not here yet"); }
else { c.dataset.l = "0"; c.tabIndex = -1; }
cells[i] = c; row.appendChild(c);
}
frag.appendChild(row);
}
grid.appendChild(frag);
/* month labels sit on the week a month starts. Two real month starts are
always 4+ weeks apart, but the leading partial month can land one week
before the next one — the labels overflow their slot, so they would
collide. Only label the leading month when there is room for the word. */
var mstart = [], prevM = DATES[6].getMonth(), mm;
for (w = 1; w < WEEKS - 1; w++) {
mm = DATES[w * ROWS + 6].getMonth();
if (mm !== prevM) { mstart.push(w); prevM = mm; }
}
if (!mstart.length || mstart[0] >= 3) mstart.unshift(0);
var mfrag = document.createDocumentFragment(), mi = 0;
for (w = 0; w < WEEKS; w++) {
var slot = document.createElement("b");
if (mi < mstart.length && mstart[mi] === w) {
var s = document.createElement("span");
s.textContent = MON[DATES[Math.min(w * ROWS + 6, N - 1)].getMonth()];
slot.appendChild(s); mi++;
}
mfrag.appendChild(slot);
}
months.appendChild(mfrag);
$("range").textContent = MON[DATES[0].getMonth()] + " " + DATES[0].getFullYear() +
" → " + MON[today.getMonth()] + " " + today.getFullYear();
$("footR").textContent = VALID + " days · 5 shades";
})();
function label(i) {
return fFull(i) + ", " + (levels[i] ? "shade " + levels[i] + " of 4, " + SHADE[levels[i]] : "nothing");
}
function paint(i, v) {
if (i < 0 || i > TODAY_I || levels[i] === v) return false;
levels[i] = v;
var c = cells[i];
c.dataset.l = v;
c.setAttribute("aria-label", label(i));
return true;
}
/* ── counters ───────────────────────────────────────────────────────────── */
function measure() {
var done = 0, sum = 0, best = 0, bestEnd = -1, run = 0, last = -1, i;
for (i = 0; i < VALID; i++) {
if (levels[i]) {
done++; sum += levels[i]; last = i; run++;
if (run > best) { best = run; bestEnd = i; }
} else run = 0;
}
var open = levels[TODAY_I] === 0;
var end = open ? TODAY_I - 1 : TODAY_I, cur = 0;
for (i = end; i >= 0 && levels[i]; i--) cur++;
return { done: done, sum: sum, best: best, bestEnd: bestEnd, cur: cur, open: open, last: last };
}
var ringBest = false, bestFrom = -1, bestTo = -1;
function ring(from, to) {
if (from === bestFrom && to === bestTo) return;
var i;
if (bestFrom >= 0) for (i = bestFrom; i <= bestTo; i++) cells[i].classList.remove("best");
bestFrom = from; bestTo = to;
if (from >= 0) for (i = from; i <= to; i++) cells[i].classList.add("best");
}
var pristine = true;
function setPristine(v) {
pristine = v;
$("b-sample").className = v ? "primary" : "";
$("b-sample").textContent = v ? "sample year" : "restore sample year";
}
var S = null;
function refresh() {
var s = S = measure();
var pct = VALID ? Math.round(s.done / VALID * 100) : 0;
$("v-cur").textContent = s.cur;
$("d-cur").textContent = s.cur === 0 ? "nothing running" : (s.open ? "up to yesterday · today is open" : "counting today");
$("s-cur").dataset.live = s.cur > 0 ? "1" : "0";
$("v-best").textContent = s.best;
$("d-best").textContent = s.best ? fShort(s.bestEnd - s.best + 1) + " → " + fShort(s.bestEnd) : "no run yet";
$("v-done").textContent = s.done;
$("d-done").textContent = "of " + VALID + " days";
$("v-rate").textContent = pct;
$("d-rate").textContent = pct >= 80 ? "near daily" : pct >= 50 ? "most days" : pct >= 25 ? "on and off" : "rarely";
$("v-sum").textContent = s.sum;
$("d-sum").textContent = "shades added up";
var pill = $("pill");
pill.dataset.state = s.cur > 0 ? "live" : "idle";
$("pillText").textContent = s.cur > 0 ? "streak alive · " + s.cur + "d" : (s.done ? "no streak running" : "empty board");
var st;
if (!s.done) {
st = "The year is blank. Pick a shade below and drag across a few weeks — every counter above recounts as you paint.";
} else if (s.cur > 0 && s.cur >= s.best) {
st = "The run you are on <em>is</em> the longest of the year: <em>" + s.cur + "</em> days and still going. " +
"Shown up on <em>" + s.done + "</em> of <em>" + VALID + "</em> days.";
} else {
st = "Longest run: <em>" + s.best + "</em> days, ending " + fShort(s.bestEnd) + ". " +
(s.cur > 0
? "You are <em>" + s.cur + "</em> days into the current one — <em>" + (s.best - s.cur + 1) + "</em> more days to beat it."
: "Nothing running right now; the last day painted was " + fShort(s.last) + ".") +
" Shown up on <em>" + s.done + "</em> of <em>" + VALID + "</em> days.";
}
$("story").innerHTML = st;
ring(ringBest && s.best ? s.bestEnd - s.best + 1 : -1, s.bestEnd);
}
/* ── readout ────────────────────────────────────────────────────────────── */
var IDLE = "Hover a square — or press an arrow key to walk the year.";
function say(i) {
$("readout").innerHTML = i < 0 ? IDLE
: "<b>" + fFull(i) + "</b> · shade " + levels[i] + " — " + SHADE[levels[i]] + (i === TODAY_I ? " · today" : "");
}
function announce(t) { $("live").textContent = t; }
/* ── painting: click, drag, tap ─────────────────────────────────────────── */
var brush = 2, painting = false, pend = -1, px = 0, py = 0, touched = 0;
/* true while the board is wider than its scroller: only then does a touch
drag have to be arbitrated between scrolling and painting. */
var overX = true;
function cellAt(x, y) {
var el = document.elementFromPoint(x, y);
if (!el || !el.classList || !el.classList.contains("cell") || el.classList.contains("future")) return -1;
return +el.dataset.i;
}
function stroke(i) {
if (i < 0) return;
if (paint(i, brush)) { touched++; if (pristine) setPristine(false); refresh(); }
say(i);
}
grid.addEventListener("pointerdown", function (e) {
if (e.button > 0) return;
var i = cellAt(e.clientX, e.clientY);
if (i < 0) return;
px = e.clientX; py = e.clientY; touched = 0;
focusTo(i, false);
try { grid.setPointerCapture(e.pointerId); } catch (err) {}
if (e.pointerType === "touch" && overX) { pend = i; painting = false; }
else { pend = -1; painting = true; e.preventDefault(); stroke(i); }
});
grid.addEventListener("pointermove", function (e) {
if (!painting && pend < 0) { return; }
if (!painting) {
/* on touch, a sideways drag belongs to the scroller; a downward one paints */
var dx = Math.abs(e.clientX - px), dy = Math.abs(e.clientY - py);
if (dy > 7 && dy > dx) { painting = true; stroke(pend); pend = -1; }
else return;
}
e.preventDefault();
stroke(cellAt(e.clientX, e.clientY));
});
function endStroke(cancelled) {
if (pend >= 0 && !cancelled) stroke(pend);
pend = -1;
if (touched) announce(touched + (touched === 1 ? " day" : " days") + " painted");
painting = false;
}
grid.addEventListener("pointerup", function () { endStroke(false); });
grid.addEventListener("pointercancel", function () { endStroke(true); });
grid.addEventListener("pointerover", function (e) {
if (painting) return;
var el = e.target;
if (el && el.classList && el.classList.contains("cell") && !el.classList.contains("future")) say(+el.dataset.i);
});
grid.addEventListener("pointerleave", function () { if (!painting) say(-1); });
/* ── keyboard ───────────────────────────────────────────────────────────── */
var focusI = TODAY_I;
cells[TODAY_I].tabIndex = 0;
function focusTo(i, move) {
if (i < 0 || i > TODAY_I) return;
cells[focusI].tabIndex = -1;
focusI = i;
cells[i].tabIndex = 0;
if (move) { cells[i].focus(); say(i); }
}
grid.addEventListener("focusin", function (e) {
var el = e.target;
if (el && el.classList && el.classList.contains("cell")) { focusTo(+el.dataset.i, false); say(+el.dataset.i); }
});
grid.addEventListener("focusout", function () { if (!painting) say(-1); });
grid.addEventListener("keydown", function (e) {
var i = focusI, d = i % ROWS, w = (i - d) / ROWS, k = e.key, ni = -1;
if (k === "ArrowLeft") ni = i - ROWS;
else if (k === "ArrowRight") ni = i + ROWS;
else if (k === "ArrowUp") { if (d === 0) return; ni = i - 1; }
else if (k === "ArrowDown") { if (d === ROWS - 1) return; ni = i + 1; }
else if (k === "Home") ni = d;
else if (k === "End") ni = ((WEEKS - 1) * ROWS + d) <= TODAY_I ? (WEEKS - 1) * ROWS + d : (WEEKS - 2) * ROWS + d;
else if (k.length === 1 && k >= "0" && k <= "4") {
setBrush(+k); stroke(i); announce(fShort(i) + " set to " + SHADE[+k]); e.preventDefault(); return;
} else if (k === "Enter" || k === " " || k === "Spacebar") {
stroke(i); announce(fShort(i) + " set to " + SHADE[brush]); e.preventDefault(); return;
} else return;
e.preventDefault();
if (ni < 0 || ni > TODAY_I) return;
focusTo(ni, true);
if (e.shiftKey) { stroke(ni); }
});
/* ── shade picker ───────────────────────────────────────────────────────── */
(function () {
var sws = $("sws"), f = document.createDocumentFragment();
for (var v = 0; v <= 4; v++) {
var l = document.createElement("label");
l.className = "sw";
var inp = document.createElement("input");
inp.type = "radio"; inp.name = "brush"; inp.value = v;
inp.setAttribute("aria-label", v + " — " + SHADE[v]);
if (v === brush) inp.checked = true;
var chip = document.createElement("span");
chip.className = "chip"; chip.dataset.l = v; chip.setAttribute("aria-hidden", "true");
l.appendChild(inp); l.appendChild(chip); f.appendChild(l);
}
sws.appendChild(f);
sws.addEventListener("change", function (e) {
if (e.target && e.target.name === "brush") { brush = +e.target.value; announce("shade " + brush + ", " + SHADE[brush]); }
});
})();
function setBrush(v) {
brush = v;
var r = $("sws").querySelectorAll("input");
if (r[v]) r[v].checked = true;
}
/* ── bulk moves ─────────────────────────────────────────────────────────── */
var reduced = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
var sweepT = 0;
function load(next, note) {
if (!(reduced && reduced.matches)) {
grid.classList.add("sweep");
clearTimeout(sweepT);
sweepT = setTimeout(function () { grid.classList.remove("sweep"); }, 420);
}
for (var i = 0; i <= TODAY_I; i++) paint(i, next ? next[i] : 0);
refresh();
announce(note);
}
var SEED = 20260413;
$("b-sample").addEventListener("click", function () { load(makeYear(SEED), "sample year restored"); setPristine(true); });
$("b-random").addEventListener("click", function () {
load(makeYear((Math.random() * 4294967295) >>> 0), "a different year rolled");
setPristine(false);
});
$("b-clear").addEventListener("click", function () { load(null, "board cleared"); setPristine(false); });
$("b-best").addEventListener("click", function () {
ringBest = !ringBest;
this.setAttribute("aria-pressed", ringBest ? "true" : "false");
refresh();
announce(ringBest ? "longest run ringed" : "ring off");
});
/* ── theme + layout ─────────────────────────────────────────────────────── */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
}
});
var rz = 0;
function fit() {
rz = 0;
overX = scroll.scrollWidth > scroll.clientWidth + 1;
/* when the whole year fits, a drag in any direction can paint */
grid.style.touchAction = overX ? "pan-x" : "none";
$("hint").hidden = !overX;
}
window.addEventListener("resize", function () { if (!rz) rz = requestAnimationFrame(fit); });
/* ── boot: already painted, already scrolled to now ─────────────────────── */
for (var i = 0; i <= TODAY_I; i++) cells[i].setAttribute("aria-label", label(i));
load(makeYear(SEED), "");
setPristine(true);
fit();
scroll.scrollLeft = scroll.scrollWidth;
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.