Skip to main content

max / quasi

A control can write when its value changes, and prose can be markdown
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:28 UTC
Signed with PGP, not checked
Commit: 4b2ef3ee10de27e0434ed63a590c855759434541
Parent: 4207aab
5 files changed, +400 insertions, -37 deletions
M Cargo.lock +112 -16
@@ -32,6 +32,18 @@
32 32 "alloc-no-stdlib",
33 33 ]
34 34
35 + [[package]]
36 + name = "ammonia"
37 + version = "4.1.4"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
40 + dependencies = [
41 + "cssparser 0.37.0",
42 + "html5ever 0.39.0",
43 + "maplit",
44 + "url",
45 + ]
46 +
35 47 [[package]]
36 48 name = "android_system_properties"
37 49 version = "0.1.6"
@@ -480,6 +492,17 @@
480 492 "smallvec",
481 493 ]
482 494
495 + [[package]]
496 + name = "cssparser"
497 + version = "0.37.0"
498 + source = "registry+https://github.com/rust-lang/crates.io-index"
499 + checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
500 + dependencies = [
501 + "dtoa-short",
502 + "itoa",
503 + "smallvec",
504 + ]
505 +
483 506 [[package]]
484 507 name = "cssparser-macros"
485 508 version = "0.6.1"
@@ -659,6 +682,15 @@
659 682 "syn 2.0.119",
660 683 ]
661 684
685 + [[package]]
686 + name = "docengine"
687 + version = "0.4.0"
688 + dependencies = [
689 + "ammonia",
690 + "pulldown-cmark",
691 + "serde",
692 + ]
693 +
662 694 [[package]]
663 695 name = "dom_query"
664 696 version = "0.27.0"
@@ -666,9 +698,9 @@
666 698 checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
667 699 dependencies = [
668 700 "bit-set",
669 - "cssparser",
701 + "cssparser 0.36.0",
670 702 "foldhash",
671 - "html5ever",
703 + "html5ever 0.38.0",
672 704 "precomputed-hash",
673 705 "selectors",
674 706 "tendril",
@@ -1031,6 +1063,15 @@
1031 1063 "version_check",
1032 1064 ]
1033 1065
1066 + [[package]]
1067 + name = "getopts"
1068 + version = "0.2.24"
1069 + source = "registry+https://github.com/rust-lang/crates.io-index"
1070 + checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
1071 + dependencies = [
1072 + "unicode-width",
1073 + ]
1074 +
1034 1075 [[package]]
1035 1076 name = "getrandom"
1036 1077 version = "0.2.17"
@@ -1250,7 +1291,17 @@
1250 1291 checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
1251 1292 dependencies = [
1252 1293 "log",
1253 - "markup5ever",
1294 + "markup5ever 0.38.0",
1295 + ]
1296 +
1297 + [[package]]
1298 + name = "html5ever"
1299 + version = "0.39.0"
1300 + source = "registry+https://github.com/rust-lang/crates.io-index"
1301 + checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
1302 + dependencies = [
1303 + "log",
1304 + "markup5ever 0.39.0",
1254 1305 ]
1255 1306
1256 1307 [[package]]
@@ -1751,6 +1802,12 @@
1751 1802 "makeover-touch",
1752 1803 ]
1753 1804
1805 + [[package]]
1806 + name = "maplit"
1807 + version = "1.0.2"
1808 + source = "registry+https://github.com/rust-lang/crates.io-index"
1809 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
1810 +
1754 1811 [[package]]
1755 1812 name = "markup5ever"
1756 1813 version = "0.38.0"
@@ -1762,6 +1819,17 @@
1762 1819 "web_atoms",
1763 1820 ]
1764 1821
1822 + [[package]]
1823 + name = "markup5ever"
1824 + version = "0.39.0"
1825 + source = "registry+https://github.com/rust-lang/crates.io-index"
1826 + checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
1827 + dependencies = [
1828 + "log",
1829 + "tendril",
1830 + "web_atoms",
1831 + ]
1832 +
1765 1833 [[package]]
1766 1834 name = "matchit"
1767 1835 version = "0.8.4"
@@ -2337,6 +2405,25 @@
2337 2405 "unicode-ident",
2338 2406 ]
2339 2407
2408 + [[package]]
2409 + name = "pulldown-cmark"
2410 + version = "0.13.4"
2411 + source = "registry+https://github.com/rust-lang/crates.io-index"
2412 + checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
2413 + dependencies = [
2414 + "bitflags 2.13.1",
2415 + "getopts",
2416 + "memchr",
2417 + "pulldown-cmark-escape",
2418 + "unicase",
2419 + ]
2420 +
2421 + [[package]]
2422 + name = "pulldown-cmark-escape"
2423 + version = "0.11.0"
2424 + source = "registry+https://github.com/rust-lang/crates.io-index"
2425 + checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
2426 +
2340 2427 [[package]]
2341 2428 name = "quasi"
2342 2429 version = "0.0.0"
@@ -2386,6 +2473,7 @@
2386 2473 name = "quasi-webview"
2387 2474 version = "0.1.0"
2388 2475 dependencies = [
2476 + "docengine",
2389 2477 "makeover-layout",
2390 2478 "makeover-webview",
2391 2479 "quasi-http",
@@ -2631,7 +2719,7 @@
2631 2719 checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
2632 2720 dependencies = [
2633 2721 "bitflags 2.13.1",
2634 - "cssparser",
2722 + "cssparser 0.36.0",
2635 2723 "derive_more",
2636 2724 "log",
2637 2725 "new_debug_unreachable",
@@ -3697,6 +3785,12 @@
3697 3785 "unic-common",
3698 3786 ]
3699 3787
3788 + [[package]]
3789 + name = "unicase"
3790 + version = "2.9.0"
3791 + source = "registry+https://github.com/rust-lang/crates.io-index"
3792 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
3793 +
3700 3794 [[package]]
3701 3795 name = "unicode-ident"
3702 3796 version = "1.0.24"
@@ -3709,6 +3803,12 @@
3709 3803 source = "registry+https://github.com/rust-lang/crates.io-index"
3710 3804 checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
3711 3805
3806 + [[package]]
3807 + name = "unicode-width"
3808 + version = "0.2.2"
3809 + source = "registry+https://github.com/rust-lang/crates.io-index"
3810 + checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
3811 +
3712 3812 [[package]]
3713 3813 name = "url"
3714 3814 version = "2.5.8"
@@ -4530,6 +4630,14 @@
4530 4630 source = "registry+https://github.com/rust-lang/crates.io-index"
4531 4631 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4532 4632
4633 + [[patch.unused]]
4634 + name = "synckit-client"
4635 + version = "0.8.0"
4636 +
4637 + [[patch.unused]]
4638 + name = "synckit-config"
4639 + version = "0.2.0"
4640 +
4533 4641 [[patch.unused]]
4534 4642 name = "kberg"
4535 4643 version = "0.1.0"
@@ -4541,15 +4649,3 @@
4541 4649 [[patch.unused]]
4542 4650 name = "tagtree"
4543 4651 version = "0.4.0"
4544 -
4545 - [[patch.unused]]
4546 - name = "docengine"
4547 - version = "0.4.0"
4548 -
4549 - [[patch.unused]]
4550 - name = "synckit-client"
4551 - version = "0.8.0"
4552 -
4553 - [[patch.unused]]
4554 - name = "synckit-config"
4555 - version = "0.2.0"
@@ -17,3 +17,15 @@
17 17 quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 18 makeover-layout = "0.10.0"
19 19 makeover-webview = "0.19.0"
20 + # `Node::Rich` carries markdown source and this is what turns it into markup.
21 + # Optional and default-on: a renderer that cannot render the member makes the
22 + # member do nothing, and a consumer with no rich text should not pay for a
23 + # markdown parser. Sanitising comes with it, which is why the node can carry
24 + # what a user typed.
25 + docengine = { git = "https://makenot.work/git/max/docengine.git", version = "0.4.0", optional = true }
26 +
27 + [features]
28 + default = ["rich"]
29 + # Render `Node::Rich`. Without it the node emits its source as text, which is
30 + # what every renderer did before the member existed.
31 + rich = ["dep:docengine"]
@@ -315,7 +315,9 @@
315 315 /// the buffer never went anywhere, so nothing is lost and there is nothing to
316 316 /// re-offer. This is the layer where the loss happens, so this is the layer that
317 317 /// repairs it.
318 - #[derive(Debug, Clone, PartialEq, Eq, Hash)]
318 + /// No `Hash`, for the reason [`Tag`] has none: it can hold an [`Action`], which
319 + /// holds [`Params`], which is a `Vec`.
320 + #[derive(Debug, Clone, PartialEq, Eq)]
319 321 pub struct Field {
320 322 /// What kind of value it takes.
321 323 pub kind: layout::FieldKind,
@@ -348,6 +350,20 @@
348 350 /// to set it and every renderer refuses to emit it, so the guarantee does
349 351 /// not rest on either alone.
350 352 pub value: Option<String>,
353 + /// What changing this calls, for a control that writes on its own rather
354 + /// than waiting for a submit.
355 + ///
356 + /// `14612ed8`. A field inside a [`Node::Form`] submits with the form and
357 + /// needs nothing here. A settings toggle is the other kind: there is no
358 + /// submit, and changing the control *is* the write. goingson had 13 of these
359 + /// and reached them through `dispatch.js`, 109 lines of its own event
360 + /// plumbing, because nothing in the description could say it. No version of
361 + /// spinning up an app quickly has each app hand-rolling a dispatcher.
362 + ///
363 + /// The route receives the value under this field's [`name`](Self::name),
364 + /// which is the same name a submit would have sent it under. Nothing else
365 + /// changes about the field.
366 + pub changes: Option<Action>,
351 367 }
352 368
353 369 impl Field {
@@ -364,9 +380,17 @@
364 380 required: false,
365 381 extended: false,
366 382 value: None,
383 + changes: None,
367 384 }
368 385 }
369 386
387 + /// Changing this writes, without waiting for a submit.
388 + #[must_use]
389 + pub fn changes(mut self, action: Action) -> Self {
390 + self.changes = Some(action);
391 + self
392 + }
393 +
370 394 /// A select offering the given options.
371 395 pub fn select(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self {
372 396 Self {
@@ -746,6 +770,23 @@
746 770 /// [`current`](Self::current). goingson's contacts and tasks screens both
747 771 /// drive bulk actions from it.
748 772 pub selected: Option<bool>,
773 + /// What ticking this row calls, if ticking it is the write.
774 + ///
775 + /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row
776 + /// is ticked and whether it can be, and that was the whole story for a bulk
777 + /// checkbox, whose tick is client state feeding a later action. A checklist
778 + /// is the other case: the tick *is* the write, and it is the only affordance
779 + /// the screen offers for it. Described without this, the port drew the tick
780 + /// inert and put the toggle on a button beside it, which is a user clicking
781 + /// a button next to a checkbox that ignores clicks.
782 + ///
783 + /// Two fields rather than a `Selection` struct, matching how
784 + /// [`activate`](Self::activate) sits beside [`current`](Self::current):
785 + /// state and behaviour are separate facts about the row. They do have to
786 + /// agree — a `toggle` with no [`selected`](Self::selected) is a route on a
787 + /// control nothing draws — and [`Row::toggling`] is the constructor that
788 + /// makes them agree.
789 + pub toggle: Option<Action>,
749 790 }
750 791
751 792 impl Row {
@@ -757,6 +798,19 @@
757 798 }
758 799 }
759 800
801 + /// A tick that is the write, in the state it is currently in.
802 + ///
803 + /// Sets [`selected`](Self::selected) and [`toggle`](Self::toggle) together,
804 + /// because a route on a tick nothing draws is the one way the two fields can
805 + /// disagree. A checklist item is what this is for; a bulk checkbox sets
806 + /// `selected` alone and keeps its meaning as client state.
807 + #[must_use]
808 + pub fn toggling(mut self, ticked: bool, action: Action) -> Self {
809 + self.selected = Some(ticked);
810 + self.toggle = Some(action);
811 + self
812 + }
813 +
760 814 /// Supporting text under the primary.
761 815 #[must_use]
762 816 pub fn secondary(mut self, text: impl Into<String>) -> Self {
@@ -871,6 +925,28 @@
871 925 /// What it is saying. [`layout::Tone::Neutral`] is ordinary content.
872 926 tone: layout::Tone,
873 927 },
928 + /// Prose the author wrote in markdown.
929 + ///
930 + /// `25822137`, decided 2026-08-09. What is carried is the **source**, never
931 + /// markup, which is the property that lets this exist at all. Every renderer
932 + /// has an honest answer because each renders the source its own way: a
933 + /// webview through a markdown-to-HTML pass, a terminal through
934 + /// markdown-to-ANSI, egui through its own. A `Node::Html` would have handed
935 + /// every one of them a string it could not honour, and would have broken
936 + /// [`Node::Text`]'s escaping guarantee for every consumer rather than the
937 + /// one that asked. That refusal stands; this is not it.
938 + ///
939 + /// Sanitising is the renderer's, at the point markup is produced, for the
940 + /// reason escaping already is: this holds text a user typed, and a
941 + /// description that sanitised would be deciding what a host can draw.
942 + ///
943 + /// Not available inside a [`Row`]: a row part holds no node, by the
944 + /// 2026-08-08 ruling, and that is why the projects card keeps its raw
945 + /// markdown in `secondary` rather than gaining this.
946 + Rich {
947 + /// The markdown, as written.
948 + source: String,
949 + },
874 950 /// A control that calls a route.
875 951 Act(Act),
876 952 /// A small labelled thing sitting inside something else.
@@ -884,6 +960,17 @@
884 960 /// The message.
885 961 text: String,
886 962 },
963 + /// One control, standing on its own.
964 + ///
965 + /// `14612ed8`. A [`Form`](Self::Form) is a set of questions asked together
966 + /// and answered at once. A settings screen is not that: goingson's is
967 + /// sections with headings between them, each holding one control that writes
968 + /// as soon as it changes, and wrapping those in a form would describe markup
969 + /// that is not there and a submit that does not exist.
970 + ///
971 + /// Almost always carries a [`Field::changes`], because a control with no
972 + /// form around it and no route on it collects a value nothing reads.
973 + Field(Field),
887 974 /// Fields, and the route that submits them.
888 975 Form {
889 976 /// Where the answers go. Almost always a [`Method::Post`].
@@ -982,6 +1069,13 @@
982 1069 }
983 1070 }
984 1071
1072 + /// Prose written in markdown.
1073 + pub fn rich(source: impl Into<String>) -> Self {
1074 + Self::Rich {
1075 + source: source.into(),
1076 + }
1077 + }
1078 +
985 1079 /// A control calling a route.
986 1080 pub fn act(label: impl Into<String>, action: Action) -> Self {
987 1081 Self::Act(Act::new(label, action))
@@ -83,6 +83,61 @@
83 83 }
84 84 }
85 85
86 + /// One field, with whatever `1c4a66a4` and `14612ed8` added around it.
87 + ///
88 + /// The field's own markup is makeover-webview's, unchanged. A second field
89 + /// emitter here is the divergence phase A existed to end, and it would be the
90 + /// same anatomy with a different escaping story.
91 + ///
92 + /// A [`Field::changes`] is a wrapper rather than attributes on the control,
93 + /// because the control is emitted by makeover-webview and there is no seam to
94 + /// put them through. That turns out to be the better shape anyway: the `change`
95 + /// event bubbles, so one element around the group catches it whichever of the
96 + /// input, select or textarea forms the field took, and `hx-include` finds the
97 + /// control back without this having to know which it was.
98 + fn field_group_html(field: &Field, morphs: bool, opts: &Emit, out: &mut String) {
99 + let filling = Filling::of(refill(field));
100 + let writes = field.changes.as_ref();
101 +
102 + if let Some(action) = writes {
103 + out.push_str("<div");
104 + class_attr(&["field-writes"], opts, out);
105 + action_attrs(action, Fires::ChangeInside, morphs, out);
106 + out.push('>');
107 + }
108 +
109 + field.with_layout(|borrowed| {
110 + out.push_str(&field_html(&borrowed, &filling, opts));
111 + });
112 +
113 + if writes.is_some() {
114 + out.push_str("</div>");
115 + }
116 + }
117 +
118 + /// Markdown source into markup, for [`Node::Rich`].
119 + ///
120 + /// The strict preset, which is a deliberate difference from the
121 + /// `render_standard` goingson's own JS calls: standard lets sanitised raw HTML
122 + /// through, and a shared renderer taking text a user typed should be the safer
123 + /// of the two by default. What it costs is angle brackets in a description
124 + /// rendering as text rather than as markup, which is the outcome
125 + /// [`Node::Text`] would have given anyway.
126 + ///
127 + /// Without the `rich` feature the source is escaped and emitted as it was
128 + /// written. That is lossy and visible rather than silent, and it is what the
129 + /// node did before it could be rendered at all.
130 + fn rich_html(source: &str) -> String {
131 + #[cfg(feature = "rich")]
132 + {
133 + docengine::render_strict(source)
134 + }
135 + #[cfg(not(feature = "rich"))]
136 + {
137 + escape(source)
138 + }
139 + }
140 +
86 141 /// JSON-encode a string, for an `hx-vals` payload.
87 142 ///
88 143 /// Small enough to own. Pulling in a JSON crate to write object literals of
@@ -123,6 +178,27 @@
123 178 json
124 179 }
125 180
181 + /// What makes a control call its action.
182 + ///
183 + /// Here rather than at the call sites because every `hx-` attribute this crate
184 + /// emits has to come out of one function, which is decision 13's claim that the
185 + /// transport is replaceable and is asserted by a test.
186 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
187 + pub(crate) enum Fires {
188 + /// The user activating the control. htmx's default for a button or a link.
189 + Click,
190 + /// The control's own value changing. What a checkbox that is itself the
191 + /// write does.
192 + Change,
193 + /// The value of a control *inside* this element changing.
194 + ///
195 + /// A field group, whose control is emitted by makeover-webview and has no
196 + /// seam to hang attributes on. The `change` event bubbles, so the wrapper
197 + /// catches it whichever of input, select or textarea the field turned out to
198 + /// be, and the value is found back rather than assumed.
199 + ChangeInside,
200 + }
201 +
126 202 /// The transport attributes for one action.
127 203 ///
128 204 /// `hx-vals` rather than a hand-built query string, for both methods. htmx
@@ -130,7 +206,7 @@
130 206 /// one code path covers both, and no part of this stack concatenates a `?`.
131 207 /// That is where escaping bugs live, and the description layer's own reason for
132 208 /// carrying [`Action::params`] as values rather than as text.
133 - pub(crate) fn action_attrs(action: &Action, morphs: bool, out: &mut String) {
209 + pub(crate) fn action_attrs(action: &Action, fires: Fires, morphs: bool, out: &mut String) {
134 210 // An external destination is not htmx's business: nothing swaps, no route
135 211 // is called, and the browser follows a normal link. `rel` rather than
136 212 // trust: a new tab with `window.opener` left intact hands the other page a
@@ -156,6 +232,17 @@
156 232 out.push('"');
157 233 }
158 234
235 + // Named even where it matches htmx's own default for the element, so the
236 + // markup says what it does rather than resting on a default holding.
237 + match fires {
238 + Fires::Click => {}
239 + Fires::Change => out.push_str(" hx-trigger=\"change\""),
240 + Fires::ChangeInside => {
241 + out.push_str(" hx-trigger=\"change\"");
242 + out.push_str(" hx-include=\"find input, find select, find textarea\"");
243 + }
244 + }
245 +
159 246 if morphs {
160 247 // Decision 7's slack: a morph preserves focus, scroll and input state
161 248 // through a swap, so a whole-Screen answer stops being destructive.
@@ -211,13 +298,13 @@
211 298 }
212 299 Some(layout::State::Focus) => {
213 300 out.push_str(" autofocus");
214 - action_attrs(&act.action, morphs, out);
301 + action_attrs(&act.action, Fires::Click, morphs, out);
215 302 }
216 303 // `State` is `#[non_exhaustive]`, so a member added upstream lands
217 304 // here. Emitting the transport is the right default for anything that
218 305 // is not a suppression: a state this renderer has not learned yet
219 306 // should leave the control working, not silently inert.
220 - _ => action_attrs(&act.action, morphs, out),
307 + _ => action_attrs(&act.action, Fires::Click, morphs, out),
221 308 }
222 309
223 310 out.push('>');
@@ -255,10 +342,14 @@
255 342 if ticked {
256 343 out.push_str(" checked");
257 344 }
258 - // What ticking it *does* is not describable here, and deliberately: it
259 - // is local state until something submits it, and the description layer
260 - // does not carry local state. The app binds this the way it binds any
261 - // other input, which for goingson is its delegated dispatcher.
345 + // A tick with no route is local state until something submits it, which
346 + // is what a bulk checkbox is; the app binds those itself. A tick with
347 + // one is the write, which is `14612ed8`, and htmx's own default trigger
348 + // for an input is `change` — named anyway, so the markup says what it
349 + // does rather than relying on a default holding.
350 + if let Some(action) = &row.toggle {
351 + action_attrs(action, Fires::Change, morphs, out);
352 + }
262 353 out.push_str(" aria-label=\"Select\">");
263 354 }
264 355
@@ -270,7 +361,7 @@
270 361 let (open, close) = control_tag(action);
271 362 out.push_str(open);
272 363 class_attr(&["row-activate"], opts, out);
273 - action_attrs(action, morphs, out);
364 + action_attrs(action, Fires::Click, morphs, out);
274 365 out.push('>');
275 366 out.push_str(&escape(&row.primary));
276 367 out.push_str(close);
@@ -349,7 +440,7 @@
349 440 out.push_str(" aria-current=\"true\"");
350 441 }
351 442 if let Some(action) = &cells.activate {
352 - action_attrs(action, morphs, out);
443 + action_attrs(action, Fires::Click, morphs, out);
353 444 }
354 445 out.push('>');
355 446
@@ -400,6 +491,14 @@
400 491 out.push_str("</p>");
401 492 }
402 493
494 + Node::Rich { source } => {
495 + out.push_str("<div");
496 + class_attr(&["rich"], opts, out);
497 + out.push('>');
498 + out.push_str(&rich_html(source));
499 + out.push_str("</div>");
500 + }
501 +
403 502 Node::Act(act) => act_html(act, morphs, opts, out),
404 503
405 504 Node::Token(tag) => tag_html(tag, morphs, opts, out),
@@ -428,6 +527,8 @@
428 527 out.push_str("</div>");
429 528 }
430 529
530 + Node::Field(field) => field_group_html(field, morphs, opts, out),
531 +
431 532 Node::Form {
432 533 action,
433 534 submit,
@@ -435,17 +536,10 @@
435 536 } => {
436 537 out.push_str("<form");
437 538 class_attr(&["form"], opts, out);
438 - action_attrs(action, morphs, out);
539 + action_attrs(action, Fires::Click, morphs, out);
439 540 out.push('>');
440 541 for field in fields {
441 - // The field's own markup is makeover-webview's, unchanged. A
442 - // second field emitter here is the divergence phase A existed
443 - // to end, and it would be the same anatomy with a different
444 - // escaping story.
445 - let filling = Filling::of(refill(field));
446 - field.with_layout(|borrowed| {
447 - out.push_str(&field_html(&borrowed, &filling, opts));
448 - });
542 + field_group_html(field, morphs, opts, out);
449 543 }
450 544 out.push_str("<button type=\"submit\"");
451 545 class_attr(&["act", "act-submit"], opts, out);
@@ -579,7 +673,7 @@
579 673 out.push_str(" aria-pressed=\"true\"");
580 674 }
581 675 if let Some(action) = action {
582 - action_attrs(action, morphs, out);
676 + action_attrs(action, Fires::Click, morphs, out);
583 677 }
584 678 }
585 679
@@ -641,7 +735,7 @@
641 735 // `Node::SELECTED`, rather than agreed per screen between a
642 736 // renderer and a handler.
643 737 let carrying = action.clone().with(Node::SELECTED, option.value.clone());
644 - action_attrs(&carrying, morphs, out);
738 + action_attrs(&carrying, Fires::Click, morphs, out);
645 739 }
646 740
647 741 out.push('>');
@@ -455,6 +455,73 @@
455 455 assert!(html.contains("<label"));
456 456 }
457 457
458 + #[test]
459 + fn a_control_that_writes_on_change_says_so_without_a_form_around_it() {
460 + // `14612ed8`. goingson reached 13 of these through `dispatch.js`, which
461 + // exists because the description could not say this.
462 + let html = fragment(&Node::Field(
463 + Field::select(
464 + "theme",
465 + "Theme",
466 + vec![Choice::new("dark", "Dark"), Choice::new("light", "Light")],
467 + )
468 + .changes(Action::post("/settings/theme")),
469 + ));
470 +
471 + assert!(html.contains("hx-post=\"/settings/theme\""));
472 + assert!(html.contains("hx-trigger=\"change\""));
473 + // The value is found back rather than assumed, because the control could
474 + // have been any of three elements.
475 + assert!(html.contains("hx-include="));
476 + assert!(html.contains("name=\"theme\""));
477 + // No form and no submit: that is the whole difference from `Node::Form`.
478 + assert!(!html.contains("<form"));
479 + assert!(!html.contains("type=\"submit\""));
480 + }
481 +
482 + #[test]
483 + fn a_field_with_no_route_is_the_plain_group_it_always_was() {
484 + let html = fragment(&Node::Field(Field::new(
485 + layout::FieldKind::Text,
486 + "title",
487 + "Title",
488 + )));
489 + assert!(html.contains("name=\"title\""));
490 + assert!(!html.contains("hx-"));
491 + }
492 +
493 + #[test]
494 + fn a_tick_that_is_the_write_calls_a_route_and_a_tick_that_is_not_does_not() {
495 + // The two cases `Row::selected` could not tell apart. A bulk checkbox is
496 + // client state feeding a later action; a checklist item is the write.
497 + let checklist = fragment(&Node::list(vec![
498 + Row::new("Buy milk").toggling(false, Action::post("/subtasks/1/toggle")),
499 + ]));
500 + assert!(checklist.contains("type=\"checkbox\""));
501 + assert!(checklist.contains("hx-post=\"/subtasks/1/toggle\""));
502 + assert!(checklist.contains("hx-trigger=\"change\""));
503 +
504 + let mut bulk = Row::new("Ada Lovelace");
505 + bulk.selected = Some(false);
506 + let bulk = fragment(&Node::list(vec![bulk]));
507 + assert!(bulk.contains("type=\"checkbox\""));
508 + assert!(!bulk.contains("hx-"));
509 + }
510 +
511 + #[test]
512 + fn rich_text_is_rendered_from_source_and_still_cannot_smuggle_markup() {
513 + // `25822137`. The member is only defensible because what it carries is
514 + // source: the renderer decides what becomes markup, so a script tag in a
515 + // description is no more dangerous here than in `Node::Text`.
516 + let html = fragment(&Node::rich("A **bold** claim\n\n<script>alert(1)</script>"));
517 +
518 + assert!(html.contains("<strong>bold</strong>"));
519 + assert!(!html.contains("<script"));
520 + // The node names itself, so a stylesheet has something to hang typography
521 + // on without the app wrapping it in a region of its own.
522 + assert!(html.contains("class=\"rich\""));
523 + }
524 +
458 525 #[test]
459 526 fn a_refused_form_comes_back_with_what_was_typed_in_it() {
460 527 // `1c4a66a4`. The value the user lost is the whole point of the finding, so