Skip to main content

max / makenotwork

Read every frontend duration from makeover-timing A duration on this site is now the crate's number rather than a literal at a call site. `core/timing.ts` reads the custom properties `build.rs` already generates into `static/timing.css`, the route `quasi-clock.js` takes, and carries the two helpers that spend them: a keyed debounce and a status line that clears itself. The legacy `static/*.js` files reach both through one `window.timing` namespace on the bridge, paid for by inlining `window.copyEmbed` into its only caller, so the globals ratchet is unmoved. The folds: - The seven copy buttons drop their `resetMs` argument; `copyWithFeedback` defaults to `Intent::Revert`. - The HTMX error toast is built by `toast.ts` rather than by hand in `htmx-glue.ts`. One toast implementation, one dismissal, one stack cap. - Eight hand-rolled debounce closures collapse onto `timing.debounce`, keyed so no caller keeps a timer variable. - Six self-clearing status lines collapse onto `timing.clearStatusLater`. - The toast animation in `style.css` reads `--motion-fade` instead of restating 0.3s beside a script that waits the token. Behaviour changes. Each is a place MNW disagreed with itself, and the crate rules one duration per intent: - The four category and tag typeaheads waited 200ms against the docs search's 150. All four are `Intent::Debounce` at 150 now. - The cart amount waited 300ms before its PUT. `Intent::Debounce`, 150. - Copy Feed URL flashed for 2000ms where every other copy button used 1500. `Intent::Revert`, 1500. - The HTMX success flash reverted its label after 1200ms, and the Export All button after 3000. `Intent::Revert`, 1500. - The HTMX error toast lived 6000ms against an ordinary toast's 3000, and was the one toast that ignored the five-visible cap. `Intent::Dismiss`, 3000, capped. - The two autosave "Auto-saved" lines cleared after 3000ms. `Intent::Clear`, 2000. Left alone, because none of them is a timing intent: the three dropdown blur-close races at 150ms, the export button's 1500ms click pacing, the import poller and the restart banner's poll cadence, and the 30-second autosave delays, which are a save cadence the crate names no intent for and which now pass their number to the shared helper explicitly.
Author: Max Johnson <me@maxj.phd> · 2026-08-29 19:51 UTC
Signed with PGP, not checked
Commit: 2c8cf51c26e07d8b522541b840443f26bde368a3
Parent: 5efe747
21 files changed, +342 insertions, -121 deletions
@@ -6,10 +6,10 @@
6 6 // are preserved verbatim from the original inline handlers.
7 7
8 8 // Copy the item id to the clipboard and flash feedback on the button. The
9 - // revert is core/clipboard.ts's, through the legacy bridge; 1500 is the
10 - // duration this button already used.
9 + // revert is core/clipboard.ts's, through the legacy bridge, and its duration is
10 + // `Intent::Revert`.
11 11 window.onCopyItemId = function (id) {
12 - window.copyWithFeedback(this, id, 'Copied!', 1500);
12 + window.copyWithFeedback(this, id, 'Copied!');
13 13 };
14 14
15 15 // Custom page reset form: confirm before submitting. data-submit auto-prevents
@@ -20,7 +20,7 @@
20 20
21 21 // --- item_embed.html ---
22 22 window.copyEmbedBtn = function () {
23 - copyEmbed(this);
23 + window.copyWithFeedback(this, this.previousElementSibling.textContent, 'Copied!');
24 24 };
25 25
26 26 // --- project_settings.html ---
@@ -92,7 +92,7 @@
92 92 };
93 93
94 94 window.copyKeyCode = function (code) {
95 - window.copyWithFeedback(this, code, 'Copied!', 1500);
95 + window.copyWithFeedback(this, code, 'Copied!');
96 96 };
97 97
98 98 window.toggleContextMenuBtn = function (itemId) {
@@ -109,14 +109,14 @@
109 109 // verbs (onMoveLink/onEditLink/onSaveLink/onCancelLink, in actions-partials.js)
110 110 // serves both the loop and the HTMX-inserted fragment.
111 111 window.copyElementText = function (id) {
112 - window.copyWithFeedback(this, document.getElementById(id).textContent, 'Copied', 1500);
112 + window.copyWithFeedback(this, document.getElementById(id).textContent, 'Copied');
113 113 };
114 114
115 - // 2000ms here rather than the 1500 every other copy button uses. Preserved as
116 - // found: which of the two is right is a question for the semantic-timing work,
117 - // not for a consolidation pass.
115 + // This one flashed for 2000ms where every other copy button used 1500. The
116 + // semantic-timing work ruled one duration per intent, so it reverts on
117 + // `Intent::Revert` with the rest of them now.
118 118 window.copyFeedUrl = function () {
119 - window.copyWithFeedback(this, document.getElementById('feed-url').value, 'Copied!', 2000);
119 + window.copyWithFeedback(this, document.getElementById('feed-url').value, 'Copied!');
120 120 };
121 121
122 122 // --- user_media.html ---
@@ -12,7 +12,6 @@
12 12 var projectId = editor.dataset.projectId;
13 13 var projectSlug = editor.dataset.projectSlug;
14 14 var editingPostId = editor.dataset.postId || null;
15 - var blogAutoSaveTimer = null;
16 15 var postStatus = document.getElementById('post-status');
17 16
18 17 function getFields() {
@@ -97,8 +96,10 @@
97 96 if (editingPostId) {
98 97 ['post-title', 'post-slug', 'post-body'].forEach(function(id) {
99 98 document.getElementById(id).addEventListener('input', function() {
100 - clearTimeout(blogAutoSaveTimer);
101 - blogAutoSaveTimer = setTimeout(function() {
99 + // Thirty seconds is a save cadence rather than a wait for the
100 + // typing to stop, and `makeover-timing` names no such intent, so
101 + // the number stays here. The closure that spent it does not.
102 + window.timing.debounce('blog-autosave', function() {
102 103 var f = getFields();
103 104 if (!f.title || !f.slug) return;
104 105 postStatus.innerHTML = '<span style="opacity: 0.5;">Saving...</span>';
@@ -113,9 +114,7 @@
113 114 .then(function(res) {
114 115 if (!res.ok) return apiErrorMessage(res, 'Auto-save failed').then(function(m) { throw new Error(m); });
115 116 postStatus.innerHTML = '<span style="color: var(--text-muted);">Auto-saved</span>';
116 - setTimeout(function() {
117 - if (postStatus.textContent === 'Auto-saved') postStatus.innerHTML = '';
118 - }, 3000);
117 + window.timing.clearStatusLater(postStatus, 'Auto-saved');
119 118 })
120 119 .catch(function(err) {
121 120 var span = document.createElement('span');
@@ -10,11 +10,15 @@
10 10 function next() {
11 11 if (i >= buttons.length) {
12 12 btn.textContent = 'Done';
13 - setTimeout(function() { btn.textContent = 'Export All'; btn.disabled = false; }, 3000);
13 + // A temporary label going back to the real one: `Intent::Revert`,
14 + // the same duration a copy button flashes for.
15 + setTimeout(function() { btn.textContent = 'Export All'; btn.disabled = false; }, window.timing.revertMs());
14 16 return;
15 17 }
16 18 buttons[i].click();
17 19 i++;
20 + // Not a timing intent: this paces one click after another so the
21 + // browser is not handed a dozen downloads at once.
18 22 setTimeout(next, 1500);
19 23 }
20 24 next();
@@ -6,15 +6,12 @@
6 6 var results = document.querySelector(".docs-search-results");
7 7 if (!input || !results) return;
8 8
9 - var timer = null;
10 -
11 9 fetch("/docs/search.json")
12 10 .then(function (r) { return r.json(); })
13 11 .then(function (data) { index = data; });
14 12
15 13 input.addEventListener("input", function () {
16 - clearTimeout(timer);
17 - timer = setTimeout(doSearch, 150);
14 + window.timing.debounce("docs-search", doSearch);
18 15 });
19 16
20 17 function doSearch() {
@@ -288,13 +288,12 @@
288 288 var input = document.getElementById('item-tags');
289 289 if (!input) return;
290 290
291 - var tagSearchTimeout;
292 -
293 291 window.searchTags = function(q) {
294 - clearTimeout(tagSearchTimeout);
295 292 var suggestions = document.getElementById('tag-suggestions');
296 - if (!q.trim()) { suggestions.style.display = 'none'; return; }
297 - tagSearchTimeout = setTimeout(function() {
293 + if (!q.trim()) { window.timing.cancelDebounce('tag-search'); suggestions.style.display = 'none'; return; }
294 + // Was 200ms hand-rolled here, against the 150 the docs search used.
295 + // One duration per intent: this is `Intent::Debounce`.
296 + window.timing.debounce('tag-search', function() {
298 297 fetch('/api/tags/search?q=' + encodeURIComponent(q))
299 298 .then(function(r) { return r.json(); })
300 299 .then(function(tags) {
@@ -312,7 +311,7 @@
312 311 suggestions.style.display = 'block';
313 312 })
314 313 .catch(function() { suggestions.style.display = 'none'; });
315 - }, 200);
314 + });
316 315 };
317 316
318 317 function addTagById(tagId) {
@@ -399,7 +398,7 @@
399 398 status.textContent = 'Saved.';
400 399 var preview = document.getElementById('item-image-preview');
401 400 preview.innerHTML = '<img src="' + data.image_url + '" alt="Item image" style="width:100%;height:100%;object-fit:cover;">';
402 - setTimeout(function() { status.textContent = ''; }, 2000);
401 + window.timing.clearStatusLater(status);
403 402 })
404 403 .catch(function(err) { status.textContent = err.message; });
405 404 });
@@ -1,12 +1,13 @@
1 1 (function() {
2 2 document.querySelectorAll('.pwyw-cart-input').forEach(function(input) {
3 - var debounce;
4 3 input.addEventListener('change', function() {
5 - clearTimeout(debounce);
6 4 var itemId = this.dataset.itemId;
7 5 var dollars = parseFloat(this.value) || 0;
8 6 var cents = Math.round(dollars * 100);
9 - debounce = setTimeout(function() {
7 + // One key per line, so two amounts changed in quick succession are
8 + // two writes rather than one overwriting the other. The wait was
9 + // 300ms hand-rolled here; it is `Intent::Debounce` now.
10 + window.timing.debounce('cart-amount-' + itemId, function() {
10 11 fetch('/api/cart/' + itemId, {
11 12 method: 'PUT',
12 13 headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
@@ -14,7 +15,7 @@
14 15 }).then(function(r) {
15 16 if (!r.ok) return r.json().then(function(d) { showToast(d.error || 'Invalid amount'); });
16 17 }).catch(function() { showToast('Failed to update amount'); });
17 - }, 300);
18 + });
18 19 });
19 20 });
20 21 })();
@@ -49,12 +49,13 @@
49 49 document.getElementById('text-body').addEventListener('input', updateWordCount);
50 50
51 51 // Auto-save: debounce text body changes (30s after last keystroke)
52 - var autoSaveTimer = null;
53 52 var autoSaveStatus = document.getElementById('text-save-status');
54 53
55 54 document.getElementById('text-body').addEventListener('input', function() {
56 - clearTimeout(autoSaveTimer);
57 - autoSaveTimer = setTimeout(function() {
55 + // Thirty seconds is a save cadence rather than a wait for the typing to
56 + // stop, and `makeover-timing` names no such intent, so the number stays
57 + // here. The closure that spent it does not.
58 + window.timing.debounce('text-autosave', function() {
58 59 autoSaveStatus.innerHTML = '<span class="save-status saving">Saving...</span>';
59 60 var body = document.getElementById('text-body').value;
60 61 fetch('/api/items/' + ITEM_ID + '/text', {
@@ -68,9 +69,7 @@
68 69 if (data.word_count !== undefined) {
69 70 document.getElementById('word-count').textContent = data.word_count + ' words';
70 71 }
71 - setTimeout(function() {
72 - if (autoSaveStatus.textContent === 'Auto-saved') autoSaveStatus.innerHTML = '';
73 - }, 3000);
72 + window.timing.clearStatusLater(autoSaveStatus, 'Auto-saved');
74 73 })
75 74 .catch(function() {
76 75 autoSaveStatus.innerHTML = '<span class="save-status error">Auto-save failed</span>';
@@ -5088,7 +5088,7 @@
5088 5088 font-family: var(--font-mono);
5089 5089 font-size: var(--text-note);
5090 5090 box-shadow: var(--elevation-overlay);
5091 - animation: toast-in 0.3s ease-out;
5091 + animation: toast-in var(--motion-fade) ease-out;
5092 5092 max-width: 300px;
5093 5093 display: flex;
5094 5094 align-items: center;
@@ -5144,7 +5144,10 @@
5144 5144 }
5145 5145
5146 5146 .toast.fade-out {
5147 - animation: toast-out 0.3s ease-in forwards;
5147 + /* `Motion::Fade`, the same number the script waits before removing the node.
5148 + A literal here and a token there is the two parting silently, and reduced
5149 + motion zeroes the token. */
5150 + animation: toast-out var(--motion-fade) ease-in forwards;
5148 5151 }
5149 5152
5150 5153 @keyframes toast-in {
@@ -42,13 +42,13 @@
42 42 btn.className = className;
43 43 btn.textContent = 'Copy';
44 44 btn.addEventListener('click', function () {
45 - window.copyWithFeedback(btn, text, 'Copied', 1500);
45 + window.copyWithFeedback(btn, text, 'Copied');
46 46 });
47 47 return btn;
48 48 }
49 49
50 50 window.syncKitCopyKey = function (event, key) {
51 - window.copyWithFeedback(event.target, key, 'Copied', 1500);
51 + window.copyWithFeedback(event.target, key, 'Copied');
52 52 };
53 53
54 54 window.syncKitRegenKey = function (appId) {
@@ -4,10 +4,6 @@
4 4 var itemId = cfg.dataset.itemId;
5 5 var itemTitle = cfg.dataset.itemTitle;
6 6
7 - window.copyEmbed = function(btn) {
8 - window.copyWithFeedback(btn, btn.previousElementSibling.textContent, 'Copied!', 1500);
9 - };
10 -
11 7 window.switchCardLayout = function(layout) {
12 8 const iframe = document.getElementById('card-preview');
13 9 const code = document.getElementById('card-code');
@@ -38,7 +38,7 @@
38 38 status.textContent = 'Saved.';
39 39 var preview = document.getElementById('project-image-preview');
40 40 preview.innerHTML = '<img src="' + data.image_url + '" alt="Project image">';
41 - setTimeout(function() { status.textContent = ''; }, 2000);
41 + window.timing.clearStatusLater(status);
42 42 })
43 43 .catch(function(err) { status.textContent = err.message; });
44 44 });
@@ -71,7 +71,12 @@
71 71 var input = document.getElementById('settings-category');
72 72 var dropdown = document.getElementById('settings-category-dropdown');
73 73 if (!input || !dropdown) return;
74 - var debounce;
74 +
75 + // The wait between keystrokes and the search is `Intent::Debounce`, spent
76 + // through the shared helper on the core module. It used to be a 200ms
77 + // timeout hand-rolled here, which was this typeahead disagreeing with the
78 + // docs search for no reason anyone recorded.
79 + var SEARCH = 'category-search';
75 80
76 81 function showDropdown(items, query) {
77 82 dropdown.innerHTML = '';
@@ -106,15 +111,14 @@
106 111 }
107 112
108 113 input.addEventListener('input', function() {
109 - clearTimeout(debounce);
110 114 var q = input.value.trim();
111 - if (q.length < 1) { dropdown.classList.remove('open'); return; }
112 - debounce = setTimeout(function() {
115 + if (q.length < 1) { window.timing.cancelDebounce(SEARCH); dropdown.classList.remove('open'); return; }
116 + window.timing.debounce(SEARCH, function() {
113 117 fetch('/api/categories/search?q=' + encodeURIComponent(q))
114 118 .then(function(r) { return r.json(); })
115 119 .then(function(cats) { showDropdown(cats, q); })
116 120 .catch(function() {});
117 - }, 200);
121 + });
118 122 });
119 123
120 124 input.addEventListener('focus', function() {
@@ -122,6 +126,9 @@
122 126 });
123 127
124 128 input.addEventListener('blur', function() {
129 + // Not a timing intent: the dropdown has to outlive the blur long enough
130 + // for a mousedown on one of its rows to land. A race, so it keeps its
131 + // own number.
125 132 setTimeout(function() { dropdown.classList.remove('open'); }, 150);
126 133 });
127 134 })();
@@ -142,7 +149,7 @@
142 149 }).then(function(r) {
143 150 if (r.ok) {
144 151 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; }
145 - setTimeout(function() { if (status) status.textContent = ''; }, 2000);
152 + if (status) window.timing.clearStatusLater(status);
146 153 } else {
147 154 r.text().then(function(body) {
148 155 var msg = 'Save failed';
@@ -196,7 +203,7 @@
196 203 }).then(function(r) {
197 204 if (r.ok) {
198 205 if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success)'; }
199 - setTimeout(function() { if (status) status.textContent = ''; }, 2000);
206 + if (status) window.timing.clearStatusLater(status);
200 207 } else {
201 208 r.text().then(function(body) {
202 209 var msg = 'Save failed';
@@ -2,7 +2,12 @@
2 2 var input = document.getElementById('wiz-category');
3 3 var dropdown = document.getElementById('category-dropdown');
4 4 if (!input || !dropdown) return;
5 - var debounce;
5 +
6 + // The wait between keystrokes and the search is `Intent::Debounce`, spent
7 + // through the shared helper on the core module. It used to be a 200ms
8 + // timeout hand-rolled here, which was this typeahead disagreeing with the
9 + // docs search for no reason anyone recorded.
10 + var SEARCH = 'category-search';
6 11
7 12 function showDropdown(items, query) {
8 13 dropdown.innerHTML = '';
@@ -37,15 +42,14 @@
37 42 }
38 43
39 44 input.addEventListener('input', function() {
40 - clearTimeout(debounce);
41 45 var q = input.value.trim();
42 - if (q.length < 1) { dropdown.classList.remove('open'); return; }
43 - debounce = setTimeout(function() {
46 + if (q.length < 1) { window.timing.cancelDebounce(SEARCH); dropdown.classList.remove('open'); return; }
47 + window.timing.debounce(SEARCH, function() {
44 48 fetch('/api/categories/search?q=' + encodeURIComponent(q))
45 49 .then(function(r) { return r.json(); })
46 50 .then(function(cats) { showDropdown(cats, q); })
47 51 .catch(function() {});
48 - }, 200);
52 + });
49 53 });
50 54
51 55 input.addEventListener('focus', function() {
@@ -53,6 +57,9 @@
53 57 });
54 58
55 59 input.addEventListener('blur', function() {
60 + // Not a timing intent: the dropdown has to outlive the blur long enough
61 + // for a mousedown on one of its rows to land. A race, so it keeps its
62 + // own number.
56 63 setTimeout(function() { dropdown.classList.remove('open'); }, 150);
57 64 });
58 65
@@ -2,7 +2,12 @@
2 2 var input = document.getElementById('wiz-category');
3 3 var dropdown = document.getElementById('category-dropdown');
4 4 if (!input || !dropdown) return;
5 - var debounce;
5 +
6 + // The wait between keystrokes and the search is `Intent::Debounce`, spent
7 + // through the shared helper on the core module. It used to be a 200ms
8 + // timeout hand-rolled here, which was this typeahead disagreeing with the
9 + // docs search for no reason anyone recorded.
10 + var SEARCH = 'category-search';
6 11
7 12 function showDropdown(items, query) {
8 13 dropdown.innerHTML = '';
@@ -37,15 +42,14 @@
37 42 }
38 43
39 44 input.addEventListener('input', function() {
40 - clearTimeout(debounce);
41 45 var q = input.value.trim();
42 - if (q.length < 1) { dropdown.classList.remove('open'); return; }
43 - debounce = setTimeout(function() {
46 + if (q.length < 1) { window.timing.cancelDebounce(SEARCH); dropdown.classList.remove('open'); return; }
47 + window.timing.debounce(SEARCH, function() {
44 48 fetch('/api/categories/search?q=' + encodeURIComponent(q))
45 49 .then(function(r) { return r.json(); })
46 50 .then(function(cats) { showDropdown(cats, q); })
47 51 .catch(function() {});
48 - }, 200);
52 + });
49 53 });
50 54
51 55 input.addEventListener('focus', function() {
@@ -53,6 +57,9 @@
53 57 });
54 58
55 59 input.addEventListener('blur', function() {
60 + // Not a timing intent: the dropdown has to outlive the blur long enough
61 + // for a mousedown on one of its rows to land. A race, so it keeps its
62 + // own number.
56 63 setTimeout(function() { dropdown.classList.remove('open'); }, 150);
57 64 });
58 65
@@ -3,8 +3,16 @@
3 3 // back to a prompt in non-secure contexts (plain HTTP, some iframes), where the
4 4 // old inline snippets silently no-op'd.
5 5
6 - /** Copy `text`, flashing `copiedLabel` on `el`, then restoring the label. */
7 - export function copyWithFeedback(el: HTMLElement, text: string, copiedLabel = 'Copied!', resetMs = 1500): void {
6 + import { revertMs } from './timing.ts';
7 +
8 + /** Copy `text`, flashing `copiedLabel` on `el`, then restoring the label.
9 + *
10 + * The flash lasts `Intent::Revert`, which is what a temporary label lasts
11 + * everywhere. `resetMs` stays in the signature for a caller with a reason, and
12 + * as of `033c722f` no caller has one: the seven sites that used to pass a
13 + * number passed 1500 six times and 2000 once, which was a divergence rather
14 + * than an axis. */
15 + export function copyWithFeedback(el: HTMLElement, text: string, copiedLabel = 'Copied!', resetMs = revertMs()): void {
8 16 const defaultLabel = el.textContent ?? '';
9 17 const restore = () => {
10 18 el.textContent = defaultLabel;