Skip to main content

max / makenotwork

Describe the schedule form's publish-at, and delete data-config data-config was a private per-app attribute vocabulary that existed for one behaviour: a datetime-local box submits the characters a reader typed, and PUT /api/items/{id} stores a moment. The conversion was four lines in dispatch.ts keyed on an attribute only this app understood, and it was the last thing standing between the private vocabulary and zero. makeover-layout 0.37.0's Field::as_instant is the described version of the same request. quasi-webview ships the script, makeover-webview marks the input, and quasi::schedule_field is the site that asks. The form around it stays Askama's, the same division upload_field makes: converting the whole item-details tab is a screen conversion and not this. No wire contract moved. The route was already receiving an instant; what changed is who computed it. quasi-instant.js joins GENERATED_STATIC_JS, which is what that list exists for. Takes quasi 0.72 and the makeover suite with it: layout 0.37.0, webview 0.63.0, build 0.56.0, plus quasi-type at the rev that carries cut_native.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-28 15:29 UTC
Signed with PGP, not checked
Commit: 1c0b9f4ae985801e10b90af6810a22721cdec6ab
Parent: 0435223
8 files changed, +193 insertions, -52 deletions
M server/Cargo.lock +25 -25
@@ -5390,9 +5390,9 @@
5390 5390
5391 5391 [[package]]
5392 5392 name = "makeover-build"
5393 - version = "0.55.0"
5393 + version = "0.56.0"
5394 5394 source = "registry+https://github.com/rust-lang/crates.io-index"
5395 - checksum = "725a4e9fad30d4d9fa840c408a70dfea583f0d5ada611a95a131a63429b4391a"
5395 + checksum = "56c8903ef617932723aea44f7ada31c7ce4f89d008e8d25ebaee956b2258bb91"
5396 5396 dependencies = [
5397 5397 "makeover",
5398 5398 "makeover-geometry",
@@ -5408,9 +5408,9 @@
5408 5408
5409 5409 [[package]]
5410 5410 name = "makeover-layout"
5411 - version = "0.36.0"
5411 + version = "0.37.0"
5412 5412 source = "registry+https://github.com/rust-lang/crates.io-index"
5413 - checksum = "9ffed28fd6f311d8603b9fe02cffc14bacf29b840c486f84e8ff4866a4748514"
5413 + checksum = "369bc3c3e0ee91d24b3552f48f1ecb1764bca953725fb817df33b2dc80f2d34f"
5414 5414
5415 5415 [[package]]
5416 5416 name = "makeover-timing"
@@ -5423,9 +5423,9 @@
5423 5423
5424 5424 [[package]]
5425 5425 name = "makeover-touch"
5426 - version = "0.26.0"
5426 + version = "0.27.0"
5427 5427 source = "registry+https://github.com/rust-lang/crates.io-index"
5428 - checksum = "941676f114fdb8b8e2dbd2e35dd0149d409212982b37139de831d9865f664d77"
5428 + checksum = "b180e22917189e05d291bfec0e930b5e6c2952dc7bf6e2f7a2262b903431cc72"
5429 5429 dependencies = [
5430 5430 "makeover-geometry",
5431 5431 "makeover-layout",
@@ -5433,9 +5433,9 @@
5433 5433
5434 5434 [[package]]
5435 5435 name = "makeover-webview"
5436 - version = "0.62.0"
5436 + version = "0.63.0"
5437 5437 source = "registry+https://github.com/rust-lang/crates.io-index"
5438 - checksum = "546fb283399000bcd303b1d4d59b7d4c560eea8d6c40c4bed172f0c32a50c8e1"
5438 + checksum = "9149f06aa7b8f4660609a680169d76075d91def63c23127bd84af9b183be22fa"
5439 5439 dependencies = [
5440 5440 "makeover-geometry",
5441 5441 "makeover-layout",
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.71.0"
6639 + version = "0.72.0"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.71.0"
6650 + version = "0.72.0"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.71.0"
6658 + version = "0.72.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,14 +6664,14 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.71.0"
6667 + version = "0.72.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
6671 6671
6672 6672 [[package]]
6673 6673 name = "quasi-type"
6674 - version = "0.1.0"
6674 + version = "0.1.1"
6675 6675 dependencies = [
6676 6676 "brotli",
6677 6677 "font-types",
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.71.0"
6690 + version = "0.72.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -10759,14 +10759,6 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 - [[patch.unused]]
10763 - name = "synckit-client"
10764 - version = "0.9.1"
10765 -
10766 - [[patch.unused]]
10767 - name = "synckit-config"
10768 - version = "0.2.0"
10769 -
10770 10762 [[patch.unused]]
10771 10763 name = "kberg"
10772 10764 version = "0.1.0"
@@ -10779,13 +10771,21 @@
10779 10771 name = "painhours"
10780 10772 version = "0.1.0"
10781 10773
10774 + [[patch.unused]]
10775 + name = "synckit-client"
10776 + version = "0.9.1"
10777 +
10778 + [[patch.unused]]
10779 + name = "synckit-config"
10780 + version = "0.2.0"
10781 +
10782 10782 [[patch.unused]]
10783 10783 name = "quasi-immediate"
10784 - version = "0.71.0"
10784 + version = "0.72.0"
10785 10785
10786 10786 [[patch.unused]]
10787 10787 name = "quasi-notifs"
10788 - version = "0.71.0"
10788 + version = "0.72.0"
10789 10789
10790 10790 [[patch.unused]]
10791 10791 name = "quasi-store"
@@ -10793,4 +10793,4 @@
10793 10793
10794 10794 [[patch.unused]]
10795 10795 name = "quasi-tauri"
10796 - version = "0.71.0"
10796 + version = "0.72.0"
@@ -163,29 +163,29 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
167 167 # The description vocabulary quasi's screen types are built from. Pinned here
168 168 # rather than reached through quasi-router's re-export because a described
169 169 # screen names FieldKind and Tone directly; it has to track what quasi-router
170 170 # resolves or the two `layout::` paths are different crates.
171 - makeover-layout = "0.36.0"
171 + makeover-layout = "0.37.0"
172 172 # The webview renderer's field emitter, called directly by
173 173 # `quasi::rich_field` so one markdown editor's markup comes from the same
174 174 # place a whole described screen's would. Pinned here rather than reached
175 175 # through quasi-webview for makeover-layout's reason: two `form::` paths from
176 176 # two resolutions are two crates, and the `data-format="markdown"` rules in
177 177 # `static/layout.css` are generated from this one.
178 - makeover-webview = "0.62.0"
178 + makeover-webview = "0.63.0"
179 179 # For the request head the per-viewer state factory reads. axum re-exports it,
180 180 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
181 181 http = "1.3.1"
182 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
184 184 # The first-party widget set. The carousel was proved here against three pages
185 185 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
186 186 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
187 187 # sits above quasi-router rather than inside the description suite.
188 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -254,7 +254,7 @@
254 254 # Emits static/geometry.css (makeover-geometry) and static/layout.css
255 255 # (makeover-webview). The same generator GO and BB run; only the output paths
256 256 # differ, since the server serves its stylesheets rather than bundling them.
257 - makeover-build = "0.55.0"
257 + makeover-build = "0.56.0"
258 258 # Read directly for the embeds' own copy of the spacing layer, which they need
259 259 # because an iframe cannot link a stylesheet. makeover-build does not re-export
260 260 # it, so the pin lives here and has to track the one makeover-build resolves.
@@ -269,7 +269,7 @@
269 269 # and the terminal bundles. The three move together, in one pass, or a user sees
270 270 # two Quasi Monos. Under the tree's `[patch]` block the rev is ignored and the
271 271 # working copy is what builds.
272 - quasi-type = { git = "https://makenot.work/git/max/quasi-type.git", version = "0.1", rev = "e40330602e68fbdd2a9d2917bdec33a7ce573a1f" }
272 + quasi-type = { git = "https://makenot.work/git/max/quasi-type.git", version = "0.1.1", rev = "e57a57f35563252b5ed00a8caf1262517f3d51f4" }
273 273 # Writes the two scripts the renderer ships into `static/`, from the constants
274 274 # the crate carries. They are the renderer's behaviour rather than the app's --
275 275 # a bulk bar's count, a readout that keeps ticking -- and the shell has always
@@ -277,7 +277,7 @@
277 277 # Written from the crate rather than copied into the repo for the reason the
278 278 # constants exist: a stale copy in a static directory is a silently dead bulk
279 279 # bar, and the version has to be the one the emitter agrees with.
280 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.71" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.72" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
M server/build.rs +2 -1
@@ -547,7 +547,7 @@
547 547 /// and unserved -- a 404 per document, with nothing in the log -- and
548 548 /// `quasi-awaiting.js` would have joined them the moment it shipped. Found
549 549 /// adding the third, which is the only reason the other two were.
550 - const GENERATED_STATIC_JS: [(&str, &str); 8] = [
550 + const GENERATED_STATIC_JS: [(&str, &str); 9] = [
551 551 ("quasi-selection.js", quasi_webview::SELECTION_JS),
552 552 ("quasi-clock.js", quasi_webview::CLOCK_JS),
553 553 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
@@ -556,6 +556,7 @@
556 556 ("quasi-copy.js", quasi_webview::COPY_JS),
557 557 ("quasi-repeat.js", quasi_webview::REPEAT_JS),
558 558 ("quasi-awaiting.js", quasi_webview::AWAITING_JS),
559 + ("quasi-instant.js", quasi_webview::INSTANT_JS),
559 560 ];
560 561
561 562 /// Every file that can carry a class name.
@@ -55,6 +55,7 @@
55 55 pub mod project_overview;
56 56 pub mod project_tabs;
57 57 pub mod rich_field;
58 + pub mod schedule_field;
58 59 pub mod settings_tabs;
59 60 pub mod ssh_keys;
60 61 pub mod upload_field;
@@ -213,7 +213,7 @@
213 213 });
214 214 }
215 215
216 - /** Install the `data-after` / `data-after-always` / `data-config` dispatcher. */
216 + /** Install the `data-after` / `data-after-always` dispatcher. */
217 217 export function initAfterRequestDispatcher(): void {
218 218 registerAfterVerbs();
219 219
@@ -228,14 +228,4 @@
228 228 if ((evt.detail.ctx.response?.status ?? 0) >= 400) return;
229 229 runList(el, 'data-after', evt);
230 230 });
231 -
232 - document.body.addEventListener('htmx:config:request', (e) => {
233 - const evt = e as CustomEvent<HxDetail>;
234 - const el = evt.detail.ctx.sourceElement;
235 - if (el?.getAttribute?.('data-config') === 'publish-at-iso') {
236 - const body = evt.detail.ctx.request.body;
237 - const v = body?.get('publish_at');
238 - if (typeof v === 'string' && v) body?.set('publish_at', new Date(v).toISOString());
239 - }
240 - });
241 231 }
@@ -318,15 +318,15 @@
318 318 data-after="click" data-target="tab-details">Publish Now</button>
319 319 <button class="btn-secondary" data-action="show" data-target="schedule-form">Schedule</button>
320 320 </div>
321 + {# The field is described: `crate::quasi::schedule_field`. It carries the
322 + label, the hint, that it is required, and that the value is submitted as
323 + the moment it names rather than as the characters typed. That last was
324 + `data-config="publish-at-iso"` and four lines of app JS; it is
325 + makeover-layout's `Field::as_instant` now, and this was its last site. #}
321 326 <form id="schedule-form" class="hidden mt-section"
322 327 hx-put="/api/items/{{ item.id }}"
323 - data-config="publish-at-iso"
324 328 data-after="click" data-target="tab-details">
325 - <div class="form-group">
326 - <label for="publish-at">Publish at</label>
327 - <input type="datetime-local" id="publish-at" name="publish_at" required>
328 - <div class="hint">Uses your computer's time zone.</div>
329 - </div>
329 + {{ crate::quasi::schedule_field::publish_at()|safe }}
330 330 <button type="submit" class="btn-primary">Schedule Publish</button>
331 331 </form>
332 332 {% endif %}
@@ -1,0 +1,82 @@
1 + //! The Askama entry point for the described publish-at field.
2 + //!
3 + //! One field, and it is the last site of `data-config` — a private per-app
4 + //! attribute vocabulary that existed for exactly one behaviour: a
5 + //! `datetime-local` box submits the characters a reader typed, and
6 + //! `PUT /api/items/{id}` stores a moment. Something had to convert, and the
7 + //! conversion was four lines in `frontend/src/core/dispatch.ts` keyed on an
8 + //! attribute only this app understood.
9 + //!
10 + //! `makeover_layout::Field::as_instant` is the described version of the same
11 + //! request: the value is submitted as the moment it names, and how is the
12 + //! renderer's, because the browser is the only party that knows what "your
13 + //! computer's time zone" means. `quasi-webview` ships the script;
14 + //! `makeover-webview` marks the input; this file is the site that asks.
15 + //!
16 + //! # What is described here and what stays the host's
17 + //!
18 + //! Described: the kind, the name, the label, the standing hint, that it is
19 + //! required, and that the value is submitted as an instant.
20 + //!
21 + //! Host: the form around it. `item_details.html` keeps the `<form>` with its
22 + //! `hx-put` and its `data-after`, the same division `upload_field` makes — the
23 + //! wrapper is the app's and the group inside it is the renderer's. Converting
24 + //! the whole item-details tab is a screen conversion and not this.
25 + //!
26 + //! No wire contract moves. The route was already receiving an instant; what
27 + //! changed is who computed it.
28 +
29 + use makeover_layout::FieldKind;
30 + use quasi_router::{Field, Node};
31 +
32 + /// The publish-at field for an item's schedule form.
33 + ///
34 + /// The hint is the one the template carried, and it is still true for the same
35 + /// reason it was: the reader types a time in their own zone. What is no longer
36 + /// true is that nothing said so to the renderer.
37 + #[must_use]
38 + pub fn publish_at() -> String {
39 + use quasi_axum::Serves as _;
40 +
41 + let field = Field::new(FieldKind::DateTime, "publish_at", "Publish at")
42 + .required()
43 + .hint("Uses your computer's time zone.")
44 + .submits_instant();
45 +
46 + // No shell: a fragment landing inside a document Askama already built.
47 + quasi_webview::Webview::new().fragment(&Node::field(field))
48 + }
49 +
50 + #[cfg(test)]
51 + mod tests {
52 + /// The assertion the whole change is for. Without the mark the form
53 + /// submits `2026-09-01T14:30` and the route stores whatever it makes of
54 + /// that, which is the bug `data-config` was patching.
55 + #[test]
56 + fn the_field_asks_to_be_submitted_as_a_moment() {
57 + let html = super::publish_at();
58 +
59 + assert!(html.contains(r#"type="datetime-local""#), "{html}");
60 + assert!(html.contains(r#"name="publish_at""#), "{html}");
61 + assert!(html.contains(r#"data-instant="true""#), "{html}");
62 + assert!(html.contains(" required"), "{html}");
63 + }
64 +
65 + /// The private vocabulary does not come back in through the renderer.
66 + #[test]
67 + fn nothing_here_emits_data_config() {
68 + assert!(!super::publish_at().contains("data-config"), "gone");
69 + }
70 +
71 + /// The label and the hint are the template's, moved rather than rewritten.
72 + #[test]
73 + fn the_question_and_its_standing_help_survived_the_move() {
74 + let html = super::publish_at();
75 +
76 + assert!(html.contains("Publish at"), "{html}");
77 + assert!(
78 + html.contains("Uses your computer&#39;s time zone."),
79 + "{html}"
80 + );
81 + }
82 + }
@@ -1,0 +1,67 @@
1 + // Submitting the moment a reader meant, rather than the words they typed.
2 + //
3 + // A `datetime-local` box asks for a time the way a person says one -- "the 14th
4 + // at half past two" -- and that names a different moment in Denver than it does
5 + // in Berlin. A route storing an instant needs the moment, and nothing in HTML
6 + // converts one to the other: the box submits the characters. So somebody
7 + // converts, and in this renderer that is here.
8 + //
9 + // It is here rather than above the renderer because the browser is the only
10 + // party that knows what "your computer's time zone" means. A description cannot
11 + // answer it; a server can only guess; a terminal and an egui host would each
12 + // answer it their own way. `makeover_layout::Field::as_instant` is the
13 + // description saying it wants the conversion and declining to say how, and this
14 + // file is the how for one host.
15 + //
16 + // It reads one attribute the form renderer writes, `data-instant`, and it is
17 + // the whole of the per-app publish-at plumbing this stack exists to delete. The
18 + // MNW server carried its own copy under a private `data-config` vocabulary.
19 + (() => {
20 + "use strict";
21 +
22 + // The mark makeover-webview puts on a datetime input whose description
23 + // asked for an instant. Only that kind is marked: a date and a time are
24 + // each half a moment and cannot name one on their own.
25 + const MARK = "data-instant";
26 +
27 + /**
28 + * The absolute instant a local wall-clock string names, as RFC 3339.
29 + *
30 + * `new Date(v)` reads a bare `YYYY-MM-DDTHH:mm` in the browser's own zone,
31 + * which is exactly the reading wanted. A value the browser cannot parse is
32 + * left alone rather than replaced with `Invalid Date`: an unparseable value
33 + * is a validation failure the route should see and answer, and rewriting it
34 + * here would turn a legible error into an unreadable one.
35 + */
36 + const instant = (local) => {
37 + const at = new Date(local);
38 + return Number.isNaN(at.getTime()) ? null : at.toISOString();
39 + };
40 +
41 + // Rewrite on the way out, on the request htmx is about to make, so nothing
42 + // the reader can see changes: the box keeps showing the time they typed and
43 + // the wire carries the moment it names.
44 + //
45 + // The walk is over the submitting element's own marked inputs rather than
46 + // over the document, so a page with two forms on it converts the one being
47 + // submitted. `htmx:config:request` is htmx 4's name; 2.x spelled it
48 + // `htmx:configRequest`.
49 + document.body.addEventListener("htmx:config:request", (event) => {
50 + const source = event.detail?.ctx?.sourceElement;
51 + const body = event.detail?.ctx?.request?.body;
52 + if (!source?.querySelectorAll || !body) return;
53 +
54 + const marked =
55 + source.matches?.(`[${MARK}]`) === true
56 + ? [source]
57 + : source.querySelectorAll(`[${MARK}]`);
58 + for (const input of marked) {
59 + const name = input.getAttribute("name");
60 + if (!name) continue;
61 + const local = body.get(name);
62 + if (typeof local !== "string" || !local) continue;
63 + const moment = instant(local);
64 + if (moment !== null) body.set(name, moment);
65 + }
66 + });
67 + })();