Skip to main content

max / quasi

9.1 KB · 190 lines History Blame Raw
1 // What a browser knows about the time and a description cannot.
2 //
3 // `f00244a6`: the renderer owns the clock. A description carries an instant and
4 // which way a readout runs against now, and picking the words and the rate is
5 // the renderer's half. In this renderer the markup is emitted once by a server
6 // and then held by a browser for as long as the reader is on the page, so this
7 // file is where every set of words after the first one is made.
8 //
9 // The first set is Rust's, in `clock.rs`, and the two formats have to agree or
10 // the page changes what it says a second after it loads. Both sides carry a
11 // note saying so and both are covered by tests.
12 //
13 // It also takes toasts away, which is the other thing on a page that happens
14 // because time passed and for which the browser is the only one awake.
15 // `4453bf82`: the description says a notice goes away on its own and declines
16 // to say when, so the when is the renderer's, and in this renderer that means
17 // here. It rides in this file rather than in a second script the host has to
18 // serve, because it is the same fact -- what a browser knows about the time and
19 // a description cannot -- and a page dropping the clock has no timer left to
20 // take a toast away with either.
21 //
22 // Nothing here knows what a readout is of. It reads two attributes the renderer
23 // emits, `data-clock` for which way the readout runs and `data-at` for the
24 // instant in milliseconds, and it is the whole of the per-app elapsed-time
25 // plumbing this stack exists to delete. The apps that hand-write it run to
26 // hundreds of lines each.
27 (() => {
28 "use strict";
29
30 // Two rates, because the cadence follows the granularity the words are
31 // shown at. A stopwatch reads seconds and needs one; a stamp reads "3h ago"
32 // and changes on the minute at its finest, so waking for it every second
33 // would repaint the page 60 times to change nothing.
34 //
35 // Many readouts, one tick: every readout of a kind is written by that
36 // kind's own interval, rather than each element carrying a timer. A page of
37 // running timers costs one wake a second however many of them there are,
38 // which is what the hand-written version arrives at after it has been
39 // rewritten twice.
40 const RATES = { since: 1000, until: 1000, age: 30000 };
41
42 // How long a toast stays before it is taken off the page.
43 //
44 // `eea7ba88`. Read rather than written: `makeover-timing` names the
45 // duration `Intent::Dismiss`, and `makeover-build` writes it into every
46 // consumer's `timing.css` as `--timing-dismiss`. The terminal and egui
47 // renderers take the same number out of the same crate, so a screen
48 // described once and drawn three times keeps its messages for one length of
49 // time and moving that length is moving one constant.
50 //
51 // The fallback is for a page that ships this script without that
52 // stylesheet. It is the crate's own value and a Rust test pins it there, the
53 // same arrangement the two format implementations above already run on: a
54 // number in two languages is fine so long as something fails when they part.
55 const LINGER_FALLBACK = 3000;
56
57 // How long a toast takes to leave, once its linger is up.
58 //
59 // `43bdbff7`. `makeover-timing` has always said `Intent::Dismiss` is how
60 // long a notice lives *before it starts to leave*, and that the leaving is
61 // `Motion::Fade`; the renderers removed the node at Dismiss and the fade
62 // was emitted into every `timing.css` and read by nothing. It is read here,
63 // and `makeover-webview` 0.69.0 carries the rule that draws it.
64 //
65 // The fallback is `Motion::Fade.ms()`, pinned by a Rust test the same way
66 // `LINGER_FALLBACK` is: a number in two languages is fine so long as
67 // something fails when they part.
68 const FADE_FALLBACK = 300;
69
70 // A CSS time as a number of milliseconds. `3000ms` and `3s` are both legal
71 // spellings of the same duration and a stylesheet may carry either, so the
72 // unit is read rather than assumed. An absent or unparseable value is a page
73 // with no timing sheet, not a page asking for zero.
74 const milliseconds = (token, fallback) => {
75 const raw = getComputedStyle(document.documentElement)
76 .getPropertyValue(token)
77 .trim();
78 const parsed = Number.parseFloat(raw);
79 if (!Number.isFinite(parsed)) return fallback;
80 return raw.endsWith("ms") ? parsed : parsed * 1000;
81 };
82
83 // Read once, on the first settle rather than at parse time: this script may
84 // run before the stylesheet it is asking about has been applied, and a
85 // custom property that is not there yet reads as the empty string.
86 let lingerMs = null;
87 let fadeMs = null;
88
89 /** A span in seconds as a stopwatch: `h:mm:ss`, hours unbounded. */
90 const face = (seconds) => {
91 const whole = Math.max(0, Math.floor(seconds));
92 const mm = String(Math.floor((whole % 3600) / 60)).padStart(2, "0");
93 const ss = String(whole % 60).padStart(2, "0");
94 return `${Math.floor(whole / 3600)}:${mm}:${ss}`;
95 };
96
97 /** A span in seconds as a stamp: the largest unit that is not zero. */
98 const ago = (seconds) => {
99 const whole = Math.max(0, Math.floor(seconds));
100 if (whole < 60) return "just now";
101 if (whole < 3600) return `${Math.floor(whole / 60)}m ago`;
102 if (whole < 86400) return `${Math.floor(whole / 3600)}h ago`;
103 return `${Math.floor(whole / 86400)}d ago`;
104 };
105
106 /** What one readout says now. */
107 const words = (kind, at, now) => {
108 const seconds = (now - at) / 1000;
109 if (kind === "until") return face(-seconds);
110 if (kind === "age") return ago(seconds);
111 return face(seconds);
112 };
113
114 /** Write every readout of one kind. */
115 const sync = (kind) => {
116 const now = Date.now();
117 for (const readout of document.querySelectorAll(`[data-clock="${kind}"]`)) {
118 const at = Number(readout.getAttribute("data-at"));
119 if (Number.isNaN(at)) {
120 continue;
121 }
122 readout.textContent = words(kind, at, now);
123 }
124 };
125
126 // One interval per kind, started once and never cleared. A swap brings new
127 // readouts and takes old ones away, and both are found by the next tick
128 // because the walk is over the document rather than over a list captured
129 // when the page loaded -- the same reason the selection script delegates
130 // its events.
131 for (const [kind, rate] of Object.entries(RATES)) {
132 setInterval(() => sync(kind), rate);
133 }
134
135 // Start the clock on every toast that has not been given one.
136 //
137 // A timer per element, which is what the readouts above deliberately do not
138 // do. The reasoning is the same reasoning and it comes out the other way: a
139 // page carries many readouts and each one wants waking again and again, and
140 // it carries one or two toasts whose timer runs once and is over. What a
141 // shared interval would buy here is a message that leaves up to a second
142 // late, which is the whole of what the reader would see.
143 //
144 // The mark is what keeps a settle from starting a second timer on a toast
145 // that is already counting.
146 const linger = () => {
147 if (lingerMs === null) {
148 lingerMs = milliseconds("--timing-dismiss", LINGER_FALLBACK);
149 fadeMs = milliseconds("--motion-fade", FADE_FALLBACK);
150 }
151 const fresh = '[data-notice="toast"]:not([data-lingering])';
152 for (const toast of document.querySelectorAll(fresh)) {
153 toast.setAttribute("data-lingering", "");
154 // Two steps, because Dismiss is when the leaving *starts*. The
155 // attribute is what `makeover-webview`'s rule transitions on; the
156 // node goes when the transition is over.
157 setTimeout(() => {
158 toast.setAttribute("data-leaving", "");
159 // A timer and not `transitionend`, deliberately. `timing.css`
160 // zeroes `--motion-fade` under `prefers-reduced-motion`, and a
161 // zero-length transition may fire no event at all -- a node
162 // waiting on one that never comes would stay on the page
163 // forever, for exactly the reader who asked for less motion.
164 // The timer costs one frame there and cannot hang.
165 setTimeout(() => toast.remove(), fadeMs);
166 }, lingerMs);
167 }
168 };
169
170 // A readout that arrives mid-page should not wait out a whole period
171 // showing what the server said a minute ago, and a toast that arrives in a
172 // swap is exactly how a toast arrives. htmx's event and the initial parse
173 // both land here.
174 const settled = () => {
175 for (const kind of Object.keys(RATES)) {
176 sync(kind);
177 }
178 linger();
179 };
180
181 // `htmx:after:settle` is htmx 4's name for it; 2.x spelled the same
182 // event `htmx:afterSettle`, and 4 renamed every event to phase:action.
183 document.addEventListener("htmx:after:settle", settled);
184 if (document.readyState === "loading") {
185 document.addEventListener("DOMContentLoaded", settled);
186 } else {
187 settled();
188 }
189 })();
190