eleven / ocho Public

web: to-do — completed items linger with a countdown ring, then tuck away (#215)

* web: to-do — completed items linger with a countdown ring, then tuck away

Checking off a to-do now records doneAt (the send time) and the item lingers in
place, struck-through with a depleting ring, for DONE_GRACE_MS — tap to undo
(reopen) — then tucks into a collapsible "Completed (n)" section.

- apps.js: partitionByGrace(items, doneAtOf, now, graceMs) — pure, the whole
  "linger then archive" rule, so the reducer only records doneAt and the fold
  stays deterministic (no timer mutates state). DONE_GRACE_MS exported.
- todo.js: todo.done records doneAt (todo.reopen clears it); render partitions
  active + grace (in place, with the ring) vs archived (Completed section). A 1s
  ticker repaints ONLY the list containers (never the form) when a grace window
  expires, so typing isn't clobbered and the CSS ring doesn't restart each second.
  A legacy done item (no doneAt) reads as long-archived, so it tucks away rather
  than reappearing as active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — snappier grace (ease-in ring) + eased fold-up on tuck

Polish on the completed-items behaviour:
- Shorter grace (TODO_GRACE_MS = 2.5s, was the 6s framework default) so a ticked
  to-do clears sooner, and the ring now depletes ease-in (slow → quick) so the
  last moment feels snappy. Grace is passed explicitly to partitionByGrace +
  graceRing, so reminders keep the default.
- When the grace expires the row folds up — height/padding/margin ease to zero
  (overflow hidden), sliding the rows below smoothly upward — before the repaint,
  so the item tucks into Completed without the list jumping. Row spacing moved
  from the list's `gap` to per-item margin so it can fully collapse. Reduced
  motion skips the animation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — grace ✓ visible (accent, not white-on-grey) and centred

During the grace window the check circle is transparent (so the ring shows), but
the ✓ was still accent-ink (white) → invisible on the grey row. Colour it accent,
and absolutely centre the tick inside the circle so it sits dead-centre in the ring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — SVG-drawn centred ✓, 1.5s grace, ring starts immediately

- The grace ✓ is now a path drawn inside the ring SVG (not a text glyph), so it's
  geometrically centred and accent-coloured — fixes the off-centre, white-on-grey ✓.
- Grace shortened to 1.5s.
- Ring easing → cubic-bezier(0.3,0.1,0.7,0.6): it moves immediately when you tick
  (no flat ease-in dead-zone) and then accelerates into the tuck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: to-do — 750ms grace + tuck follows the ring immediately (precise timer)

- Grace halved again to 750ms.
- Replace the 1s poll that detected grace expiry (which left up to ~1s pause
  between the ring finishing and the fold-up) with a precise timer fired at the
  next grace item's expiry + TUCK_AFTER_MS (25ms), so the tuck follows the ring
  right away. Timer is cleared on re-render/thread reset via ctx.onCleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 36294c6aaeddeda4734401a374f8d6b7aa12b655 parent c2bf7d7
4 files changed, +314 −55
  • changelog/2026-07-05-todo-completed-section.md +1 −0
  • test/apps.test.mjs +28 −2
  • web/static/apps.js +24 −0
  • web/static/apps/todo.js +261 −53
diff --git a/changelog/2026-07-05-todo-completed-section.md b/changelog/2026-07-05-todo-completed-section.md
new file mode 100644
index 0000000..1cc9421
--- /dev/null
+++ b/changelog/2026-07-05-todo-completed-section.md
@@ -0,0 +1 @@
+Checked-off to-dos no longer clutter your list. When you complete one it lingers for a few seconds — struck through, with a little countdown ring — so you can tap to undo if it was a slip. Then it tucks itself away into a “Completed” section at the bottom, which you can expand any time to review or reopen past to-dos.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index e4b675e..7ba0df0 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -26,6 +26,8 @@ import {
addItem,
updateItem,
removeItem,
+ partitionByGrace,
+ DONE_GRACE_MS,
keyedList,
appWantsChatView,
noticeParts,
@@ -215,21 +217,45 @@ await test("updateItem patches one item into a fresh array; removeItem drops it"
await test("reduce adds, completes, reopens, and removes items", () => {
let s = reduce(undefined, { type: "todo.add", id: "a", text: "apples", name: "Jo" });
- assert.deepEqual(s.items, [{ id: "a", text: "apples", done: false, by: "Jo" }]);
+ assert.deepEqual(s.items, [{ id: "a", text: "apples", done: false, doneAt: 0, by: "Jo" }]);
s = reduce(s, { type: "todo.add", id: "b", text: "bread" });
assert.equal(s.items.length, 2);
- s = reduce(s, { type: "todo.done", id: "a" });
+ // done records WHEN (the event's send time) so the grace→archive split converges.
+ s = reduce(s, { type: "todo.done", id: "a", ts: 1700 });
assert.equal(s.items.find((i) => i.id === "a").done, true);
+ assert.equal(s.items.find((i) => i.id === "a").doneAt, 1700);
s = reduce(s, { type: "todo.reopen", id: "a" });
assert.equal(s.items.find((i) => i.id === "a").done, false);
+ assert.equal(s.items.find((i) => i.id === "a").doneAt, 0, "reopen clears doneAt");
s = reduce(s, { type: "todo.remove", id: "a" });
assert.deepEqual(s.items.map((i) => i.id), ["b"]);
});
+await test("partitionByGrace: active / recently-done (grace) / archived by doneAt", () => {
+ const now = 100000;
+ const items = [
+ { id: "a", doneAt: 0 }, // active (not done)
+ { id: "b", doneAt: now - 1000 }, // done 1s ago → grace (< DONE_GRACE_MS)
+ { id: "c", doneAt: now - (DONE_GRACE_MS + 5000) }, // done long ago → archived
+ { id: "d", doneAt: now - (DONE_GRACE_MS - 1) }, // just inside the window → grace
+ { id: "e", doneAt: now - DONE_GRACE_MS }, // exactly at the edge → archived
+ ];
+ const { active, grace, archived } = partitionByGrace(items, (i) => i.doneAt, now);
+ assert.deepEqual(active.map((i) => i.id), ["a"]);
+ assert.deepEqual(grace.map((i) => i.id), ["b", "d"]);
+ assert.deepEqual(archived.map((i) => i.id), ["c", "e"]);
+ // A custom grace window is honoured.
+ assert.deepEqual(
+ partitionByGrace(items, (i) => i.doneAt, now, 500).grace.map((i) => i.id),
+ [],
+ "nothing is within a 500ms window",
+ );
+});
+
await test("reduce ignores a malformed add (missing text) and unknown events", () => {
let s = reduce(undefined, { type: "todo.add", id: "a" }); // no text
assert.deepEqual(s.items, []);
diff --git a/web/static/apps.js b/web/static/apps.js
index 5a9e853..175c017 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -163,6 +163,30 @@ export function removeItem(list, id) {
return list.filter((i) => i.id !== id);
}
+// How long a just-completed item lingers in place (struck-through, with a
+// countdown ring, still undoable) before it tucks into the "completed" section.
+export const DONE_GRACE_MS = 6000;
+
+// partitionByGrace splits items into { active, grace, archived } by a completion
+// timestamp (`doneAtOf(item)` — falsy = not done). A done item is in `grace`
+// while it's younger than graceMs (shown in place, undoable), then `archived`
+// (tucked away). This is the whole "linger then drop off" rule, kept PURE and in
+// the RENDER — the reducer only records doneAt, so the fold stays deterministic
+// (a timer never mutates state); the UI derives the buckets from the clock and
+// re-derives on a tick. Order within each bucket is the input order (callers sort).
+export function partitionByGrace(items, doneAtOf, now, graceMs = DONE_GRACE_MS) {
+ const active = [];
+ const grace = [];
+ const archived = [];
+ for (const it of items || []) {
+ const d = doneAtOf(it) || 0;
+ if (!d) active.push(it);
+ else if (now - d < graceMs) grace.push(it);
+ else archived.push(it);
+ }
+ return { active, grace, archived };
+}
+
// ---- keyedList: incremental DOM reconciliation for list surfaces ---------
// Renders `items` into `el`, reusing existing DOM nodes by key: unchanged items
// keep their exact nodes (scroll position, focus, loaded images survive), new
diff --git a/web/static/apps/todo.js b/web/static/apps/todo.js
index a4470be..df81d61 100644
--- a/web/static/apps/todo.js
+++ b/web/static/apps/todo.js
@@ -6,14 +6,23 @@
//
// Relative import (not "/static/apps.js") so the reducer is exercisable in Node
// tests while still resolving to /static/apps.js in the browser.
-import { defineApp, addItem, updateItem, removeItem } from "../apps.js";
-
-// A to-do is { id, text, done, by }. The events map below IS the model: its keys
-// are the event types this app owns, its values fold each event into new state.
-// The collection helpers keep the fold idempotent (addItem dedupes by id) and
-// snapshot-safe (every op returns a new array) — so folding the same window twice
-// on reopen never duplicates, and two devices converge. `by` is the sender's
-// screen name, injected by the transport as `e.name`.
+import { defineApp, addItem, updateItem, removeItem, partitionByGrace } from "../apps.js";
+
+// How long a checked-off to-do lingers (struck through, with its countdown ring)
+// before it tucks into Completed — short, since ticking a to-do is usually
+// deliberate. The ring depletes on a curve that moves immediately (so it visibly
+// starts the instant you tick) and then accelerates into the tuck.
+const TODO_GRACE_MS = 750;
+const RING_EASE = "cubic-bezier(0.3, 0.1, 0.7, 0.6)"; // immediate start, then speeds up
+const TUCK_AFTER_MS = 25; // beat between the ring finishing and the fold-up starting
+
+// A to-do is { id, text, done, doneAt, by }. The events map below IS the model:
+// its keys are the event types this app owns, its values fold each event into new
+// state. The collection helpers keep the fold idempotent (addItem dedupes by id)
+// and snapshot-safe (every op returns a new array). `doneAt` is the send time of
+// the check-off (e.ts) — it drives the "linger then tuck away" behaviour, but note
+// the REDUCER only records it: the grace-period / archive split is derived in the
+// render from the clock (see partitionByGrace), never by a timer mutating state.
export const app = defineApp({
name: "todo",
title: "To-do",
@@ -21,9 +30,11 @@ export const app = defineApp({
scope: "personal",
initialState: () => ({ items: [] }),
events: {
- "todo.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, done: false, by: e.name || "" }) } : s),
- "todo.done": (s, e) => ({ items: updateItem(s.items, e.id, { done: true }) }),
- "todo.reopen": (s, e) => ({ items: updateItem(s.items, e.id, { done: false }) }),
+ "todo.add": (s, e) => (e.text ? { items: addItem(s.items, { id: e.id, text: e.text, done: false, doneAt: 0, by: e.name || "" }) } : s),
+ // Record WHEN it was checked off (the sender's send time) so every device folds
+ // the same doneAt and the grace→archive transition converges.
+ "todo.done": (s, e) => ({ items: updateItem(s.items, e.id, { done: true, doneAt: e.ts || 0 }) }),
+ "todo.reopen": (s, e) => ({ items: updateItem(s.items, e.id, { done: false, doneAt: 0 }) }),
"todo.remove": (s, e) => ({ items: removeItem(s.items, e.id) }),
},
commands: {
@@ -62,17 +73,34 @@ export const app = defineApp({
border: 1px solid var(--border); background: var(--panel); color: var(--ink);
}
.todo-add-btn { flex: 0 0 auto; }
-.todo-count { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; }
-.todo-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
-.todo-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; margin: 0 -8px; border-radius: 8px; cursor: var(--ui-cursor, pointer); }
+.todo-count { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; margin-top: 8px; }
+.todo-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
+/* Row spacing lives in the item's own margin (not a list gap) so a tucking row can
+ animate its height, padding AND margin to zero and fully collapse — no residual
+ gap for the rows below to jump over. */
+.todo-item { display: flex; align-items: center; gap: 10px; padding: 6px 8px; margin: 0 -8px 4px; border-radius: 8px; cursor: var(--ui-cursor, pointer); }
.todo-item:hover { background: var(--bubble-them); }
.todo-check {
- flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%;
+ position: relative; flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%;
border: 1.5px solid var(--border); background: transparent; color: var(--accent-ink);
font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center;
cursor: var(--ui-cursor, pointer); padding: 0;
}
.todo-item.done .todo-check { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
+/* During the grace period the circle carries a depleting ring around a ✓; both are
+ drawn INSIDE one SVG (geometry, not a text glyph) so the tick is dead-centre and
+ accent-coloured — no white-on-grey, no font-metric drift. Drop the fill/border so
+ only the ring + tick show. */
+.todo-item.grace .todo-check { background: transparent; border-color: transparent; }
+.todo-ring { position: absolute; inset: -1.5px; width: 23px; height: 23px; }
+.todo-ring-track { fill: none; stroke: var(--border); stroke-width: 2; }
+.todo-ring-fill {
+ fill: none; stroke: var(--accent); stroke-width: 2; stroke-linecap: round;
+ stroke-dasharray: 62.83; stroke-dashoffset: 0;
+ transform: rotate(-90deg); transform-origin: 50% 50%;
+}
+.todo-ring-check { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
+@keyframes todo-ring-deplete { to { stroke-dashoffset: 62.83; } }
.todo-text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.todo-item.done .todo-text { text-decoration: line-through; color: var(--muted); }
.todo-remove {
@@ -82,11 +110,20 @@ export const app = defineApp({
}
.todo-item:hover .todo-remove, .todo-remove:focus-visible { opacity: 1; }
@media (pointer: coarse) { .todo-remove { opacity: 1; } }
+.todo-completed { margin-top: 12px; }
+.todo-completed-toggle {
+ background: none; border: none; padding: 6px 2px; font: inherit; font-size: 13px; font-weight: 600;
+ color: var(--muted); cursor: var(--ui-cursor, pointer);
+}
+.todo-completed-toggle:hover { color: var(--ink); }
+.todo-list--done { margin-top: 2px; }
`,
- // Full-pane render: an add row on top, a count line, then the checklist.
+ // Full-pane render: add row, a count line, the live checklist (active items plus
+ // ones inside their grace window), then a collapsible "Completed" section.
render(state, el, ctx) {
const items = (state && state.items) || [];
+ const tid = ctx.threadId;
const form = document.createElement("form");
form.className = "todo-add";
@@ -109,49 +146,220 @@ export const app = defineApp({
};
el.appendChild(form);
- const open = items.filter((i) => !i.done).length;
const count = document.createElement("div");
count.className = "todo-count";
- count.textContent = items.length ? (open ? `${open} left` : "all done 🎉") : "No to-dos yet — add one above";
el.appendChild(count);
const list = document.createElement("ul");
list.className = "todo-list";
- // Newest on top: the fold appends new items, so render the list reversed.
- for (const item of [...items].reverse()) {
- const li = document.createElement("li");
- li.className = "todo-item" + (item.done ? " done" : "");
- // Tapping anywhere on the row checks it off (or reopens it) — the whole item
- // is the target, not just the little circle. Remove (×) stops the click from
- // also toggling (see its handler below).
- li.onclick = () => ctx.emit({ type: item.done ? "todo.reopen" : "todo.done", id: item.id });
-
- const check = document.createElement("button");
- check.type = "button";
- check.className = "todo-check";
- check.setAttribute("aria-label", item.done ? "Reopen" : "Mark done");
- check.textContent = item.done ? "✓" : "";
- // No own handler: the click bubbles to the row's toggle. Keeping it a real
- // <button> means it stays keyboard-focusable and labelled, so Space/Enter
- // on the circle checks the item off too.
-
- const text = document.createElement("span");
- text.className = "todo-text";
- text.textContent = item.text; // textContent — never innerHTML for user content
-
- const rm = document.createElement("button");
- rm.type = "button";
- rm.className = "todo-remove";
- rm.setAttribute("aria-label", "Remove");
- rm.textContent = "×";
- rm.onclick = (e) => {
- e.stopPropagation(); // remove the item — don't also toggle the row
- ctx.emit({ type: "todo.remove", id: item.id });
- };
-
- li.append(check, text, rm);
- list.appendChild(li);
- }
el.appendChild(list);
+
+ const completed = document.createElement("div");
+ completed.className = "todo-completed";
+ el.appendChild(completed);
+
+ // paint() rebuilds ONLY the count + list + completed containers (never the
+ // form), so the ticker can repaint on a grace→archive transition without
+ // clobbering what you're typing. A done item with no doneAt (from before this
+ // feature) reads as long-archived (doneAt = 1), so old completed items tuck
+ // straight into the Completed section rather than reappearing as active.
+ const doneAt = (i) => (i.done ? i.doneAt || 1 : 0);
+ let archivedKey = null;
+ const paint = () => {
+ const now = Date.now();
+ const { active, archived } = partitionByGrace(items, doneAt, now, TODO_GRACE_MS);
+
+ count.textContent = items.length ? (active.length ? `${active.length} left` : "all done 🎉") : "No to-dos yet — add one above";
+
+ // Visible list = everything not yet archived, newest-first, IN PLACE (a
+ // just-checked item stays where it sits, struck-through with its ring).
+ const archivedIds = new Set(archived.map((i) => i.id));
+ list.replaceChildren();
+ for (const item of [...items].reverse()) {
+ if (!archivedIds.has(item.id)) list.appendChild(buildRow(item, now, ctx));
+ }
+
+ completed.replaceChildren();
+ if (archived.length) {
+ const open = !!completedOpen[tid];
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "todo-completed-toggle";
+ toggle.textContent = `${open ? "▾" : "▸"} Completed (${archived.length})`;
+ toggle.onclick = () => {
+ completedOpen[tid] = !completedOpen[tid];
+ paint();
+ };
+ completed.appendChild(toggle);
+ if (open) {
+ const ul = document.createElement("ul");
+ ul.className = "todo-list todo-list--done";
+ for (const item of [...archived].sort((a, b) => (b.doneAt || 0) - (a.doneAt || 0))) {
+ ul.appendChild(buildRow(item, now, ctx));
+ }
+ completed.appendChild(ul);
+ }
+ }
+ archivedKey = [...archivedIds].sort().join(",");
+ };
+
+ paint();
+
+ // When a grace window expires the row folds up — height/padding/margin ease to
+ // zero, sliding the rows below smoothly up — and only THEN do we repaint, so the
+ // item tucks into Completed without the list jumping. We fire on a PRECISE timer
+ // (the next grace item's expiry + a tiny beat), not a poll, so the fold follows
+ // the ring immediately instead of lagging up to a second behind it.
+ let folding = false;
+ let tuckTimer = null;
+ const scheduleTuck = () => {
+ if (tuckTimer) clearTimeout(tuckTimer);
+ tuckTimer = null;
+ const grace = partitionByGrace(items, doneAt, Date.now(), TODO_GRACE_MS).grace;
+ if (!grace.length) return; // nothing lingering — nothing to tuck
+ const nextExpiry = Math.min(...grace.map((i) => (i.doneAt || 0) + TODO_GRACE_MS));
+ tuckTimer = setTimeout(tuckExpired, Math.max(0, nextExpiry - Date.now()) + TUCK_AFTER_MS);
+ };
+ const tuckExpired = () => {
+ tuckTimer = null;
+ if (folding) return; // a fold in flight will re-schedule when it finishes
+ const now = Date.now();
+ const archived = partitionByGrace(items, doneAt, now, TODO_GRACE_MS).archived;
+ if (archived.map((i) => i.id).sort().join(",") === archivedKey) {
+ scheduleTuck();
+ return;
+ }
+ const nowArchived = new Set(archived.map((i) => i.id));
+ const rows = [...list.querySelectorAll(".todo-item")].filter((li) => nowArchived.has(li.dataset.id));
+ if (!rows.length || reducedMotion()) {
+ paint();
+ scheduleTuck();
+ return;
+ }
+ folding = true;
+ let remaining = rows.length;
+ const finish = () => {
+ if (--remaining > 0) return;
+ folding = false;
+ paint();
+ scheduleTuck(); // line up the next grace item's tuck
+ };
+ rows.forEach((li) => foldUpRow(li, finish));
+ };
+ scheduleTuck();
+ ctx.onCleanup(() => {
+ if (tuckTimer) clearTimeout(tuckTimer);
+ });
},
});
+
+// The completed section's expand state, per app-thread (module scope so it
+// survives repaints and re-renders — it's transient UI, not fold state).
+const completedOpen = {};
+
+// buildRow renders one to-do row. Tapping anywhere on the row toggles done/reopen
+// (so a tap during the grace period is the "undo"); the × removes it. A row inside
+// its grace window shows a depleting ring around the ✓ — a visible countdown until
+// it tucks into Completed.
+function buildRow(item, now, ctx) {
+ const grace = !!(item.done && item.doneAt && now - item.doneAt < TODO_GRACE_MS);
+ const li = document.createElement("li");
+ li.className = "todo-item" + (item.done ? " done" : "") + (grace ? " grace" : "");
+ li.dataset.id = item.id; // so the ticker can find the row to fold up on expiry
+ if (grace) li.title = "Tap to undo";
+ li.onclick = () => ctx.emit({ type: item.done ? "todo.reopen" : "todo.done", id: item.id });
+
+ const check = document.createElement("button");
+ check.type = "button";
+ check.className = "todo-check";
+ check.setAttribute("aria-label", item.done ? "Reopen" : "Mark done");
+ // No own handler: the click bubbles to the row's toggle. Keeping it a real
+ // <button> means it stays keyboard-focusable and labelled (Space/Enter works).
+ if (grace) {
+ check.appendChild(graceRing(now - item.doneAt)); // ring + centred ✓, one SVG
+ } else {
+ check.textContent = item.done ? "✓" : "";
+ }
+
+ const text = document.createElement("span");
+ text.className = "todo-text";
+ text.textContent = item.text; // textContent — never innerHTML for user content
+
+ const rm = document.createElement("button");
+ rm.type = "button";
+ rm.className = "todo-remove";
+ rm.setAttribute("aria-label", "Remove");
+ rm.textContent = "×";
+ rm.onclick = (e) => {
+ e.stopPropagation(); // remove the item — don't also toggle the row
+ ctx.emit({ type: "todo.remove", id: item.id });
+ };
+
+ li.append(check, text, rm);
+ return li;
+}
+
+// An SVG holding the depleting ring AND a centred ✓ check mark (drawn geometry, so
+// the tick is exactly centred — no font-metric drift). The ring drains over the
+// grace window on RING_EASE (moves immediately, then speeds up); animation-delay is
+// the negative elapsed time so it picks up mid-countdown on a repaint (or reopen),
+// and the duration is TODO_GRACE_MS so the visual and partitionByGrace stay locked.
+function graceRing(elapsed) {
+ const NS = "http://www.w3.org/2000/svg";
+ const svg = document.createElementNS(NS, "svg");
+ svg.setAttribute("class", "todo-ring");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("aria-hidden", "true");
+ const track = document.createElementNS(NS, "circle");
+ track.setAttribute("class", "todo-ring-track");
+ track.setAttribute("cx", "12");
+ track.setAttribute("cy", "12");
+ track.setAttribute("r", "10");
+ const fill = document.createElementNS(NS, "circle");
+ fill.setAttribute("class", "todo-ring-fill");
+ fill.setAttribute("cx", "12");
+ fill.setAttribute("cy", "12");
+ fill.setAttribute("r", "10");
+ fill.style.animation = `todo-ring-deplete ${TODO_GRACE_MS}ms ${RING_EASE} forwards`;
+ fill.style.animationDelay = `-${Math.max(0, Math.round(elapsed))}ms`;
+ const check = document.createElementNS(NS, "path");
+ check.setAttribute("class", "todo-ring-check");
+ check.setAttribute("d", "M7.5 12.5 L10.5 15.5 L16.5 8.5"); // ✓ centred on (12,12)
+ svg.append(track, fill, check);
+ return svg;
+}
+
+// Whether the OS asks us to minimise motion — the fold-up is skipped if so.
+function reducedMotion() {
+ try {
+ return typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;
+ } catch {
+ return false;
+ }
+}
+
+// foldUpRow collapses a row to nothing with an ease-in curve — height, padding and
+// margin all animate to 0 (overflow hidden), so the row folds away and the rows
+// below slide smoothly up. Calls done() when the height transition finishes (with a
+// timeout backstop). The row is discarded by the repaint that follows.
+function foldUpRow(li, done) {
+ const h = li.offsetHeight;
+ li.style.overflow = "hidden";
+ li.style.height = h + "px";
+ void li.offsetHeight; // reflow so the start height sticks before we transition
+ li.style.transition = "height 0.26s ease-in, opacity 0.2s ease-in, margin 0.26s ease-in, padding 0.26s ease-in";
+ li.style.height = "0px";
+ li.style.opacity = "0";
+ li.style.marginTop = "0";
+ li.style.marginBottom = "0";
+ li.style.paddingTop = "0";
+ li.style.paddingBottom = "0";
+ let called = false;
+ const fin = () => {
+ if (called) return;
+ called = true;
+ done();
+ };
+ li.addEventListener("transitionend", (e) => { if (e.propertyName === "height") fin(); }, { once: true });
+ setTimeout(fin, 320);
+}