// What a browser knows about the time and a description cannot. // // `f00244a6`: the renderer owns the clock. A description carries an instant and // which way a readout runs against now, and picking the words and the rate is // the renderer's half. In this renderer the markup is emitted once by a server // and then held by a browser for as long as the reader is on the page, so this // file is where every set of words after the first one is made. // // The first set is Rust's, in `clock.rs`, and the two formats have to agree or // the page changes what it says a second after it loads. Both sides carry a // note saying so and both are covered by tests. // // It also takes toasts away, which is the other thing on a page that happens // because time passed and for which the browser is the only one awake. // `4453bf82`: the description says a notice goes away on its own and declines // to say when, so the when is the renderer's, and in this renderer that means // here. It rides in this file rather than in a second script the host has to // serve, because it is the same fact -- what a browser knows about the time and // a description cannot -- and a page dropping the clock has no timer left to // take a toast away with either. // // Nothing here knows what a readout is of. It reads two attributes the renderer // emits, `data-clock` for which way the readout runs and `data-at` for the // instant in milliseconds, and it is the whole of the per-app elapsed-time // plumbing this stack exists to delete. The apps that hand-write it run to // hundreds of lines each. (() => { "use strict"; // Two rates, because the cadence follows the granularity the words are // shown at. A stopwatch reads seconds and needs one; a stamp reads "3h ago" // and changes on the minute at its finest, so waking for it every second // would repaint the page 60 times to change nothing. // // Many readouts, one tick: every readout of a kind is written by that // kind's own interval, rather than each element carrying a timer. A page of // running timers costs one wake a second however many of them there are, // which is what the hand-written version arrives at after it has been // rewritten twice. const RATES = { since: 1000, until: 1000, age: 30000 }; // How long a toast stays before it is taken off the page. // // `eea7ba88`. Read rather than written: `makeover-timing` names the // duration `Intent::Dismiss`, and `makeover-build` writes it into every // consumer's `timing.css` as `--timing-dismiss`. The terminal and egui // renderers take the same number out of the same crate, so a screen // described once and drawn three times keeps its messages for one length of // time and moving that length is moving one constant. // // The fallback is for a page that ships this script without that // stylesheet. It is the crate's own value and a Rust test pins it there, the // same arrangement the two format implementations above already run on: a // number in two languages is fine so long as something fails when they part. const LINGER_FALLBACK = 3000; // How long a toast takes to leave, once its linger is up. // // `43bdbff7`. `makeover-timing` has always said `Intent::Dismiss` is how // long a notice lives *before it starts to leave*, and that the leaving is // `Motion::Fade`; the renderers removed the node at Dismiss and the fade // was emitted into every `timing.css` and read by nothing. It is read here, // and `makeover-webview` 0.69.0 carries the rule that draws it. // // The fallback is `Motion::Fade.ms()`, pinned by a Rust test the same way // `LINGER_FALLBACK` is: a number in two languages is fine so long as // something fails when they part. const FADE_FALLBACK = 300; // A CSS time as a number of milliseconds. `3000ms` and `3s` are both legal // spellings of the same duration and a stylesheet may carry either, so the // unit is read rather than assumed. An absent or unparseable value is a page // with no timing sheet, not a page asking for zero. const milliseconds = (token, fallback) => { const raw = getComputedStyle(document.documentElement) .getPropertyValue(token) .trim(); const parsed = Number.parseFloat(raw); if (!Number.isFinite(parsed)) return fallback; return raw.endsWith("ms") ? parsed : parsed * 1000; }; // Read once, on the first settle rather than at parse time: this script may // run before the stylesheet it is asking about has been applied, and a // custom property that is not there yet reads as the empty string. let lingerMs = null; let fadeMs = null; /** A span in seconds as a stopwatch: `h:mm:ss`, hours unbounded. */ const face = (seconds) => { const whole = Math.max(0, Math.floor(seconds)); const mm = String(Math.floor((whole % 3600) / 60)).padStart(2, "0"); const ss = String(whole % 60).padStart(2, "0"); return `${Math.floor(whole / 3600)}:${mm}:${ss}`; }; /** A span in seconds as a stamp: the largest unit that is not zero. */ const ago = (seconds) => { const whole = Math.max(0, Math.floor(seconds)); if (whole < 60) return "just now"; if (whole < 3600) return `${Math.floor(whole / 60)}m ago`; if (whole < 86400) return `${Math.floor(whole / 3600)}h ago`; return `${Math.floor(whole / 86400)}d ago`; }; /** What one readout says now. */ const words = (kind, at, now) => { const seconds = (now - at) / 1000; if (kind === "until") return face(-seconds); if (kind === "age") return ago(seconds); return face(seconds); }; /** Write every readout of one kind. */ const sync = (kind) => { const now = Date.now(); for (const readout of document.querySelectorAll(`[data-clock="${kind}"]`)) { const at = Number(readout.getAttribute("data-at")); if (Number.isNaN(at)) { continue; } readout.textContent = words(kind, at, now); } }; // One interval per kind, started once and never cleared. A swap brings new // readouts and takes old ones away, and both are found by the next tick // because the walk is over the document rather than over a list captured // when the page loaded -- the same reason the selection script delegates // its events. for (const [kind, rate] of Object.entries(RATES)) { setInterval(() => sync(kind), rate); } // Start the clock on every toast that has not been given one. // // A timer per element, which is what the readouts above deliberately do not // do. The reasoning is the same reasoning and it comes out the other way: a // page carries many readouts and each one wants waking again and again, and // it carries one or two toasts whose timer runs once and is over. What a // shared interval would buy here is a message that leaves up to a second // late, which is the whole of what the reader would see. // // The mark is what keeps a settle from starting a second timer on a toast // that is already counting. const linger = () => { if (lingerMs === null) { lingerMs = milliseconds("--timing-dismiss", LINGER_FALLBACK); fadeMs = milliseconds("--motion-fade", FADE_FALLBACK); } const fresh = '[data-notice="toast"]:not([data-lingering])'; for (const toast of document.querySelectorAll(fresh)) { toast.setAttribute("data-lingering", ""); // Two steps, because Dismiss is when the leaving *starts*. The // attribute is what `makeover-webview`'s rule transitions on; the // node goes when the transition is over. setTimeout(() => { toast.setAttribute("data-leaving", ""); // A timer and not `transitionend`, deliberately. `timing.css` // zeroes `--motion-fade` under `prefers-reduced-motion`, and a // zero-length transition may fire no event at all -- a node // waiting on one that never comes would stay on the page // forever, for exactly the reader who asked for less motion. // The timer costs one frame there and cannot hang. setTimeout(() => toast.remove(), fadeMs); }, lingerMs); } }; // A readout that arrives mid-page should not wait out a whole period // showing what the server said a minute ago, and a toast that arrives in a // swap is exactly how a toast arrives. htmx's event and the initial parse // both land here. const settled = () => { for (const kind of Object.keys(RATES)) { sync(kind); } linger(); }; // `htmx:after:settle` is htmx 4's name for it; 2.x spelled the same // event `htmx:afterSettle`, and 4 renamed every event to phase:action. document.addEventListener("htmx:after:settle", settled); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", settled); } else { settled(); } })();