Skip to main content

max / quasi

200.6 KB · 4487 lines History Blame Raw
1 //! Nodes to markup.
2 //!
3 //! Every function here takes a piece of [`quasi_router`]'s screen tree and
4 //! pushes markup onto a buffer. Nothing returns a `Result`: a description that
5 //! exists is renderable by construction, which is the property the owned mirror
6 //! in `quasi-router` was built to have.
7 //!
8 //! # Where the htmx goes in
9 //!
10 //! In exactly one function, [`action_attrs`]. An [`Action`] is a method, a path
11 //! and some params, and turning that into `hx-get` / `hx-post` / `hx-vals` is
12 //! the whole of what "htmx is the transport" means in code. Nothing else in
13 //! this file knows the word htmx, so decision 13's claim that the transport is
14 //! replaceable is a claim about one function rather than about the crate.
15 //!
16 //! `hx-target` is emitted for one case and only one: [`Action::replaces`], set
17 //! when a control calls a route the description layer does not serve. Decision 7
18 //! puts the target on the response, where `quasi-http` sets `HX-Retarget` from
19 //! [`Response::Fragment`](quasi_router::Response::Fragment), because the router
20 //! is the only party that knows what it just changed, and a control that also
21 //! named a target would be a second party deciding one thing. That reasoning
22 //! assumes the responder is described. A plain API route is not, cannot name a
23 //! region, and leaves the answer to land wherever the transport defaults, which
24 //! for htmx is inside the pressed button. See `Action::replaces` for the whole
25 //! of it.
26
27 use std::collections::HashMap;
28 use std::fmt::Write as _;
29
30 use makeover_layout as layout;
31 // `Tone::token` is the trait method, and `data-tone` is spelled from it rather
32 // than from a match here, so a tone added upstream cannot be named two ways.
33 use makeover_layout::Intent as _;
34 use makeover_webview::chart::chart_html_into;
35 use makeover_webview::figure::figure_html_into;
36 use makeover_webview::form::{Filling, Markup, Value, escape_into, field_html_into};
37 // `class`, `option_class` and the two part-class mappings below are makeover's,
38 // not copies of it. They were copies until makeover-webview 0.27.0 made them
39 // public: the prefix helper was byte-identical, and the row and cell part names
40 // were a second spelling of a list whose own doc comment carries an obligation
41 // to be grepped on upgrade. A second spelling is a second place to forget, and
42 // the selector names had already drifted.
43 use makeover_webview::{Emit, class, fallback_class, option_class};
44 // `Cell` is a name both crates use: makeover's is the emitted table cell, ours
45 // is the described one. Aliased rather than qualified at the call site, so the
46 // two never read as the same type.
47 use makeover_webview::list::{
48 Cell as Emitted, cell_part_class, cells_html_into, flow_class, part_class, push_column_classes,
49 };
50 use makeover_webview::meter::meter_html_into;
51 use makeover_webview::placeholder::placeholder_html_into;
52 use quasi_router::screen::{
53 Act, Bar, Cell, Clock, Destination, Field, Node, Repeat, Rest, Row, Slot, Tag,
54 };
55 use quasi_router::{Action, Candidate, Method, Params, Replaces, Richness, Trust};
56
57 /// The facts about the document being written that no [`Node`] carries.
58 ///
59 /// Threaded beside [`Emit`] rather than folded into it: `Emit` is the host's
60 /// standing configuration and is the same for every answer, while this is about
61 /// the one answer being written. Two members and both of them are the screen's:
62 /// what a bespoke region was handed, and where the caret starts.
63 ///
64 /// A struct rather than two more parameters. The walk is deep and every level
65 /// passes it along untouched, so the third document-level fact would otherwise
66 /// be a third edit to every signature between here and the leaf.
67 pub(crate) struct Doc<'a> {
68 /// The markup a host drew for a region the description ceded, by
69 /// [`Slot::id`](quasi_router::Slot).
70 pub(crate) fills: &'a HashMap<String, String>,
71 /// The [`Field::name`] the caret starts in, when this answer is a whole
72 /// document and the screen named one.
73 ///
74 /// `None` on every fragment, which is the whole of the accessibility
75 /// argument: a swap that moves the caret takes it out of whatever the
76 /// reader was typing into, and a browser answers most interactions with a
77 /// fragment. See [`Screen::opens_at`](quasi_router::Screen::opens_at).
78 pub(crate) opens_at: Option<&'a str>,
79 }
80
81 /// The empty fill map, for the answers that cede no region.
82 ///
83 /// One allocation for the process rather than one per call. Every site reaching
84 /// for it is on a path that cannot hold a bespoke region at all, so the map is
85 /// empty by construction rather than by luck.
86 static NO_FILLS: std::sync::LazyLock<HashMap<String, String>> =
87 std::sync::LazyLock::new(HashMap::new);
88
89 impl Doc<'static> {
90 /// No fills and no caret, for the fragments and the run-level walks.
91 pub(crate) fn bare() -> Self {
92 Self {
93 fills: &NO_FILLS,
94 opens_at: None,
95 }
96 }
97 }
98
99 impl<'a> Doc<'a> {
100 /// What a host drew, and no caret.
101 ///
102 /// Everything but a whole document: a fragment, an overlay, the chrome and
103 /// the frame. See [`opens_at`](Self::opens_at).
104 pub(crate) fn caretless(fills: &'a HashMap<String, String>) -> Self {
105 Self {
106 fills,
107 opens_at: None,
108 }
109 }
110
111 /// What a host drew, and where the caret starts.
112 pub(crate) fn opening(fills: &'a HashMap<String, String>, opens_at: Option<&'a str>) -> Self {
113 Self { fills, opens_at }
114 }
115
116 /// Whether this field is the one the caret starts in.
117 fn opens(&self, name: &str) -> bool {
118 self.opens_at == Some(name)
119 }
120 }
121
122 /// Write one prefixed class name onto a buffer the caller already has.
123 ///
124 /// The two halves are escaped separately rather than joined and escaped once.
125 /// That is the same bytes, since escaping is per character and has no context to
126 /// carry across the seam, and none of the allocations.
127 ///
128 /// Escaped at all because a prefix is host configuration reaching an attribute
129 /// value. It is a `&'static str` and every real one is identity under this, so
130 /// the cost is a scan; what it buys is that the one string here that did not
131 /// come from this crate cannot end the attribute.
132 pub(crate) fn class_into(name: &str, opts: &Emit, out: &mut String) {
133 escape_into(opts.class_prefix, out);
134 escape_into(name, out);
135 }
136
137 /// Write a `class="..."` attribute, prefixed.
138 fn class_attr(names: &[&str], opts: &Emit, out: &mut String) {
139 out.push_str(" class=\"");
140 for (i, name) in names.iter().enumerate() {
141 if i > 0 {
142 out.push(' ');
143 }
144 class_into(name, opts, out);
145 }
146 out.push('"');
147 }
148
149 /// Write the attribute naming a tone, if the tone is worth naming.
150 ///
151 /// [`Tone::Neutral`] writes nothing: ordinary content is the default, and an
152 /// attribute meaning "nothing unusual" is an attribute on every element in the
153 /// document.
154 ///
155 /// An attribute and not a class, which is the correction. This emitted
156 /// `tone-info`, `tone-success`, `tone-warning` and `tone-danger` as classes, and
157 /// makeover has never defined one of them: its whole vocabulary keys tone off
158 /// `data-tone`, from `.badge[data-tone="danger"]` to the progress fill to a
159 /// figure's value. So every toned thing a description produced arrived with a
160 /// class no stylesheet in the tree had heard of, which is why the SSH-keys tab's
161 /// Remove button came out the same colour as everything else.
162 fn tone_attr(tone: layout::Tone, out: &mut String) {
163 if matches!(tone, layout::Tone::Neutral) {
164 return;
165 }
166 out.push_str(" data-tone=\"");
167 out.push_str(tone.token());
168 out.push('"');
169 }
170
171 /// How much of its row a control asked for, where it is not the default.
172 ///
173 /// `tone_attr`'s shape and its reason. An attribute rather than a class because
174 /// this is a fact the description carried, not a styling hook this renderer
175 /// invented -- the same division `data-selector` and `data-tone` are on the
176 /// right side of. `Fill` is the default and is what a control did before the
177 /// member existed, so saying it would be a hook that changes nothing.
178 fn width_attr(width: layout::Width, out: &mut String) {
179 let said = match width {
180 layout::Width::Content => "content",
181 layout::Width::Fixed => "fixed",
182 // `Fill` is the default, and a member added upstream since this
183 // renderer learned the vocabulary is one it does not draw.
184 _ => return,
185 };
186 out.push_str(" data-width=\"");
187 out.push_str(said);
188 out.push('"');
189 }
190
191 /// How a picture sits in its box, where it is not the default.
192 ///
193 /// `tone_attr`'s shape and for its reason: `Natural` is what an `<img>` does
194 /// with no rule at all, so saying it would be a stylesheet hook that changes
195 /// nothing. The two that need a rule get one.
196 fn fit_attr(fit: layout::Fit, out: &mut String) {
197 let value = match fit {
198 layout::Fit::Natural => return,
199 layout::Fit::Cover => "cover",
200 layout::Fit::Contain => "contain",
201 // `Fit` is `#[non_exhaustive]`, so a member added upstream lands here
202 // rather than failing the build. Drawing it as natural is the safe
203 // read: the picture is whole and its own shape, which is wrong about
204 // the box and never wrong about the content.
205 _ => return,
206 };
207 out.push_str(" data-fit=\"");
208 out.push_str(value);
209 out.push('"');
210 }
211
212 /// What goes back in the box, for a form being offered again after a refusal.
213 ///
214 /// The description carries the value as a string, because that is what came
215 /// off the wire; the kind is what says how to read it. A checkbox is carried
216 /// by presence the way HTML submits one, so any value means ticked and nothing
217 /// means not.
218 ///
219 /// A [`layout::FieldKind::Secret`] is emitted empty whatever it holds. That is
220 /// the third refusal of the same thing and none of the three is redundant:
221 /// `Field::value` will not store one, `makeover_webview::form` will not write
222 /// one into an `<input type="password">`, and this one stands between them
223 /// because `Field::value` is a public field that a struct literal reaches past.
224 fn refill(field: &Field) -> Value<'_> {
225 if field.kind == layout::FieldKind::Secret {
226 return Value::Absent;
227 }
228 // An interval came back under two names, so it goes back into two boxes.
229 // Either end absent is an answer rather than a half-filled form -- "over
230 // 120 BPM" -- so this is not gated on both being present.
231 if field.kind == layout::FieldKind::Interval {
232 return Value::Between {
233 lower: field.value.as_deref().unwrap_or_default(),
234 upper: field.upper_value.as_deref().unwrap_or_default(),
235 };
236 }
237 match field.value.as_deref() {
238 None => Value::Absent,
239 Some(_) if field.kind == layout::FieldKind::Checkbox => Value::On(true),
240 Some(value) => Value::Text(value),
241 }
242 }
243
244 /// Whether the reader reaches this control's value by typing characters into
245 /// it.
246 ///
247 /// What `keyup` can hear, which is the question the two consult triggers turn
248 /// on: a `<select>`, a radio, a checkbox, a slider and a date picker all
249 /// change without a key ever being released over them, so a question hung on
250 /// `keyup` is one they never ask.
251 ///
252 /// Not [`layout::FieldKind::offers_options`] and its two companions, which the
253 /// three renderers already share for a different question -- whether a value is
254 /// complete the moment it changes. The sets nearly agree and the reasons do
255 /// not: a range is built up by dragging, so it is incomplete on the way and is
256 /// still not typed into.
257 ///
258 /// `false` for a kind this renderer has not met. [`layout::FieldKind`] is
259 /// `#[non_exhaustive]` and `input` is the wider event of the two -- it fires
260 /// for typing as well -- so an unknown kind is heard rather than missed.
261 const fn typed_into(kind: layout::FieldKind) -> bool {
262 matches!(
263 kind,
264 layout::FieldKind::Text
265 | layout::FieldKind::Secret
266 | layout::FieldKind::Number
267 | layout::FieldKind::Interval
268 | layout::FieldKind::Email
269 | layout::FieldKind::Url
270 | layout::FieldKind::Tel
271 | layout::FieldKind::Textarea
272 | layout::FieldKind::Rich
273 )
274 }
275
276 /// How a field's question is raised: on the keystroke, or on the value moving.
277 ///
278 /// [`Field::consults`] and [`Slot::consults`] are one idea at two scopes, and
279 /// only the region's heard a discrete control -- because a region's dials were
280 /// never all typed into and a field's were assumed to be. They are not: a
281 /// folder select that re-asks for a list of mail is the same question a search
282 /// box asks, and it is the majority shape rather than the exception.
283 ///
284 /// The event is this renderer's and the wait is the description's, which is why
285 /// only the first half is decided here. A discrete control's description says
286 /// [`Consult::after`] of zero, which is a real number for it rather than a
287 /// meaningless one -- [`Slot::consults`] already applies its wait to a radio
288 /// group deliberately.
289 ///
290 /// [`Field::consults`]: quasi_router::Field::consults
291 /// [`Slot::consults`]: quasi_router::Slot::consults
292 /// [`Consult::after`]: quasi_router::Consult::after
293 fn asking(kind: layout::FieldKind, consult: &quasi_router::Consult) -> Fires<'static> {
294 let after = consult.after;
295 let at_least = consult.at_least;
296 if typed_into(kind) {
297 Fires::Typing { after, at_least }
298 } else {
299 Fires::Working { after, at_least }
300 }
301 }
302
303 /// One field, with whatever `1c4a66a4` and `14612ed8` added around it.
304 ///
305 /// The field's own markup is makeover-webview's, unchanged. A second field
306 /// emitter here is the divergence phase A existed to end, and it would be the
307 /// same anatomy with a different escaping story.
308 ///
309 /// A [`Field::writes`] is a wrapper rather than attributes on the control,
310 /// because the control is emitted by makeover-webview and there is no seam to
311 /// put them through. That turns out to be the better shape anyway: the `change`
312 /// event bubbles, so one element around the group catches it whichever of the
313 /// input, select or textarea forms the field took, and `hx-include` finds the
314 /// control back without this having to know which it was.
315 fn field_group_html(field: &Field, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
316 // A question that only sometimes applies is drawn inside a box carrying the
317 // condition, so the reveal script settles it exactly as it settles a
318 // region, and the control stays inside the form and still submits.
319 // `8fdb814c`.
320 if let Some(reveal) = &field.revealed_by {
321 out.push_str("<div");
322 class_attr(&["field-reveal"], opts, out);
323 reveal_attrs(reveal, out);
324 out.push('>');
325 let mut inner = Field::clone(field);
326 inner.revealed_by = None;
327 field_group_html(&inner, opts, doc, out);
328 out.push_str("</div>");
329 return;
330 }
331
332 // A question answered N times is N slots and two controls, and each slot is
333 // an ordinary field of this same emitter. `60d1753c`.
334 if let Some(repeat) = &field.repeats {
335 repeat_html(field, repeat, opts, doc, out);
336 return;
337 }
338 let mut filling = Filling::of(refill(field));
339 let writes = field.writes.as_ref();
340
341 // A field that owns a suggestion list is a combobox, and all three parts of
342 // one hang off the field's own name: the box's id is the name, the list's
343 // id is `suggestions_id` of it, and nothing is authored anywhere. That is
344 // what ownership buys over two elements pointed at each other by hand.
345 //
346 // Gated on the name being a handle, for `hyperscript::handle`'s reason: the
347 // programs below address both elements by id inside a selector inside an
348 // attribute. A name that is not a plain handle draws the field it drew
349 // before this existed, which is a fallback rather than a break.
350 let suggests = field
351 .suggests
352 .as_ref()
353 .zip(crate::hyperscript::handle(&field.name));
354 let wiring = suggests.map(|(consult, name)| {
355 let list = suggestions_id(name);
356 (consult, name.to_owned(), combobox_attrs(&list), list)
357 });
358 let listbox = wiring
359 .as_ref()
360 .map(|(_, _, _, list)| listbox_html(field, list, opts));
361 if let Some(markup) = listbox.as_ref() {
362 filling.trailing = Some(Markup(markup));
363 }
364
365 // Everything this renderer knows about the control element that no
366 // description layer carries, gathered before it is handed over: there is
367 // one `control_attrs` slot and two things that want it. `a135f898` is the
368 // second, and it arrived after the combobox had taken the slot.
369 let mut control_attrs = String::new();
370 if let Some((_, _, attrs, _)) = wiring.as_ref() {
371 control_attrs.push_str(attrs);
372 }
373 // What is in this box is the reader's, so a swap that redraws it must not
374 // take it away. `hx-preserve` is htmx's word for exactly that, and it works
375 // off the `id` makeover already writes from the field's name.
376 //
377 // The one host where this has to be said: a terminal keeps its own buffer
378 // and egui keeps widget state by id, so both were already right, and a
379 // browser replaces the element and takes what was typed with it.
380 if field.keeps_value {
381 if !control_attrs.is_empty() {
382 control_attrs.push(' ');
383 }
384 preserve_attr(&mut control_attrs);
385 }
386 // Where the caret starts, when the screen said so and this answer is a
387 // whole document. `Doc::opens_at` is `None` on every fragment, which is the
388 // whole of the guard: a swap that moves the caret takes it out of whatever
389 // the reader was typing into, and a browser answers most interactions with
390 // a fragment.
391 //
392 // Nothing counts the emissions. Two fields under one name is an app
393 // disagreeing with itself, and HTML already rules on it -- the first
394 // `autofocus` in tree order is the one a browser honours -- so a counter
395 // here would only be a second, quieter answer to a question the platform
396 // has settled.
397 if doc.opens(&field.name) {
398 if !control_attrs.is_empty() {
399 control_attrs.push(' ');
400 }
401 control_attrs.push_str("autofocus");
402 }
403 if !control_attrs.is_empty() {
404 filling.control_attrs = Some(Markup(&control_attrs));
405 }
406
407 // Outside the consult wrappers below, for their own reason one turn further
408 // out: htmx takes one verb and one address per element, and the question a
409 // field owns is a different question from the ones it merely asks.
410 if let Some((consult, _, _, list)) = wiring.as_ref() {
411 out.push_str("<div");
412 class_attr(&["field-suggests"], opts, out);
413 let sends = sends_selector(&consult.sends);
414 // The answer lands in the list the field owns, always. An
415 // `Action::replacing` on a suggestion source is a description arguing
416 // with itself -- it says the candidates go somewhere other than the
417 // control they are candidates for -- so it is overwritten here rather
418 // than honoured, and the combobox wiring cannot come apart from the
419 // element it names.
420 let mut action = consult.action.clone();
421 action.replaces = Some(Replaces::Region(list.clone()));
422 action_attrs(
423 &action,
424 asking(field.kind, consult),
425 None,
426 sends.as_deref(),
427 false,
428 out,
429 );
430 out.push('>');
431 }
432
433 // A consult is a second wrapper outside the first rather than more
434 // attributes on it. Two routes cannot share one element: htmx takes one
435 // verb and one address per element, and a field that both writes when it
436 // settles and asks about itself while it is being typed names two. Outside
437 // rather than inside so the width and the tick-gathering stay on the
438 // element that already carries them.
439 //
440 // One wrapper each where there are several. A box asking two routes at two
441 // rates is MNW's discover search, and the two questions cannot share an
442 // element for the reason a consult cannot share one with a write.
443 for consult in &field.consults {
444 out.push_str("<div");
445 class_attr(&["field-consults"], opts, out);
446 // What rides along, named by field name and turned into a selector
447 // here. The names are the description's — they are what a submit sends
448 // each value under — and reducing them to a document selector is this
449 // renderer's job, which is the whole reason the vocabulary does not
450 // carry the `.discover-filter` class the shipped markup groups by.
451 let sends = sends_selector(&consult.sends);
452 action_attrs(
453 &consult.action,
454 asking(field.kind, consult),
455 None,
456 sends.as_deref(),
457 false,
458 out,
459 );
460 out.push('>');
461 }
462
463 // The width sits on the wrapper, because what `field_html_into` emits is
464 // makeover's and this is quasi's fact about it. A field that writes already
465 // has a wrapper for the same reason; one that only asks for room gets the
466 // same box with nothing else on it.
467 let asked = !matches!(field.width, layout::Width::Fill);
468 if writes.is_none() && asked {
469 out.push_str("<div");
470 class_attr(&["field-writes"], opts, out);
471 width_attr(field.width, out);
472 out.push('>');
473 }
474
475 if let Some(action) = writes {
476 out.push_str("<div");
477 class_attr(&["field-writes"], opts, out);
478 width_attr(field.width, out);
479 action_attrs(action, Fires::ChangeInside, None, None, false, out);
480 out.push('>');
481 }
482
483 // A field's members are its options: `option` places one, and `chosen`
484 // settles the one it places. Told where each landed rather than looking for
485 // it, since two options with the same label are the same bytes.
486 let mut marked = crate::stage::Cursor::open(&field.marks);
487 if marked.watching() {
488 let mut placed = Vec::new();
489 field.with_layout(|borrowed| {
490 makeover_webview::form::field_html_placed(&borrowed, &filling, opts, out, &mut placed);
491 });
492 for block in &placed {
493 marked.wrote(block.start, block.end);
494 }
495 marked.close(&field.marks);
496 } else {
497 field.with_layout(|borrowed| {
498 field_html_into(&borrowed, &filling, opts, out);
499 });
500 }
501
502 if writes.is_some() || asked {
503 out.push_str("</div>");
504 }
505 for _ in &field.consults {
506 out.push_str("</div>");
507 }
508 if wiring.is_some() {
509 out.push_str("</div>");
510 }
511 }
512
513 /// A question answered zero or more times: the slots, and the controls that add
514 /// and remove one.
515 ///
516 /// A `fieldset` because that is what a group of controls answering one
517 /// question is in HTML, and the legend is the question. Each slot is
518 /// [`Field::instance`] put back through [`field_group_html`], so a slot is
519 /// drawn by everything this renderer already does to a field rather than by a
520 /// second emitter.
521 ///
522 /// # What the reader does here costs no round trip
523 ///
524 /// Adding a slot clones the blank one out of the `template` and removing one
525 /// takes the slot out of the document, both in `repeat.js`. A description that
526 /// had to ask a route for another empty box would re-render a form the reader
527 /// is midway through, which is `a135f898` and is the same objection that put
528 /// the reveal condition in a script.
529 ///
530 /// The hooks the script reads are `data-` attributes rather than classes, for
531 /// `data-reveal`'s reason: a class is prefixed by host configuration and a
532 /// script shipped by this crate cannot know the prefix.
533 ///
534 /// # A page served without the script
535 ///
536 /// Every slot the description offered is there, fillable and submittable, and
537 /// the two controls do nothing. That is the direction the rest of this renderer
538 /// degrades in: what the handler said, without what the reader could have added
539 /// to it.
540 fn repeat_html(field: &Field, repeat: &Repeat, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
541 let standing = repeat.standing();
542 out.push_str("<fieldset");
543 class_attr(&["field-repeat"], opts, out);
544 out.push_str(" data-repeat=\"");
545 escape_into(&field.name, out);
546 // The question's own label, so the script can renumber the slots it
547 // renames. `Repeat::ordinal` is what wrote them in the first place, and the
548 // two spellings are the same string in the same order.
549 out.push_str("\" data-repeat-label=\"");
550 escape_into(&field.label, out);
551 let _ = write!(out, "\" data-repeat-least=\"{}\"", repeat.least);
552 if let Some(from) = repeat.add.from() {
553 out.push_str(" data-repeat-add-from=\"");
554 escape_into(from, out);
555 out.push('"');
556 }
557 if let Some(most) = repeat.most {
558 let _ = write!(out, " data-repeat-most=\"{most}\"");
559 }
560 out.push('>');
561
562 out.push_str("<legend");
563 class_attr(&["field-repeat-legend"], opts, out);
564 out.push('>');
565 escape_into(&field.label, out);
566 out.push_str("</legend>");
567
568 // What is wrong with the *set*, which is the fact a per-slot message cannot
569 // carry: "at most eight reminders" is about none of them in particular. A
570 // slot's own error rides on the slot, through `Field::error` as every other
571 // field's does.
572 if let Some(error) = &field.error {
573 out.push_str("<div");
574 class_attr(&["form-error"], opts, out);
575 out.push_str(" role=\"alert\">");
576 escape_into(error, out);
577 out.push_str("</div>");
578 }
579
580 out.push_str("<div");
581 class_attr(&["field-repeat-slots"], opts, out);
582 // The hook the script appends into. A container of its own rather than the
583 // fieldset, so a slot cloned into a group with none standing lands above
584 // the add control rather than under it.
585 out.push_str(" data-repeat-slots>");
586 for at in 0..standing {
587 repeat_slot_html(field, repeat, at, standing, opts, doc, out);
588 }
589 out.push_str("</div>");
590
591 // The blank the add control clones. Inert markup until a script takes it
592 // out, which is what a `template` is for, and it carries the next index so
593 // that pressing add once with no script running is the only thing that ever
594 // needed renumbering.
595 out.push_str("<template");
596 class_attr(&["field-repeat-blank"], opts, out);
597 out.push('>');
598 // The question itself, at an index past the last standing slot. `instance`
599 // and `instance_part` both answer blank there, which is what the template
600 // needs, and a grouped question keeps its parts this way where a cleared
601 // `repeats` would have left the template with no questions to draw.
602 repeat_slot_html(field, repeat, standing, standing + 1, opts, doc, out);
603 out.push_str("</template>");
604
605 // A question whose slots come from another control offers no add control
606 // of its own: there is no blank a reader could fill. The script is told
607 // which control makes them, on the group, so it needs no second address.
608 if let Some(label) = repeat.add.label() {
609 out.push_str("<button type=\"button\"");
610 class_attr(&["button", "field-repeat-add"], opts, out);
611 out.push_str(" data-repeat-add");
612 if !repeat.more(standing) {
613 out.push_str(" disabled");
614 }
615 out.push('>');
616 escape_into(label, out);
617 out.push_str("</button>");
618 }
619 out.push_str("</fieldset>");
620 }
621
622 /// One slot: the question under its indexed name, and the control that takes it
623 /// away.
624 ///
625 /// `standing` is how many slots there are, which is what decides whether this
626 /// one may be removed. The script recomputes the same answer after every change
627 /// from `data-repeat-least`, so the first paint and every one after it agree.
628 fn repeat_slot_html(
629 field: &Field,
630 repeat: &Repeat,
631 at: usize,
632 standing: usize,
633 opts: &Emit,
634 doc: &Doc<'_>,
635 out: &mut String,
636 ) {
637 let slot = repeat.instances.get(at);
638 let progress = slot.map_or(&quasi_router::Progress::Idle, |slot| &slot.progress);
639 out.push_str("<div");
640 class_attr(&["field-repeat-slot"], opts, out);
641 let _ = write!(out, " data-repeat-at=\"{at}\"");
642 // The slot is named by what is in it rather than by where it is, so the
643 // script must not rewrite the ordinal over the top of it.
644 if slot.is_some_and(|slot| slot.named.is_some()) {
645 out.push_str(" data-repeat-named");
646 }
647 // Stated rather than inferred from the markup inside, so the script and the
648 // stylesheet read the slot's state from one place and a slot mid-flight is
649 // the same string on every host.
650 match progress {
651 quasi_router::Progress::Idle => {}
652 quasi_router::Progress::Working(_) => out.push_str(" data-repeat-progress=\"working\""),
653 quasi_router::Progress::Done => out.push_str(" data-repeat-progress=\"done\""),
654 quasi_router::Progress::Failed => out.push_str(" data-repeat-progress=\"failed\""),
655 }
656 out.push('>');
657 for slot in field.instance_fields(at) {
658 field_group_html(&slot, opts, doc, out);
659 }
660 // What is wrong with the slot as a whole, which a part's own error cannot
661 // carry: the upload that failed, not the name that was too long.
662 if let Some(error) = slot.and_then(|slot| slot.error.as_deref()) {
663 out.push_str("<div");
664 class_attr(&["form-error"], opts, out);
665 out.push_str(" role=\"alert\">");
666 escape_into(error, out);
667 out.push_str("</div>");
668 }
669 // makeover's own meter, not a second spelling of one: how far a slot has
670 // got is the proportion this renderer already knows how to draw.
671 if let quasi_router::Progress::Working(Some(meter)) = progress {
672 meter_html_into(&meter.as_layout(), opts, out);
673 }
674 out.push_str("<button type=\"button\"");
675 class_attr(&["button", "field-repeat-remove"], opts, out);
676 out.push_str(" data-repeat-remove");
677 // A slot the work has not finished with is one the reader may not pull out
678 // from under it, which is the floor's reason one state further along.
679 if !repeat.fewer(standing) || progress.busy() {
680 out.push_str(" disabled");
681 }
682 out.push('>');
683 escape_into(&repeat.remove, out);
684 out.push_str("</button></div>");
685 }
686
687 /// The document id of the list a field owns, from the field's own name.
688 ///
689 /// Derived and never authored, which is the difference between a list a field
690 /// owns and two elements a description has to keep pointing at each other. The
691 /// box's own id is the name, so the two cannot collide.
692 ///
693 /// The name is a [`handle`](crate::hyperscript::handle) at every call site, so
694 /// the result is one too and can be addressed from a program.
695 pub(crate) fn suggestions_id(name: &str) -> String {
696 format!("{name}-suggestions")
697 }
698
699 /// The id of the popover container belonging to an anchorable thing.
700 ///
701 /// [`suggestions_id`]'s sibling and derived the same way: a container the
702 /// description never names, minted from the name the description does carry,
703 /// so nothing has to point two elements at each other.
704 ///
705 /// The handle is a region's [`Slot::id`](quasi_router::Slot::id) or a control's
706 /// [`Act::id`](quasi_router::Act::id). A screen's selection has neither and
707 /// needs neither -- see [`SELECTION_ANCHOR_ID`].
708 pub(crate) fn anchored_id(handle: &str) -> String {
709 format!("{handle}-anchored")
710 }
711
712 /// The id of the popover container for the screen's selection.
713 ///
714 /// Fixed rather than derived, because a screen holds one selection
715 /// ([`Screen::selection`](quasi_router::Screen::selection)) and
716 /// [`Anchor::Selection`](quasi_router::Anchor::Selection) therefore carries no
717 /// name to derive one from. It becomes derived on the day that field becomes a
718 /// map, which is the same day [`Act::over`](quasi_router::Act::over) starts
719 /// choosing between sets.
720 pub(crate) const SELECTION_ANCHOR_ID: &str = "quasi-selection-anchored";
721
722 /// An empty container for an anchored screen to land in.
723 ///
724 /// Emitted beside the thing it belongs to and left empty until a route answers
725 /// with an [`Outcome::Anchored`](quasi_router::Outcome::Anchored), which is
726 /// exactly how a row's menu is emitted: a menu is a container the host opens its
727 /// own way, and this is that container for the things that are not rows.
728 ///
729 /// `hidden` for the same reason a row's menu carries it. An empty container is
730 /// invisible either way; saying so is what keeps it out of the accessible tree
731 /// until there is something in it.
732 pub(crate) fn anchor_container_html(id: &str, opts: &Emit, out: &mut String) {
733 out.push_str("<div");
734 class_attr(&["anchored"], opts, out);
735 out.push_str(" id=\"");
736 escape_into(id, out);
737 // Named rather than found by class, so a host's own menu code binds to
738 // something a `class_prefix` cannot move. `data-menu="anchored"` and not
739 // `"row"`: both are menus and only one of them belongs to a row.
740 out.push_str("\" data-menu=\"anchored\" hidden></div>");
741 }
742
743 /// The combobox attributes that go on the control itself.
744 ///
745 /// Through `makeover-webview`'s `Filling::control_attrs`, which exists for this:
746 /// what makes a box a combobox is the list it owns, and no description layer
747 /// carries one. `aria-expanded` starts false because a field is drawn before it
748 /// has been typed into, and the list's own program is what moves it.
749 ///
750 /// `autocomplete="off"` because the browser's own list would sit on top of this
751 /// one, offering what it remembers being typed here instead of what the route
752 /// just answered.
753 fn combobox_attrs(list: &str) -> String {
754 let mut attrs = format!(
755 "role=\"combobox\" aria-autocomplete=\"list\" aria-expanded=\"false\" \
756 aria-controls=\"{list}\" autocomplete=\"off\""
757 );
758 crate::hyperscript::combobox(list, &mut attrs);
759 attrs
760 }
761
762 /// The list a field owns, empty until the route answers.
763 ///
764 /// Emitted with the field rather than by the answer, so the box has something
765 /// to point `aria-controls` at from the first draw. A reader told about a list
766 /// that is not in the document yet is told about nothing.
767 fn listbox_html(field: &Field, list: &str, opts: &Emit) -> String {
768 let mut out = String::new();
769 out.push_str("<div");
770 class_attr(&["form-suggestions"], opts, &mut out);
771 out.push_str(" id=\"");
772 escape_into(list, &mut out);
773 // Named by the field it belongs to. A listbox with no accessible name is
774 // announced as a list of nothing in particular, and the box's own label is
775 // what a reader has just heard.
776 out.push_str("\" role=\"listbox\" aria-label=\"");
777 escape_into(&field.label, &mut out);
778 out.push('"');
779 crate::hyperscript::suggestion_list(&field.name, list, &mut out);
780 out.push_str("></div>");
781 out
782 }
783
784 /// One candidate, as the answer to the question a field owns.
785 ///
786 /// [`Outcome::Suggestions`](quasi_router::Outcome::Suggestions) rendered: the
787 /// inside of the list, and nothing else. What each entry carries is the pair
788 /// [`Choice`] carries — the value is what the box is set to and the label is
789 /// what is read — plus its index, which is how the keyboard finds the next one
790 /// without counting the ones before it.
791 ///
792 /// A candidate that cannot be picked is announced and not offered:
793 /// `aria-disabled` says so, the reason is drawn beside it, and it carries
794 /// neither a value nor a program, so neither Enter nor a click can take it.
795 pub(crate) fn suggestions_html(name: &str, options: &[Candidate], opts: &Emit) -> String {
796 let Some(name) = crate::hyperscript::handle(name) else {
797 // The field this answers drew no list, for the same gate's reason, so
798 // there is nothing here to put candidates into.
799 return String::new();
800 };
801 let list = suggestions_id(name);
802 let mut out = String::new();
803 for (at, choice) in options.iter().enumerate() {
804 // A row whose pick replaces the whole document is an anchor, because an
805 // anchor is what a browser navigates. `action_attrs` writes the `href`
806 // and no transport for such a pick (`00ee7af5`), and an `href` on a
807 // `div` is an attribute nothing reads: the row would close the list and
808 // stay where it was. Every other row is a `div`, since what picking it
809 // does is htmx's or the box's and neither wants a link.
810 let (open, close) = match &choice.picks {
811 Some(action) if action.navigates => ("<a", "</a>"),
812 _ => ("<div", "</div>"),
813 };
814 out.push_str(open);
815 class_attr(&["form-suggestion"], opts, &mut out);
816 // `aria-selected="false"` on every entry from the first draw, so the
817 // keyboard's program moves a value that is already there rather than
818 // introducing an attribute halfway through a list.
819 let _ = write!(
820 out,
821 " id=\"{list}-{at}\" data-at=\"{at}\" role=\"option\" aria-selected=\"false\""
822 );
823 // What picking does. `ed1fa86f`: local by default, so a candidate that
824 // says nothing writes its value into the box exactly as before. One
825 // that carries an action has htmx call it, and nothing is written --
826 // the search box's case, where a pick navigates and the typed value is
827 // discarded.
828 match &choice.picks {
829 Some(action) => {
830 action_attrs(action, Fires::Click, None, None, false, &mut out);
831 crate::hyperscript::suggestion_acting(name, &list, &mut out);
832 }
833 None => {
834 out.push_str(" data-value=\"");
835 escape_into(&choice.value, &mut out);
836 out.push('"');
837 crate::hyperscript::suggestion(name, &list, &mut out);
838 }
839 }
840 out.push('>');
841 escape_into(&choice.label, &mut out);
842 // The second line, and the whole of why a candidate is not a `Choice`
843 // (`1fcf2e9b`). Its own element rather than folded into the label: it
844 // is styled apart, and it is not part of what the typed value matches.
845 if let Some(detail) = &choice.detail {
846 out.push_str("<span");
847 class_attr(&["form-suggestion-detail"], opts, &mut out);
848 out.push('>');
849 escape_into(detail, &mut out);
850 out.push_str("</span>");
851 }
852 out.push_str(close);
853 }
854 out
855 }
856
857 /// The controls a [`Consult::sends`] names, as one selector.
858 ///
859 /// By `name`, which is what the description carries and what the route reads
860 /// each value back under. A field named here that is not on the screen
861 /// contributes a selector that matches nothing, which is the miss handled the
862 /// way every other address miss is: visible, and not worth refusing to draw a
863 /// screen over.
864 ///
865 /// `None` for the common field, which sends its own value and nothing else.
866 ///
867 /// [`Consult::sends`]: quasi_router::Consult::sends
868 fn sends_selector(names: &[String]) -> Option<String> {
869 if names.is_empty() {
870 return None;
871 }
872 let mut out = String::new();
873 for name in names {
874 if !out.is_empty() {
875 out.push_str(", ");
876 }
877 out.push_str("[name='");
878 // The selector is written into an attribute, so the quoting has to
879 // survive both. A name carrying an apostrophe would otherwise end the
880 // selector's string early and change what is gathered.
881 out.push_str(&name.replace('\\', "\\\\").replace('\'', "\\'"));
882 out.push_str("']");
883 }
884 Some(out)
885 }
886
887 /// Markdown source into markup, for [`Node::Rich`], composed from the node's
888 /// two axes rather than picked from a preset.
889 ///
890 /// docengine's presets are named points on a diagonal: `strict` turns tables,
891 /// footnotes and task lists **off** at the same time as it turns `nofollow`,
892 /// raw-HTML stripping and scheme filtering **on**. Those are two questions, and
893 /// reading them off one name is what made a page nofollow its own links -- the
894 /// only way to have prose with a followed link in it was to ask for the
895 /// permissive treatment of raw HTML as well.
896 ///
897 /// So this composes `docengine::Renderer` from [`Richness`] and [`Trust`]
898 /// independently. The four cells all mean something:
899 ///
900 /// ```text
901 /// Sentence + Untrusted a forum post (was `strict`)
902 /// Sentence + Trusted a page's own sentence (new, and the fix)
903 /// Document + Untrusted a creator's long description (no preset had this)
904 /// Document + Trusted the app's own long-form (was `permissive`)
905 /// ```
906 ///
907 /// **`Sentence + Untrusted` is `Renderer::strict()` field for field**, deliberately:
908 /// it is what `Node::rich` meant before the axes existed, and every call site
909 /// written against the old member keeps exactly what it had.
910 ///
911 /// Unconditional. This sat behind a default-on `rich` feature until markdown was
912 /// made standard, and turning the feature off did not remove a cost so much as
913 /// produce a renderer that draws `**bold**` at the user. Markdown is what these
914 /// descriptions are made of, so rendering it is part of being a renderer rather
915 /// than an extra somebody opts into.
916 fn rich_html(source: &str, richness: Richness, trust: Trust) -> String {
917 let full = matches!(richness, Richness::Document);
918 let untrusted = matches!(trust, Trust::Untrusted);
919
920 docengine::Renderer::permissive()
921 // Richness: what the format may express.
922 .with_tables(full)
923 .with_footnotes(full)
924 .with_tasklists(full)
925 .with_strikethrough(full)
926 .with_smart_punctuation(full)
927 .with_strip_images(!full)
928 // Strictness: what the app is willing to take from this author. Raw
929 // markup and fetchable schemes are the author's reach into the
930 // document, so both turn on exactly when the author is a stranger.
931 .with_strip_raw_html(untrusted)
932 .with_dangerous_scheme_filter(untrusted)
933 .with_sanitize(if untrusted {
934 docengine::SanitizePreset::Strict
935 } else {
936 docengine::SanitizePreset::Permissive
937 })
938 .render(source)
939 }
940
941 /// JSON-encode a string into an attribute value, both rules in one pass.
942 ///
943 /// Small enough to own. Pulling in a JSON crate to write object literals of
944 /// strings would be the larger decision, and the encoder a renderer needs is
945 /// this: the six characters JSON requires escaped, plus a `\u00XX` form for the
946 /// rest of the C0 range.
947 ///
948 /// The HTML escaping is applied here rather than by the caller, which is what
949 /// lets the payload go straight into `out`. Building the object and then
950 /// escaping the whole of it allocated a `String` for each, per action, and
951 /// there was nothing in between to look at.
952 ///
953 /// The two rules compose in this order and only this order. JSON runs first, so
954 /// a quote in the text becomes `\"` and then `\&quot;`; the backslash JSON adds
955 /// is not a character HTML encodes, and the quote HTML encodes is not one JSON
956 /// would look at twice. The structural quotes the object needs are written as
957 /// `&quot;` directly, because they are markup rather than content.
958 fn json_string_attr(text: &str, out: &mut String) {
959 out.push_str("&quot;");
960 for ch in text.chars() {
961 match ch {
962 // JSON first, then the HTML form of what it produced.
963 '"' => out.push_str("\\&quot;"),
964 '\\' => out.push_str("\\\\"),
965 '\n' => out.push_str("\\n"),
966 '\r' => out.push_str("\\r"),
967 '\t' => out.push_str("\\t"),
968 c if (c as u32) < 0x20 => {
969 let _ = write!(out, "\\u{:04x}", c as u32);
970 }
971 // The rest of what an attribute value cannot carry. JSON has no
972 // opinion on any of these, so this arm is HTML's alone and matches
973 // `escape_into` character for character.
974 '&' => out.push_str("&amp;"),
975 '<' => out.push_str("&lt;"),
976 '>' => out.push_str("&gt;"),
977 '\'' => out.push_str("&#39;"),
978 c => out.push(c),
979 }
980 }
981 out.push_str("&quot;");
982 }
983
984 /// The params as an `hx-vals` object, written into the attribute they land in.
985 fn json_object_attr(params: &Params, out: &mut String) {
986 json_pairs_attr(params.iter(), out);
987 }
988
989 /// Values as a JSON array inside an attribute value.
990 ///
991 /// The values a region watches for, which are app text: a licence key, a
992 /// timezone kind, whatever a select offers. Same escaper as the object form, so
993 /// there is one place where JSON and HTML compose and not two.
994 /// The three attributes a conditional thing carries: which control it watches,
995 /// what that control has to hold, and the values that satisfy it.
996 ///
997 /// Written from one place for a region and for a single question inside a
998 /// form, because `reveal.js` reads the marks rather than the element: a `div`
999 /// and a field group answer the same script, and two emitters writing the same
1000 /// three names is how they come apart.
1001 fn reveal_attrs(reveal: &quasi_router::Reveal, out: &mut String) {
1002 out.push_str(" data-reveal=\"");
1003 escape_into(&reveal.control, out);
1004 out.push('"');
1005 match &reveal.when {
1006 quasi_router::Held::Anything => out.push_str(" data-reveal-when=\"any\""),
1007 quasi_router::Held::Nothing => out.push_str(" data-reveal-when=\"none\""),
1008 quasi_router::Held::Value(value) => {
1009 out.push_str(" data-reveal-when=\"value\" data-reveal-values=\"");
1010 json_values_attr(std::slice::from_ref(value), out);
1011 out.push('"');
1012 }
1013 quasi_router::Held::OneOf(values) => {
1014 out.push_str(" data-reveal-when=\"value\" data-reveal-values=\"");
1015 json_values_attr(values, out);
1016 out.push('"');
1017 }
1018 }
1019 }
1020
1021 fn json_values_attr(values: &[String], out: &mut String) {
1022 out.push('[');
1023 for (i, value) in values.iter().enumerate() {
1024 if i > 0 {
1025 out.push(',');
1026 }
1027 json_string_attr(value, out);
1028 }
1029 out.push(']');
1030 }
1031
1032 /// Name/value pairs as a JSON object inside an attribute value.
1033 ///
1034 /// Shared with the shell, which writes the headers a document sends into
1035 /// `hx-headers`. One escaper for both, since the two attributes have the same
1036 /// shape and a second implementation is a second thing to get wrong.
1037 pub(crate) fn json_pairs_attr<'a, I>(pairs: I, out: &mut String)
1038 where
1039 I: IntoIterator<Item = (&'a str, &'a str)>,
1040 {
1041 out.push('{');
1042 for (i, (name, value)) in pairs.into_iter().enumerate() {
1043 if i > 0 {
1044 out.push(',');
1045 }
1046 json_string_attr(name, out);
1047 out.push(':');
1048 json_string_attr(value, out);
1049 }
1050 out.push('}');
1051 }
1052
1053 /// What makes a control call its action.
1054 ///
1055 /// Here rather than at the call sites because every `hx-` attribute this crate
1056 /// emits has to come out of one function, which is decision 13's claim that the
1057 /// transport is replaceable and is asserted by a test.
1058 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059 pub(crate) enum Fires<'a> {
1060 /// The user activating the control. htmx's default for a button or a link.
1061 Click,
1062 /// The user pressing a tab whose panel is a route.
1063 ///
1064 /// A gesture like [`Click`](Self::Click), and the second member that
1065 /// suppresses the `href` below, for a different reason than
1066 /// [`Load`](Self::Load): a tab strip's control *is* somewhere to go, and the
1067 /// place it goes is a fragment. An `href` to one is a link that middle-click,
1068 /// copy-link and a crawler would all follow to a shell-less scrap of a page,
1069 /// which is exactly the lying control `control_tag` exists to prevent --
1070 /// pointing the other way. So the strip keeps the `<button>` it has always
1071 /// emitted and the address stays on `hx-get` alone.
1072 ClickInStrip,
1073 /// The form being submitted. htmx's own default for a `<form>`, so no
1074 /// `hx-trigger` is emitted for it either.
1075 ///
1076 /// Distinguished from [`Click`](Self::Click) for one reason, and it is
1077 /// [`guards`](Self::guards): a waiting form cannot lock itself. `disabled`
1078 /// is not an attribute a `<form>` has and it does not reach descendants the
1079 /// way `<fieldset disabled>` does, so the guard has to name the button.
1080 Submit,
1081 /// The element existing. What a region fed by a call uses, so the screen
1082 /// paints its stand-in and the slow part arrives behind it.
1083 ///
1084 /// The only member that is not a gesture, which is why it is the only one
1085 /// that suppresses the `href` below: a region is not somewhere to go.
1086 Load,
1087 /// The element existing, and then every [`CADENCE`](crate::CADENCE)
1088 /// afterwards.
1089 ///
1090 /// [`Slot::live`](quasi_router::Slot::live) on a region that also names a
1091 /// call. `load` is kept beside the interval rather than replaced by it,
1092 /// because htmx's `every` waits out the first period before its first
1093 /// request and a region that painted its stand-in and then sat empty for
1094 /// ten seconds is a slower screen than the one this replaces.
1095 ///
1096 /// Not a gesture either, and suppresses the `href` for
1097 /// [`Load`](Self::Load)'s reason.
1098 Live,
1099 /// The control's own value changing. What a checkbox that is itself the
1100 /// write does.
1101 Change,
1102 /// The value of a control *inside* this element changing.
1103 ///
1104 /// A field group, whose control is emitted by makeover-webview and has no
1105 /// seam to hang attributes on. The `change` event bubbles, so the wrapper
1106 /// catches it whichever of input, select or textarea the field turned out to
1107 /// be, and the value is found back rather than assumed.
1108 ChangeInside,
1109 /// A key pressed anywhere in the document.
1110 ///
1111 /// What a [`Chrome`](quasi_router::Chrome) binding is: it belongs to no
1112 /// element, so it listens on the body rather than on itself. The string is
1113 /// the filter over `KeyboardEvent`, built by `crate::chrome` because
1114 /// reading a key name is the host's job.
1115 Key(&'a str),
1116 /// The user clicking the element, but not a control inside it.
1117 ///
1118 /// A table row carries its `activate` on the row itself, unlike a list
1119 /// row, which hangs it on the primary text and so has never had this
1120 /// problem. Once a cell can hold a control, a click on that control
1121 /// bubbles to the row and htmx fires both: pressing Remove would delete
1122 /// the key and open it. The filter is on the row rather than a
1123 /// `stopPropagation` on the button because the row is the element making
1124 /// the wrong assumption, and a button that swallows events breaks anything
1125 /// else listening above it.
1126 ClickBeside,
1127 /// The value of a control inside this element being typed into, once it has
1128 /// stood still for the given wait.
1129 ///
1130 /// A [`Field::consults`] on a control the reader types into, which is what
1131 /// [`typed_into`] decides. `keyup changed` rather than `change`, because
1132 /// the question is about the value being written and `change` does not fire
1133 /// until the box is left. The delay is the description's, not this
1134 /// renderer's: the four sites this replaces each spelled `delay:500ms` by
1135 /// hand, and a number picked here is a number the terminal renderer would
1136 /// have had to guess at separately.
1137 ///
1138 /// Every other control on a field's consult takes
1139 /// [`Working`](Self::Working) instead, `aeb44860`: `keyup` never hears a
1140 /// select move.
1141 ///
1142 /// [`Field::consults`]: quasi_router::Field::consults
1143 Typing {
1144 /// How long the value stands still first.
1145 after: std::time::Duration,
1146 /// How many characters it must carry before the question is asked at
1147 /// all. `0` asks whatever is there.
1148 at_least: usize,
1149 },
1150 /// Any control inside this element moving, once it has stood still for the
1151 /// given wait.
1152 ///
1153 /// [`Slot::consults`], and a [`Field::consults`] on anything
1154 /// [`typed_into`] refuses. The `input` event and not
1155 /// [`Typing`](Self::Typing)'s
1156 /// `keyup`, because these dials are not all typed into: MNW's fee
1157 /// calculator has four number boxes and a radio group, and `keyup` never
1158 /// hears the radio. `input` is what the shipped markup already reaches for
1159 /// on the four boxes, it fires for a radio, a checkbox and a select as
1160 /// well, and it does not fire on blur -- which `change` does, so the pair
1161 /// `keyup, change` would ask the same question twice for every box the
1162 /// reader typed in and then left.
1163 ///
1164 /// The wait applies to the radio as much as to the boxes. That is a
1165 /// difference from the hand-written markup, which delays the boxes and
1166 /// fires the radio at once, and it is the description's number rather than
1167 /// this renderer's: a description that wanted the two to differ would say
1168 /// so with two consults. A field's discrete consult says zero and means it.
1169 ///
1170 /// [`Field::consults`]: quasi_router::Field::consults
1171 /// [`Slot::consults`]: quasi_router::Slot::consults
1172 Working {
1173 /// How long the value that moved stands still first.
1174 after: std::time::Duration,
1175 /// How many characters that value must carry before the question is
1176 /// asked at all. `0` asks whatever is there.
1177 at_least: usize,
1178 },
1179 }
1180
1181 impl<'a> Fires<'a> {
1182 /// The key filter, when the gesture is a keystroke.
1183 ///
1184 /// One caller: the destination that goes back, which is the one this
1185 /// renderer performs itself rather than handing to htmx. Every other
1186 /// destination gets its gesture written as an `hx-trigger` and never has to
1187 /// ask what the gesture was.
1188 const fn key(self) -> Option<&'a str> {
1189 match self {
1190 Self::Key(filter) => Some(filter),
1191 _ => None,
1192 }
1193 }
1194
1195 /// The selector this trigger needs gathered, beyond anything the caller
1196 /// asked for.
1197 ///
1198 /// Only one kind has one: a field group's control is inside it rather than
1199 /// being it, so the value has to be found back. Returned rather than
1200 /// written so it can be joined with the screen's ticks — two `hx-include`
1201 /// attributes on one element is markup a parser drops half of.
1202 const fn include(self) -> Option<&'static str> {
1203 match self {
1204 Self::ChangeInside | Self::Typing { .. } | Self::Working { .. } => {
1205 Some("find input, find select, find textarea")
1206 }
1207 Self::Click
1208 | Self::ClickInStrip
1209 | Self::Submit
1210 | Self::Change
1211 | Self::Key(_)
1212 | Self::ClickBeside
1213 | Self::Load
1214 | Self::Live => None,
1215 }
1216 }
1217
1218 /// What an [`Action::awaiting`] on this trigger locks while it waits.
1219 ///
1220 /// Everything that fires on a gesture is the element the gesture landed
1221 /// on, so `this` locks the thing that was pressed. A form is the exception
1222 /// and the reason this exists: htmx sets `disabled` on whatever the
1223 /// selector resolves to, a `<form>` has no such attribute, and it does not
1224 /// cascade. `hx-disable="this"` on a form is therefore a double-submit
1225 /// guard that permits a double submit — worse than none, because a
1226 /// consumer deletes a working hand-rolled guard to adopt it.
1227 ///
1228 /// The button rather than every control inside: it is what has to stop
1229 /// answering, and locking the fields as well would take the text away from
1230 /// a reader still correcting it.
1231 const fn guards(self) -> &'static str {
1232 match self {
1233 Self::Submit => "find button[type='submit']",
1234 Self::Click
1235 | Self::ClickInStrip
1236 | Self::Change
1237 | Self::Key(_)
1238 | Self::ClickBeside
1239 | Self::Load
1240 | Self::Live
1241 | Self::ChangeInside
1242 | Self::Typing { .. }
1243 | Self::Working { .. } => "this",
1244 }
1245 }
1246 }
1247
1248 /// The transport attributes for one action.
1249 ///
1250 /// # The two bags land in two places, and that is the point
1251 ///
1252 /// An action carries what the control sends (`params`) and the view it was
1253 /// offered under (`carried`), and htmx has a slot for each: the address takes
1254 /// the view, `hx-vals` takes the payload. So a write to a filtered list emits
1255 /// `hx-post="/problems/{id}/status?status=Open"` with
1256 /// `hx-vals='{"status":"Dismissed"}'`, and the two `status` values never meet.
1257 ///
1258 /// `hx-vals` is a JSON object literal, so two entries under one name emitted a
1259 /// duplicate key and every parser kept the last — inverting
1260 /// [`Params::get`]'s first-wins rule the moment a value crossed the wire, and
1261 /// silently dropping every repeat that [`Params::get_all`] exists to carry.
1262 /// Folding the view into the address fixes both: a query string repeats a name
1263 /// happily, and it is the half that wanted to be in the URL anyway.
1264 ///
1265 /// Nothing here concatenates a `?`. [`quasi_http::route_url`] does that, in one
1266 /// place, with a real encoder, because hand-built query strings are where
1267 /// escaping bugs live.
1268 ///
1269 /// The separation is a write's. On a read htmx puts `hx-vals` in the query
1270 /// string too, so the two bags land in one place either way, and htmx 4 deletes
1271 /// an address parameter whose name a value repeats before appending it, where
1272 /// 2.x appended both. So a GET naming one thing in both bags sends the payload
1273 /// and not the view. Nothing in the tree does; a read that wanted both would
1274 /// have to say which one it means, and this is where to look when it does.
1275 pub(crate) fn action_attrs(
1276 action: &Action,
1277 fires: Fires,
1278 confirm: Option<&str>,
1279 gathers: Option<&str>,
1280 chooses: bool,
1281 out: &mut String,
1282 ) {
1283 // Neither way out of the app is htmx's business: nothing swaps, no route is
1284 // called, and the browser follows a normal link.
1285 //
1286 // The two differ in where the reader ends up, which is the description's
1287 // (`48a6e9e5`). `External` is a reference to come back from, so it is put
1288 // aside in a tab; `Leaving` is this document handing the reader on, so it
1289 // replaces the page, which is what a bare `href` already does.
1290 //
1291 // `rel` on both rather than only on the tab. It is `noopener` that needs
1292 // the argument and it needs it once: a new tab with `window.opener` intact
1293 // hands the other page a handle on this one, and while a same-tab
1294 // navigation gives it no live handle, it still sends `Referer`, which
1295 // `noreferrer` is what stops. Neither is a cost, and a branch here would be
1296 // a second thing to get right.
1297 if let Destination::External(url) | Destination::Leaving(url) = &action.destination {
1298 out.push_str(" href=\"");
1299 escape_into(url, out);
1300 out.push('"');
1301 if matches!(action.destination, Destination::External(_)) {
1302 out.push_str(" target=\"_blank\"");
1303 }
1304 out.push_str(" rel=\"noopener noreferrer\"");
1305 return;
1306 }
1307
1308 // Nothing leaves the machine, so nothing htmx does applies: no verb, no
1309 // trigger, no `href`, and above all no address -- a local destination has
1310 // none, and `route_url` below would build `hx-get=""`, which htmx reads as
1311 // "ask the page you are on". That is the failure this branch exists to make
1312 // impossible, and it is why the branch is here rather than left to the
1313 // `matches!` further down.
1314 //
1315 // `210574ca`, ruled 2026-08-18. This crate is the one
1316 // `Renderer::Hybrid` renderer, so it is the one that has to be told.
1317 //
1318 // What it emits is the mark and not the behaviour, and that is unchanged by
1319 // the hyperscript pass of `0081563e`: what happens locally is named by the
1320 // member carrying the action, and no member names one yet, so there is no
1321 // program to write here. `crate::hyperscript` is where it goes when there
1322 // is, and the attribute is what makes every site findable meanwhile.
1323 // Wherever the reader came from. The browser is the host holding that
1324 // history, so the behaviour is emitted rather than marked: this is the one
1325 // destination a webview can perform on its own, and `crate::hyperscript`
1326 // carries why an attribute is allowed to hold the program.
1327 //
1328 // Before the `Local` branch and not folded into it: `is_local` is false
1329 // here, and the mark that branch writes says "no request at all", which is
1330 // not what going back is.
1331 if action.destination.is_back() {
1332 crate::hyperscript::back(fires.key(), out);
1333 return;
1334 }
1335
1336 if action.destination.is_local() {
1337 out.push_str(" data-local");
1338 // The payload travels with it, for `by_host`'s reason: dropping it here
1339 // would be the description saying something the markup then did not
1340 // carry, and there is no other place to read it from.
1341 if !action.params.is_empty() {
1342 out.push_str(" data-vals=\"");
1343 json_object_attr(&action.params, out);
1344 out.push('"');
1345 }
1346 // Deliberately not `awaiting_attrs`. A wait is a fact about a call and
1347 // this is not one; a local action that says it waits is a description
1348 // bug, and drawing a spinner for it would hide that rather than show it.
1349 return;
1350 }
1351
1352 // The address, built once. The `href` below and the verb further down are
1353 // the same string whenever both are emitted -- a read of a route names one
1354 // place -- and building it twice was a `String` per link on every screen
1355 // made mostly of links.
1356 let url = quasi_http::route_url(action.destination.as_str(), &action.carried);
1357
1358 // The host makes this call, so the renderer hands it the address and emits
1359 // no transport at all: no verb, no trigger, no `href`. `a81384d4`, and the
1360 // same shape `saves` already has -- a named attribute the host acts on,
1361 // rather than a class plus positional arguments.
1362 //
1363 // `data-sends` and not `data-action`: what is named is where the thing goes,
1364 // which is the half the description knows. How it gets there is the host's
1365 // and is exactly what is not being described.
1366 if action.by_host {
1367 out.push_str(" data-sends=\"");
1368 escape_into(&url, out);
1369 out.push('"');
1370 // The payload travels with it. Dropping it here would be the
1371 // description saying something the markup then did not carry, and the
1372 // host has no other place to read it from.
1373 if !action.params.is_empty() {
1374 out.push_str(" data-vals=\"");
1375 json_object_attr(&action.params, out);
1376 out.push('"');
1377 }
1378 // The wait is still a fact about the call and the host is what draws it,
1379 // so the mark goes on. The `hx-disable` half does not: locking the
1380 // control is htmx's doing on a request htmx is making, and this is not
1381 // one.
1382 awaiting_attrs(action, out);
1383 return;
1384 }
1385
1386 // The answer belongs in a mount of its own, and a mount here is a second
1387 // document. Opening one is the host's -- a webview cannot put up a window --
1388 // so this emits the address and no transport, exactly the shape `by_host`
1389 // has above. goingson `3fb2526a`.
1390 //
1391 // `data-mount` and not `data-sends`: the two say different things to the
1392 // host and a host that conflated them would post where it should open. What
1393 // is named is still only where the thing goes.
1394 //
1395 // Checked after `by_host` because a call the host performs is already the
1396 // host's to place; marking such a call `elsewhere` says nothing this
1397 // renderer can add.
1398 if action.elsewhere {
1399 out.push_str(" data-mount=\"");
1400 escape_into(&url, out);
1401 out.push('"');
1402 // The payload travels with it, for the reason it travels with the two
1403 // branches above: the description said it, and the host has nowhere
1404 // else to read it from.
1405 if !action.params.is_empty() {
1406 out.push_str(" data-vals=\"");
1407 json_object_attr(&action.params, out);
1408 out.push('"');
1409 }
1410 // No wait mark. Putting up a mount is not a call that resolves, so a
1411 // spinner on the control would be waiting for something that never
1412 // lands.
1413 return;
1414 }
1415
1416 // The whole document is being replaced, so the browser is what replaces it.
1417 // The anchor goes on and nothing else does: no verb, no trigger, no swap.
1418 // An htmx request here would fetch a whole screen and morph it into the page
1419 // it was meant to leave, which is the failure `00ee7af5` was ruled on.
1420 //
1421 // A narrowing rather than an addition. The `href` below is emitted for every
1422 // read of a route already, for middle-click, copy-link, a crawler and the
1423 // page with JS off; this is the same anchor with the transport beside it
1424 // dropped.
1425 //
1426 // Guarded on the same three conditions that anchor is, so the mark never
1427 // turns a call into something it was not. A write marked this way is still
1428 // performed, because an anchor would ask where the description said to tell,
1429 // and a load or live trigger has no gesture to follow a link with.
1430 //
1431 // Named as the one gesture rather than as a list of exclusions, and that is
1432 // a correction. It read `!matches!(fires, Load | Live | ClickInStrip)`,
1433 // which let every gesture that is not a click through -- and the branch
1434 // *returns*, so a navigating read on a `Fires::ChangeInside` wrapper got an
1435 // `href` on a `<div>` and no transport at all. `Chrome::band`'s search box
1436 // is exactly that shape and did nothing. An `href` is only meaningful on
1437 // the element a browser follows, which is the anchor `control_tag` emits
1438 // for a click, so the guard says so.
1439 //
1440 // The last two guards are the attributes an anchor cannot carry the meaning
1441 // of. `hx-confirm` is htmx gating the request, so leaving htmx out would
1442 // drop a question the description asked before acting; `download` and
1443 // `data-saves` say the answer is a file, which is the opposite of replacing
1444 // the document. Either one and the transport is emitted as it was, since a
1445 // dropped prompt and a navigation to a file are both worse than a mark that
1446 // did nothing.
1447 if action.navigates
1448 && matches!(action.destination, Destination::Route(_))
1449 && !action.method.mutates()
1450 && matches!(fires, Fires::Click)
1451 && confirm.is_none()
1452 && action.saves.is_none()
1453 {
1454 out.push_str(" href=\"");
1455 escape_into(&url, out);
1456 out.push('"');
1457 return;
1458 }
1459
1460 // A read of a route this app answers is a link, and it gets the address as
1461 // well as the transport. htmx uses `hx-get` and prevents the default, so
1462 // the `href` is what everything else uses: middle-click, copy-link, a
1463 // crawler, and the page with JS off. The parameters are folded into it
1464 // because a link to a filtered list that drops the filter is a different
1465 // place, and `hx-vals` below carries the same ones down htmx's path.
1466 if matches!(action.destination, Destination::Route(_))
1467 && !action.method.mutates()
1468 && matches!(fires, Fires::Click)
1469 {
1470 out.push_str(" href=\"");
1471 escape_into(&url, out);
1472 out.push('"');
1473 }
1474
1475 // Everything the screen's selection has ticked, gathered by the selector
1476 // the caller built. `5f2b8753`: this is the whole of what the per-app JS
1477 // used to do, and it is declarative because a checkbox already submits its
1478 // own name and value -- all that was missing was something saying which
1479 // boxes belong together.
1480 //
1481 // Here rather than in `act_html` because it is htmx, and htmx entering this
1482 // crate anywhere else is what the architectural test forbids.
1483 //
1484 // One attribute, however many things are being gathered. A field writing
1485 // over a selection wants both its own control and the ticks, and `fires`
1486 // below carries the first as a selector of its own; two `hx-include`
1487 // attributes on one element is markup a parser drops half of, so they are
1488 // joined here rather than emitted in two places that cannot see each other.
1489 let include = match (gathers, fires.include()) {
1490 (None, None) => None,
1491 (Some(one), None) | (None, Some(one)) => Some(one.to_owned()),
1492 (Some(ticks), Some(own)) => Some(format!("{own}, {ticks}")),
1493 };
1494 if let Some(selector) = &include {
1495 out.push_str(" hx-include=\"");
1496 escape_into(selector, out);
1497 out.push('"');
1498 }
1499
1500 // Where the answer goes, when the responder is not ours to ask. Emitted
1501 // before the verb so the attributes read in the order they are reasoned
1502 // about: where it lands, then what is sent.
1503 match &action.replaces {
1504 None => {}
1505 Some(Replaces::Region(region)) => {
1506 out.push_str(" hx-target=\"#");
1507 escape_into(region, out);
1508 out.push('"');
1509 }
1510 // `closest [data-row]` rather than a class, for `data-act`'s reason: an
1511 // attribute survives a host setting `Emit::class_prefix`, and this
1512 // function does not take `Emit` and so could not build the prefixed
1513 // selector anyway. One attribute across list rows and table rows, the
1514 // same choice `data-menu="row"` already made, so a screen mixing the
1515 // two needs one selector rather than two that look alike.
1516 //
1517 // `outerHTML` because "replaces the row I am in" means the row element
1518 // goes. The default swap would nest the answer inside the row it was
1519 // meant to replace.
1520 Some(Replaces::Enclosing) => {
1521 out.push_str(" hx-target=\"closest [data-row]\" hx-swap=\"outerHTML\"");
1522 }
1523 // A named hook the host acts on, in the same spirit as `data-saves` and
1524 // for the same reason: htmx has no request-side spelling for "what is
1525 // showing is now stale". The response header `HX-Refresh` is the
1526 // server's half, and these acts call routes that answer with a status.
1527 Some(Replaces::Everything) => {
1528 out.push_str(" data-replaces=\"everything\"");
1529 }
1530 }
1531
1532 // The answer is a file the reader keeps, not a view. On a link the browser
1533 // does the whole job from the attribute, so nothing else is needed and the
1534 // control still works with JS off. On a write it cannot: a response has to
1535 // be performed before it can be saved, so this is a named hook the host
1536 // acts on, in the same spirit as `data-act` and for the same reason it is an
1537 // attribute rather than a class. The host handles one attribute instead of
1538 // a per-button behaviour named by a class and two positional arguments.
1539 if let Some(filename) = &action.saves {
1540 if is_link(action) {
1541 out.push_str(" download=\"");
1542 } else {
1543 out.push_str(" data-saves=\"");
1544 }
1545 escape_into(filename, out);
1546 out.push('"');
1547 }
1548
1549 let verb = match action.method {
1550 Method::Get => " hx-get=\"",
1551 Method::Post => " hx-post=\"",
1552 Method::Delete => " hx-delete=\"",
1553 Method::Put => " hx-put=\"",
1554 };
1555 out.push_str(verb);
1556 escape_into(&url, out);
1557 out.push('"');
1558
1559 if chooses {
1560 // `1894e95d`. A row of a live selection sends what the press *meant*
1561 // beside whatever the address already carries, and only the browser can
1562 // say what it meant, so the object is computed at click time rather than
1563 // written now. htmx allows one `hx-vals`, so the static half is folded
1564 // into the same literal rather than emitted twice.
1565 //
1566 // `metaKey` beside `ctrlKey` because a Mac browser reports command as
1567 // the former and every file manager on that platform toggles with it.
1568 // Shift wins, matching the desktop everywhere: a range is the bigger
1569 // statement, and holding both cannot mean the plain press.
1570 out.push_str(" hx-vals=\"js:{");
1571 for (name, value) in action.params.iter() {
1572 escape_into(&format!("&quot;{name}&quot;: &quot;{value}&quot;, "), out);
1573 }
1574 out.push_str("&quot;");
1575 escape_into(quasi_router::Node::CHOOSING, out);
1576 out.push_str("&quot;: event.shiftKey ? &quot;through&quot; : ((event.ctrlKey || event.metaKey) ? &quot;also&quot; : &quot;only&quot;)}\"");
1577 } else if !action.params.is_empty() {
1578 out.push_str(" hx-vals=\"");
1579 json_object_attr(&action.params, out);
1580 out.push('"');
1581 }
1582
1583 // Named even where it matches htmx's own default for the element, so the
1584 // markup says what it does rather than resting on a default holding.
1585 match fires {
1586 // htmx's default for the element in both cases: a click for a button or
1587 // a link, a submit for a form.
1588 Fires::Click | Fires::ClickInStrip | Fires::Submit => {}
1589 // `data-act` and not the class, so the filter does not depend on
1590 // `Emit::class_prefix` and does not break when a host sets one. Same
1591 // reasoning as `data-menu` on a row's menu.
1592 Fires::ClickBeside => out.push_str(concat!(
1593 " hx-trigger=\"click[!event.target.closest(",
1594 "&#39;[data-act]&#39;)]\""
1595 )),
1596 Fires::Change => out.push_str(" hx-trigger=\"change\""),
1597 // Once, when the element appears. `Slot::fed_by` is the whole of what
1598 // reaches this, and the region it sits on is `Readiness::Pending`
1599 // already, so the stand-in is on screen before the request leaves.
1600 Fires::Load => out.push_str(" hx-trigger=\"load\""),
1601 // `load` first, then the interval. htmx waits out a whole period before
1602 // an `every` fires, so the pair is what makes the first paint arrive at
1603 // the same time it did before the region became live.
1604 Fires::Live => {
1605 out.push_str(" hx-trigger=\"load, every ");
1606 out.push_str(&crate::CADENCE.as_secs().to_string());
1607 out.push_str("s\"");
1608 }
1609 Fires::Key(filter) => {
1610 // `from:body`, because the element is hidden and never focused: a
1611 // trigger on itself would wait for a keystroke it can never
1612 // receive.
1613 out.push_str(" hx-trigger=\"keydown[");
1614 escape_into(filter, out);
1615 out.push_str("] from:body\"");
1616 }
1617 // Its `hx-include` is emitted above with the ticks, if there are any.
1618 Fires::ChangeInside => out.push_str(" hx-trigger=\"change\""),
1619 // `changed` so that arrow keys and Tab do not ask the same question
1620 // again, and the delay so that a name is asked about once rather than
1621 // once per letter. Milliseconds because that is htmx's unit; the
1622 // description carries a `Duration` so the terminal renderer is not
1623 // handed a number in someone else's units.
1624 // The dials that are not typed into: `input` covers the radio and the
1625 // select `keyup` never hears, and does not fire on blur the way
1626 // `change` would. `Fires::Working` carries the argument.
1627 Fires::Working { after, at_least } => {
1628 out.push_str(" hx-trigger=\"input");
1629 if at_least > 0 {
1630 out.push_str("[event.target.value.length&gt;=");
1631 out.push_str(&at_least.to_string());
1632 out.push(']');
1633 }
1634 out.push_str(" changed delay:");
1635 out.push_str(&after.as_millis().to_string());
1636 out.push_str("ms\"");
1637 }
1638 Fires::Typing { after, at_least } => {
1639 out.push_str(" hx-trigger=\"keyup");
1640 // htmx takes the filter on the event and the modifiers after it, so
1641 // the floor goes here rather than beside the delay. `event.target`
1642 // and not `this`: the trigger sits on the wrapper and the value
1643 // being typed belongs to the control inside it, which is the same
1644 // reason `include` above has to find the control back.
1645 if at_least > 0 {
1646 out.push_str("[event.target.value.length&gt;=");
1647 out.push_str(&at_least.to_string());
1648 out.push(']');
1649 }
1650 out.push_str(" changed delay:");
1651 out.push_str(&after.as_millis().to_string());
1652 out.push_str("ms\"");
1653 }
1654 }
1655
1656 // Asking before acting is transport here, same as the verb: htmx gates the
1657 // request on it. That is also why it lands in this function rather than
1658 // beside the label — every `hx-` attribute this crate emits comes from one
1659 // place, or swapping htmx for fixi stops being a one-function change.
1660 if let Some(prompt) = confirm {
1661 out.push_str(" hx-confirm=\"");
1662 escape_into(prompt, out);
1663 out.push('"');
1664 }
1665
1666 // Decision 7's slack: a morph preserves focus, scroll and input state
1667 // through a swap, so a whole-Screen answer stops being destructive.
1668 //
1669 // Unconditional since the emitter moved to htmx 4, where `outerMorph` is a
1670 // swap style rather than a swap style an extension has to supply. Under 2.x
1671 // this was emitted only when the shell loaded idiomorph, because htmx fell
1672 // back to `innerHTML` in silence when it did not -- and silently
1673 // destructive is the outcome decision 7 exists to avoid. There is nothing
1674 // left to be missing. `outerMorph` and not `innerMorph`: what is replaced
1675 // is the target itself, which is what the extension's bare `morph` meant.
1676 out.push_str(" hx-swap=\"outerMorph\"");
1677
1678 // The action says it waits on something that resolves once, so the control
1679 // stops answering until it does. `hx-disable` is the half of this
1680 // treatment the MNW server writes twice against 57 spinners, and it is the
1681 // half that guards a double-submitted purchase.
1682 //
1683 // `hx-disable` is htmx 4's name for what 2.x called `hx-disabled-elt`. The
1684 // name 2.x gave `hx-disable` -- skip processing this subtree -- is
1685 // `hx-ignore` in 4, and nothing here emits it.
1686 //
1687 // No `hx-indicator`: htmx already puts `htmx-request` on the element that
1688 // made the request, so naming it as its own indicator emits an attribute
1689 // that changes nothing. A screen wanting a spinner somewhere else is
1690 // pointing at an element no description names.
1691 //
1692 // No static `aria-busy` either. The control is not busy when the page is
1693 // painted, and an attribute that is true only between two events is the
1694 // binder's to set. What a reader gets in the meantime is the `disabled` htmx
1695 // applies, which is not silent.
1696 if action.awaiting.is_some() {
1697 // What gets locked is the trigger's to say, not always `this`: see
1698 // `Fires::guards`.
1699 out.push_str(" hx-disable=\"");
1700 out.push_str(fires.guards());
1701 out.push('"');
1702 awaiting_attrs(action, out);
1703 }
1704 }
1705
1706 /// What a control says about the wait it is in, without saying who locks it.
1707 ///
1708 /// `hx-disable` stayed at the call site because it needs the trigger, which is
1709 /// the renderer's business either way.
1710 fn awaiting_attrs(action: &Action, out: &mut String) {
1711 let Some(awaiting) = &action.awaiting else {
1712 return;
1713 };
1714 out.push_str(" data-awaiting=\"");
1715 out.push_str(if awaiting.is_determinate() {
1716 "determinate"
1717 } else {
1718 "indeterminate"
1719 });
1720 out.push('"');
1721 // The amount, when it was measured. Never a duration and never anything
1722 // derived into one: a bar drawn from this shows what is done over what
1723 // there is, and the time it has taken, and predicts nothing.
1724 if let Some(amount) = awaiting.amount {
1725 let _ = write!(out, " data-awaiting-amount=\"{amount}\"");
1726 }
1727 }
1728
1729 /// Whether an action is somewhere to go rather than something to do.
1730 ///
1731 /// Two ways to be a link. An external destination leaves. A read of a route
1732 /// this app answers is also a link: it has an address, it can be visited
1733 /// directly, and nothing changes because it was.
1734 ///
1735 /// A write is never a link however it is spelled, which is the whole of the
1736 /// other side. An anchor is something a browser may prefetch and a crawler will
1737 /// follow, and neither is allowed to delete a task.
1738 ///
1739 /// A local destination is neither way. There is nowhere to go, so an anchor
1740 /// would carry no address and a middle-click would open a copy of the page the
1741 /// reader is already on. It is a button, whatever its method says: the method
1742 /// is meaningless once nothing is asked, the same way it is on a
1743 /// [`Destination::External`].
1744 const fn is_link(action: &Action) -> bool {
1745 if action.destination.is_local() {
1746 return false;
1747 }
1748 action.destination.is_external() || !action.method.mutates()
1749 }
1750
1751 /// The element a control becomes.
1752 ///
1753 /// A link is an anchor and a write is a button, and a button that navigates is
1754 /// a button lying to everything that reads the page: middle-click, copy-link,
1755 /// a crawler and a screen reader included. This keyed on external-or-not until
1756 /// the read case was separated out, which made every internal navigation a
1757 /// control that only worked by running JavaScript first.
1758 ///
1759 /// Branching on the [`Destination`] variant and never on the shape of the
1760 /// string is the rule `Destination`'s own docs set. "Starts with https" is how a
1761 /// route named `/https-setup` ends up opening a browser.
1762 const fn control_tag(action: &Action) -> (&'static str, &'static str) {
1763 if is_link(action) {
1764 ("<a", "</a>")
1765 } else {
1766 ("<button type=\"button\"", "</button>")
1767 }
1768 }
1769
1770 /// A control that calls a route, with whatever it asks for first around it.
1771 pub(crate) fn act_html(act: &Act, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
1772 if act.asks.is_empty() {
1773 control_html(act, opts, out);
1774 anchor_slot(act, opts, out);
1775 return;
1776 }
1777
1778 // `Act::asks`, the disclosure half. MNW's bulk bar presses "Set Price" and
1779 // reveals a box, an Apply and a hint, all of it hidden behind a `hidden`
1780 // class and 40 lines of JS toggling it. `details` is what a browser already
1781 // has for that: it opens on the press, closes on the second one, keeps its
1782 // own state across a swap of anything outside it, and needs no script.
1783 //
1784 // The summary carries the verb and the control inside carries it again,
1785 // rather than the inner one being called "Apply". A described act has one
1786 // label, and inventing a second word here would be this renderer writing
1787 // copy. The count the selection script appends lands on the inner control,
1788 // which is the one that fires.
1789 out.push_str("<details");
1790 class_attr(&["ask"], opts, out);
1791 out.push('>');
1792 out.push_str("<summary");
1793 class_attr(&["button", "ask-open"], opts, out);
1794 out.push('>');
1795 escape_into(&act.label, out);
1796 out.push_str("</summary><div");
1797 class_attr(&["ask-body"], opts, out);
1798 out.push('>');
1799 for field in &act.asks {
1800 // `as_asked`: a question here is answered by the control below it, so
1801 // the write members are dropped rather than honoured. A box that also
1802 // wrote on its own would send the value twice, once as it was typed and
1803 // once when the verb fired.
1804 field_group_html(&field.as_asked(), opts, doc, out);
1805 }
1806 control_html(act, opts, out);
1807 out.push_str("</div></details>");
1808 anchor_slot(act, opts, out);
1809 }
1810
1811 /// The popover container belonging to a named control, if it is named.
1812 ///
1813 /// After the control and outside it: an anchored screen is drawn *at* the
1814 /// control, and a menu inside a `button` is markup a browser is entitled to
1815 /// discard. That is the same placement a row's menu takes for the same reason.
1816 fn anchor_slot(act: &Act, opts: &Emit, out: &mut String) {
1817 if let Some(id) = act.id.as_deref().and_then(crate::hyperscript::handle) {
1818 anchor_container_html(&anchored_id(id), opts, out);
1819 }
1820 }
1821
1822 /// The control itself: the label, the address, and what it gathers.
1823 fn control_html(act: &Act, opts: &Emit, out: &mut String) {
1824 // The commit control for a staged selection gathers every tick on the
1825 // screen. One selector rather than a name per box: a screen holds one set
1826 // (`Screen::selection`), so the class the ticks already carry is what they
1827 // have in common. Built here because it needs `Emit`, which the transport
1828 // function does not take -- a host setting `class_prefix` moves the class
1829 // and the selector together.
1830 //
1831 // Built inside the `map` and not before it: staged selections are rare and
1832 // every other control on the screen was paying for two `String`s it then
1833 // dropped. `class` rather than `class_into` because what is wanted here is
1834 // a value, and a selector is the one place this crate holds one.
1835 //
1836 // A control that asked for a value gathers that too, by the disclosure it
1837 // sits in. `closest details` rather than an id: nothing in this renderer
1838 // mints ids, and the boxes a press should send are exactly the ones inside
1839 // the `details` the press happened in.
1840 let mut selectors: Vec<String> = Vec::new();
1841 if act.over.is_some() {
1842 selectors.push(format!(".{}", class("row-select", opts)));
1843 }
1844 if !act.asks.is_empty() {
1845 selectors.push("closest details".to_owned());
1846 }
1847 let gathers = (!selectors.is_empty()).then(|| selectors.join(", "));
1848 let gathers = gathers.as_deref();
1849 // `button`, which is makeover's name for this and carries its whole
1850 // interactive set: the raised bevel, the hover fill, the pressed inset, the
1851 // focus ring and the disabled treatment. This emitted `act`, a second name
1852 // for the same thing that no stylesheet in the tree defined, so a described
1853 // control rendered as unstyled text. There was never a concept here that
1854 // `button` was not already the word for.
1855 let (open, close) = control_tag(&act.action);
1856 out.push_str(open);
1857 class_attr(&["button"], opts, out);
1858 tone_attr(act.tone, out);
1859 // Named rather than found by class, so anything binding to "this is a
1860 // control" survives a host setting `Emit::class_prefix`. `Fires::ClickBeside`
1861 // is the first reader; a table row uses it to tell its own click apart from
1862 // a press on a button sitting inside one of its cells.
1863 out.push_str(" data-act");
1864
1865 // `ae8e8836`. The control's own name in the document, so the container
1866 // emitted beside it has something to be positioned against. Emitted only
1867 // when the description gave one: `Act::id` is `None` on nearly every
1868 // control, and a renderer minting ids for the rest would be inventing
1869 // addresses nothing asked for.
1870 if let Some(id) = &act.id {
1871 out.push_str(" id=\"");
1872 escape_into(id, out);
1873 out.push('"');
1874 }
1875
1876 // The hook the selection script reads: this control acts on the set, so it
1877 // says how many are in it and goes inert when there are none. Named rather
1878 // than a class for `data-act`'s reason, and it carries the selection's name
1879 // even though nothing selects between sets yet -- `Act::over` explains why
1880 // that day is not this one, and an attribute that already holds the name
1881 // costs nothing to start reading.
1882 if let Some(over) = &act.over {
1883 out.push_str(" data-over=\"");
1884 escape_into(over, out);
1885 out.push('"');
1886 }
1887
1888 // Where a chosen value goes, and what goes there. `f35aafee`. Two escaped
1889 // attributes rather than an emitted program: the value is app text -- a
1890 // media file's name arrives inside it -- and `crate::hyperscript`'s one
1891 // standing rule is that no program is built out of any. `crate::FILL_JS`
1892 // reads both.
1893 //
1894 // Emitted whatever the state is, unlike the transport below. A disabled
1895 // control performs nothing because it reports no click, so the guard is the
1896 // element rather than the absence of the attribute, and a control that is
1897 // re-enabled still knows where it deposits.
1898 if let Some(fill) = &act.fills {
1899 out.push_str(" data-fills=\"");
1900 escape_into(&fill.field, out);
1901 out.push_str("\" data-fill=\"");
1902 escape_into(&fill.value, out);
1903 out.push('"');
1904 }
1905
1906 match act.state {
1907 Some(layout::State::Disabled) => {
1908 // Disabled and emitting no transport, rather than disabled and
1909 // still carrying the address. A control that stops answering input
1910 // should also stop being a request waiting to be re-enabled from
1911 // the console.
1912 //
1913 // An anchor has no `disabled`, and omitting the href is what
1914 // actually stops it: an `<a>` without one is not a link, so it
1915 // drops out of the tab order on its own. `aria-disabled` is what
1916 // says why, since a bare span-shaped anchor says nothing.
1917 if is_link(&act.action) {
1918 out.push_str(" aria-disabled=\"true\"");
1919 } else {
1920 out.push_str(" disabled");
1921 }
1922 }
1923 // No `autofocus` arm. A description does not state focus: the browser
1924 // owns reach and focus here, which is what `makeover-layout` 0.19.0
1925 // settled by removing the member this used to read.
1926 //
1927 // `State` is `#[non_exhaustive]`, so a member added upstream lands
1928 // here. Emitting the transport is the right default for anything that
1929 // is not a suppression: a state this renderer has not learned yet
1930 // should leave the control working, not silently inert.
1931 _ => action_attrs(
1932 &act.action,
1933 Fires::Click,
1934 act.confirm.as_deref(),
1935 gathers,
1936 false,
1937 out,
1938 ),
1939 }
1940
1941 // The confirmation rides with the transport, in `action_attrs`. The key does
1942 // not: `accesskey` is plain HTML and no part of htmx. It is emitted even
1943 // though a browser makes little of it, because a key is the affordance in a
1944 // terminal and this is the nearest honest thing a page has.
1945 if let Some(key) = &act.key {
1946 out.push_str(" accesskey=\"");
1947 escape_into(key, out);
1948 out.push('"');
1949 }
1950
1951 // `ca7b5200`. Standing help, as `title` *and* as `aria-description`. `title`
1952 // alone is the mistake the shipped templates made 90 times: it is a hover, so
1953 // a touch reader never sees it and several screen readers are configured not
1954 // to announce it. Both attributes say the same sentence to the two audiences
1955 // that read different ones.
1956 if let Some(hint) = &act.hint {
1957 out.push_str(" title=\"");
1958 escape_into(hint, out);
1959 out.push_str("\" aria-description=\"");
1960 escape_into(hint, out);
1961 out.push('"');
1962 }
1963
1964 // `c3e145e0`. The value rides in an escaped attribute and `COPY_JS` reads
1965 // it, which is `FILL_JS`' arrangement and for its reason: the string is app
1966 // text -- a licence key, an embed snippet, a feed URL -- and no program in
1967 // this renderer is built out of text a user typed.
1968 //
1969 // The act's own action is emitted beside this and is a local one, so
1970 // nothing is asked and no transport attribute appears. A host that does not
1971 // serve the script gets a button that does nothing, which is why the
1972 // constant's docs say so.
1973 if let Some(value) = &act.copies {
1974 out.push_str(" data-copies=\"");
1975 escape_into(value, out);
1976 out.push('"');
1977 }
1978
1979 out.push('>');
1980 // `db998898`. What the control shows, before what it says: a thumbnail with
1981 // its name under it is the shape both measured sites have, and the label
1982 // stays in the markup rather than moving to an `aria-label`, so the control
1983 // has accessible text whether or not the picture arrives.
1984 if let Some(picture) = &act.shows {
1985 img_tag(picture, opts, out);
1986 }
1987 escape_into(&act.label, out);
1988 out.push_str(close);
1989 }
1990
1991 /// The `<img>` itself, without the `<figure>` a caption would want.
1992 fn img_tag(picture: &quasi_router::screen::Image, opts: &Emit, out: &mut String) {
1993 out.push_str("<img");
1994 class_attr(&["picture-img"], opts, out);
1995 out.push_str(" src=\"");
1996 // Escaped as an attribute and otherwise untouched, the treatment every
1997 // app-supplied string gets here. No scheme guard: quoting is what stops an
1998 // attribute breaking out, and unlike `href` an `src` has no scheme that
1999 // executes -- `javascript:` in an `<img src>` is a broken picture, not a
2000 // script.
2001 escape_into(&picture.src, out);
2002 out.push_str("\" alt=\"");
2003 escape_into(&picture.alt, out);
2004 out.push('"');
2005
2006 // The picture's own dimensions, which is how the browser holds its place.
2007 // With `height: auto` set by makeover, these two attributes give the box an
2008 // aspect ratio before a byte arrives, so the space is right at any width and
2009 // nothing below moves when the image lands. Omitted when the app does not
2010 // know them -- an invented size would reserve the wrong room, which is worse
2011 // than none.
2012 if let Some(e) = picture.intrinsic {
2013 let _ = write!(out, " width=\"{}\" height=\"{}\"", e.width, e.height);
2014 }
2015
2016 // 0.21.0 wrote `loading="lazy"` for every picture and that was wrong for
2017 // anything on screen at first paint: deferring what is already needed saves
2018 // nothing and lands its arrival later, so the page moves more rather than
2019 // less. The description says which it is now, and eager is the default.
2020 if matches!(picture.loading, layout::Loading::Lazy) {
2021 out.push_str(" loading=\"lazy\"");
2022 }
2023 fit_attr(picture.fit, out);
2024 out.push('>');
2025 }
2026
2027 /// One row of a list.
2028 ///
2029 /// `folded` is [`quasi_router::folded`]'s answer for this row: whether a shut
2030 /// branch above it is hiding it. The row is emitted regardless and marked
2031 /// `hidden`, which is what lets `outline.js` open a branch without asking the
2032 /// app for rows the document is already holding.
2033 /// How a row is drawn: as a list item, or as a row of a declared table.
2034 ///
2035 /// The 2026-09-05 collapse made a list row and a table row one type, and this
2036 /// is what is genuinely left of the difference. It is not a preference: the
2037 /// element, the class names and the gutter's wrapping are a contract with
2038 /// makeover's stylesheet and with the hosts that bind to them, so the two looks
2039 /// keep emitting exactly what they emitted before. What they no longer do is
2040 /// keep two copies of the walk that decides it.
2041 ///
2042 /// Which look a container takes is the container's, not the row's, and since
2043 /// the 2026-09-06 collapse there is one container: it is whether the table
2044 /// declared any columns. This doc predicted that a wave early and guessed the
2045 /// test slightly wrong -- it is whether there are columns at all, not whether
2046 /// they carry headings.
2047 #[derive(Clone, Copy, PartialEq, Eq)]
2048 enum RowLook {
2049 /// A list item. Flows its cells as spans on one wrapped line.
2050 List,
2051 /// A row of a declared table. Emits a cell per column, aligned by
2052 /// `display: table`.
2053 Table,
2054 }
2055
2056 impl RowLook {
2057 /// The opening tag, without its attributes.
2058 const fn open_tag(self) -> &'static str {
2059 match self {
2060 Self::List => "<li",
2061 Self::Table => "<div role=\"row\"",
2062 }
2063 }
2064
2065 /// The closing tag.
2066 const fn close_tag(self) -> &'static str {
2067 match self {
2068 Self::List => "</li>",
2069 Self::Table => "</div>",
2070 }
2071 }
2072
2073 /// The class every row of this look carries.
2074 const fn base_class(self) -> &'static str {
2075 match self {
2076 Self::List => "row",
2077 Self::Table => "table-row",
2078 }
2079 }
2080
2081 /// The class for the row the detail side is showing.
2082 const fn current_class(self) -> &'static str {
2083 match self {
2084 Self::List => "row-current",
2085 Self::Table => "table-row-current",
2086 }
2087 }
2088
2089 /// The class for a row in a selection already in force.
2090 const fn chosen_class(self) -> &'static str {
2091 match self {
2092 Self::List => "row-chosen",
2093 Self::Table => "table-row-chosen",
2094 }
2095 }
2096
2097 /// The class for a ticked row.
2098 const fn selected_class(self) -> &'static str {
2099 match self {
2100 Self::List => "row-selected",
2101 Self::Table => "table-row-selected",
2102 }
2103 }
2104 }
2105
2106 /// The classes a row carries, in the order this emitter has always written
2107 /// them.
2108 ///
2109 /// Shared by both looks since 2026-09-05. `row-nested` and `row-branch` were
2110 /// already spelled the same on both, which is the tell that the two functions
2111 /// were one function.
2112 fn row_classes(row: &Row, look: RowLook) -> Vec<&'static str> {
2113 let mut classes = vec![look.base_class()];
2114 if row.depth.is_nested() {
2115 classes.push("row-nested");
2116 }
2117 if row.open.is_some() {
2118 classes.push("row-branch");
2119 }
2120 if row.current {
2121 // `1894e95d`. A live selection, which is neither of the two below it:
2122 // not the app's pointer at one row and not a staged tick. Its own class
2123 // rather than reusing the tick's, because the tick's class is what a
2124 // commit control gathers by and these rows have nothing to commit.
2125 classes.push(look.current_class());
2126 }
2127 if row.chosen == Some(true) {
2128 classes.push(look.chosen_class());
2129 }
2130 if row.selected == Some(true) {
2131 classes.push(look.selected_class());
2132 }
2133 classes
2134 }
2135
2136 /// The row's own attributes, from `data-row` to the `>` that closes the tag.
2137 ///
2138 /// Shared by both looks. Every attribute here was written twice before
2139 /// 2026-09-05, in two functions that had drifted: the table row emitted
2140 /// `data-value` and `data-change` and the list row did not, which is recorded
2141 /// as a finding on wave `f0dac2d6` rather than silently fixed here, because
2142 /// changing what a served screen emits is not a refactor.
2143 fn row_attrs_html(row: &Row, look: RowLook, folded: bool, out: &mut String) {
2144 // `Replaces::Enclosing`'s target.
2145 out.push_str(" data-row");
2146 if look == RowLook::Table {
2147 // What the app calls this row, unique within its table. A list row
2148 // carries the same fact and spends it on its checkbox's `value=`
2149 // instead; see the finding on wave `f0dac2d6`.
2150 if let Some(value) = &row.value {
2151 out.push_str(" data-value=\"");
2152 escape_into(value, out);
2153 out.push('"');
2154 }
2155 }
2156 // The address, which is the document's name for the row rather than the
2157 // app's, and so is the one that gets an `id`.
2158 if let Some(address) = &row.address {
2159 out.push_str(" id=\"");
2160 escape_into(address, out);
2161 out.push('"');
2162 }
2163 if look == RowLook::Table {
2164 // Which side of a change this line is on, when the table is a diff.
2165 // `19d7602d`. An attribute rather than a class, for `tone_attr`'s
2166 // reason: makeover keys every intent off a data attribute, so a host
2167 // that has never heard of a diff still gets the tones it styles.
2168 //
2169 // `None` writes nothing, which keeps every ordinary table from reading
2170 // as a diff whose lines are all context.
2171 if let Some(change) = row.change {
2172 out.push_str(" data-change=\"");
2173 out.push_str(match change {
2174 layout::Change::Added => "added",
2175 layout::Change::Removed => "removed",
2176 // Including a kind added to this `#[non_exhaustive]` axis that
2177 // this renderer has not learned: an unknown side reads as an
2178 // unchanged line, which draws the text and loses only the tint.
2179 _ => "context",
2180 });
2181 out.push_str("\" data-tone=\"");
2182 out.push_str(layout::Intent::token(change));
2183 out.push('"');
2184 }
2185 }
2186 if row.depth.is_nested() {
2187 let _ = write!(
2188 out,
2189 " data-depth=\"{depth}\" style=\"--row-depth:{depth}\" aria-level=\"{level}\"",
2190 depth = row.depth.level,
2191 level = row.depth.level + 1
2192 );
2193 }
2194 if folded {
2195 out.push_str(" hidden");
2196 }
2197 if row.current {
2198 out.push_str(" aria-current=\"true\"");
2199 }
2200 if let Some(chosen) = row.chosen {
2201 out.push_str(if chosen {
2202 " aria-selected=\"true\""
2203 } else {
2204 " aria-selected=\"false\""
2205 });
2206 }
2207 }
2208
2209 fn row_html(row: &Row, folded: bool, opts: &Emit, out: &mut String) {
2210 let classes = row_classes(row, RowLook::List);
2211 out.push_str(RowLook::List.open_tag());
2212 class_attr(&classes, opts, out);
2213 row_attrs_html(row, RowLook::List, folded, out);
2214 out.push('>');
2215
2216 // The disclosure, when the row is a branch. Before the tick and before the
2217 // run, because it is the affordance for the row's place in the outline
2218 // rather than for the row: a reader scanning down the chevrons is reading
2219 // the shape of the tree.
2220 //
2221 // **A separate hit target from the label**, which is `Row::open`'s
2222 // instruction and the shipped egui sidebar's own behaviour: pressing a tag
2223 // filters by it, pressing its chevron does not. A button rather than a span
2224 // for the tick's reason -- the browser already gives it the keyboard, the
2225 // space key and a name.
2226 //
2227 // No route, and no `hx-` anything. Folding a branch is the reader tidying
2228 // their own view; see `Row::open` for why that is not a write.
2229 if let Some(open) = row.open {
2230 out.push_str("<button type=\"button\"");
2231 class_attr(&["row-disclose"], opts, out);
2232 out.push_str(" data-disclose aria-expanded=\"");
2233 out.push_str(if open { "true" } else { "false" });
2234 // The words a reader hears. Not a label a description gave: the
2235 // vocabulary says a row has a disclosure and never says what to call
2236 // it, so this is the renderer naming its own affordance the way the
2237 // tick's "Select" is.
2238 out.push_str("\" aria-label=\"");
2239 out.push_str(if open { "Collapse" } else { "Expand" });
2240 out.push_str("\"></button>");
2241 }
2242
2243 // The tick, when the row is selectable at all. A real checkbox rather than
2244 // a styled span: it is the one control here the browser already gets right,
2245 // including the label association, the space key and the mixed state a
2246 // screen reader announces.
2247 if let Some(ticked) = row.selected {
2248 out.push_str("<input type=\"checkbox\"");
2249 class_attr(&["row-select"], opts, out);
2250 if ticked {
2251 out.push_str(" checked");
2252 }
2253 // What the tick contributes to the screen's selection, under the one
2254 // name a handler reads it back by. `5f2b8753`: the app used to bind
2255 // this itself, gathering the checked boxes in JS, because nothing in
2256 // the description said what the ticks were for.
2257 //
2258 // Named rather than left to the browser's `on`, which is what a
2259 // checkbox with no value submits. `on` says a box was checked and not
2260 // which one.
2261 if let Some(value) = &row.value {
2262 out.push_str(" name=\"");
2263 escape_into(quasi_router::Node::TICKED, out);
2264 out.push_str("\" value=\"");
2265 escape_into(value, out);
2266 out.push('"');
2267 }
2268 // A tick with no route is local state until something submits it, which
2269 // is what a bulk checkbox is; the app binds those itself. A tick with
2270 // one is the write, which is `14612ed8`, and htmx's own default trigger
2271 // for an input is `change` — named anyway, so the markup says what it
2272 // does rather than relying on a default holding.
2273 if let Some(action) = &row.toggle {
2274 action_attrs(action, Fires::Change, None, None, false, out);
2275 }
2276 out.push_str(" aria-label=\"Select\">");
2277 }
2278
2279 // A span per run of consecutive parts sharing a role, rather than a fixed
2280 // sequence of members. The old shape drew primary, secondary, meta, bar,
2281 // tokens, actions in that order however the description was built; the run
2282 // draws what it was given where it was put, and a row with a tag between
2283 // two facts now says so.
2284 //
2285 // Consecutive same-role parts share one wrapping span for the reason a
2286 // cell's tokens do: the part class carries the gap between siblings, so a
2287 // span each would space two badges as though they were unrelated.
2288 //
2289 // Flow joins the role in the grouping key, and has to: the span is what
2290 // carries the clamp, so two parts sharing a role and disagreeing about how
2291 // many lines they may take cannot share one. A row whose title is relaxed
2292 // and whose second title-role part is not gets two spans, which is the
2293 // right answer and is also the only one that can be drawn.
2294 let mut rest = row.cells.as_slice();
2295 while let Some(head) = rest.first() {
2296 let (key, flow) = (head.key.clone(), head.room());
2297 let taken = rest
2298 .iter()
2299 .take_while(|cell| cell.key == key && cell.room() == flow)
2300 .count();
2301 let (group, tail) = rest.split_at(taken);
2302 // A list draws over the default column set, so every cell in one is
2303 // keyed by role. A cell keyed to a declared column has reached a list
2304 // row, which is a description error rather than something to render:
2305 // the table constructors (`Row::cells`, `at`, `cell`) produce those
2306 // keys and a list row is built by `Row::new` and its siblings.
2307 //
2308 // Loud in debug and benign in release, which is the bargain
2309 // `Table::row`'s two assertions already strike here (`d41d00a`): a
2310 // panic in a description is worse than a row that draws its text under
2311 // the wrong style.
2312 debug_assert!(
2313 matches!(key, quasi_router::CellKey::Role(_)),
2314 "a cell keyed to a declared column reached a list row, which draws \
2315 over the default column set and has no column to style it from. \
2316 Key: {key:?}",
2317 );
2318 let role = match &key {
2319 quasi_router::CellKey::Role(role) => *role,
2320 _ => quasi_router::layout::RowPart::Primary,
2321 };
2322 row_part_html(row, role, flow, group, opts, out);
2323 rest = tail;
2324 }
2325
2326 // After the run, because a menu is not on the line: it is the set of things
2327 // that can be done to the row, and it renders as a container the host opens
2328 // its own way. A webview hangs a context menu off it, a touch host an action
2329 // sheet, a terminal a key-driven list; all three read the same acts.
2330 if !row.menu.is_empty() {
2331 out.push_str("<div");
2332 class_attr(&["row-menu"], opts, out);
2333 // Named rather than hidden by class, so the host's own menu code has
2334 // something to bind to that does not depend on how it is styled.
2335 out.push_str(" data-menu=\"row\" hidden>");
2336 for act in &row.menu {
2337 act_html(act, opts, &Doc::bare(), out);
2338 }
2339 out.push_str("</div>");
2340 }
2341
2342 out.push_str(RowLook::List.close_tag());
2343 }
2344
2345 /// One run of consecutive row parts sharing a role.
2346 fn row_part_html(
2347 row: &Row,
2348 role: layout::RowPart,
2349 flow: layout::Flow,
2350 group: &[quasi_router::Cell],
2351 opts: &Emit,
2352 out: &mut String,
2353 ) {
2354 // The primary is a control when selecting the row does something, and plain
2355 // text when it does not. Emitting a button either way would give a screen
2356 // reader an affordance that answers nothing. `activate` stayed a field
2357 // through the run migration, so this is still the row's own answer rather
2358 // than something recomputed from a part.
2359 let activates = role == layout::RowPart::Primary && row.activate.is_some();
2360
2361 // `None` for a tight part, which is every part that has not asked
2362 // otherwise: one line is what the run already does and a class saying so
2363 // would be on every span in every row.
2364 let clamp = flow_class(flow);
2365
2366 let close = if let (true, Some(action)) = (activates, row.activate.as_ref()) {
2367 let (open, close) = control_tag(action);
2368 out.push_str(open);
2369 // The activating primary is a control, and it is still the thing whose
2370 // lines are being counted. Clamping the span outside it would clip a
2371 // box the button had already overflowed.
2372 //
2373 // Two calls rather than a built-up vec, so the literal stays where the
2374 // vocabulary test's reader can see it. That test only knows the
2375 // `class_attr(&[..])` shape, and a name it cannot see is a name nobody
2376 // is checking against makeover's.
2377 match clamp {
2378 Some(relaxed) => class_attr(&["row-activate", relaxed], opts, out),
2379 None => class_attr(&["row-activate"], opts, out),
2380 }
2381 action_attrs(action, Fires::Click, None, None, row.chosen.is_some(), out);
2382 out.push('>');
2383 close
2384 } else {
2385 out.push_str("<span");
2386 match clamp {
2387 Some(relaxed) => class_attr(&[part_class(role), relaxed], opts, out),
2388 None => class_attr(&[part_class(role)], opts, out),
2389 }
2390 out.push('>');
2391 "</span>"
2392 };
2393
2394 for cell in group {
2395 for node in &cell.content {
2396 row_inline_html(node, opts, out);
2397 }
2398 }
2399
2400 out.push_str(close);
2401 }
2402
2403 /// One leaf inside a row's run.
2404 ///
2405 /// Markdown is the one place a run entry is not just `node_html`. A
2406 /// [`Node::Rich`] standing on its own is a block and renders as one; inside a
2407 /// row it goes through docengine's `phrase` preset instead, which is markdown
2408 /// with no block structure at all and no links, keeping the inline emphasis. A
2409 /// heading, a list and a quote each contribute their words without claiming a
2410 /// block of a row that has no room for one, and not even a paragraph survives.
2411 ///
2412 /// Links go because a row usually carries [`Row::activate`], so the row itself
2413 /// is already a target and an anchor inside it is a second target inside the
2414 /// first: ambiguous to click, worse to reach by keyboard, and pointing
2415 /// somewhere a one-line summary cannot usefully send anyone. Their text stays.
2416 ///
2417 /// This is the renderer deciding, which is the point of the description
2418 /// carrying the kind rather than a flattened string. A terminal renderer facing
2419 /// the same source can emit bold instead, and one that wants neither can call
2420 /// `docengine::render_plain`. None of them has to be told by the screen author
2421 /// which to do.
2422 fn row_inline_html(node: &Node, opts: &Emit, out: &mut String) {
2423 match node {
2424 Node::Text { text, .. } => escape_into(text, out),
2425 // The inline path takes neither axis, and that is a containment
2426 // decision rather than an oversight: a run inside a cell is one line in
2427 // something usually already clickable, so blocks and links go whatever
2428 // the node declares. `Richness` says nothing about phrases for the same
2429 // reason -- see its doc.
2430 Node::Rich { source, .. } => out.push_str(&docengine::render_phrase(source)),
2431 Node::Token(tag) => tag_html(tag, opts, out),
2432 Node::Act(act) => act_html(act, opts, &Doc::bare(), out),
2433 Node::Meter(meter) => meter_html_into(&meter.as_layout(), opts, out),
2434 Node::Chart { axis, bars, .. } => chart_html_into(
2435 &axis.as_layout(),
2436 bars.iter().map(Bar::as_layout),
2437 opts,
2438 out,
2439 ),
2440 // Every other leaf the model admits into a run. A link in a row and a
2441 // figure in a row were the two gaps the enumeration left open and could
2442 // not close without a `RowPart` variant each; here they arrive by
2443 // already being leaves.
2444 //
2445 // No fills: a bespoke region is a block and cannot reach a run, which
2446 // is what the containment bound guarantees.
2447 other => node_html(other, opts, &Doc::bare(), out),
2448 }
2449 }
2450
2451 /// One cell's inline run.
2452 ///
2453 /// A part per inline rather than a part per cell, which is the half of the
2454 /// containment model this crate had to learn. Before it, a cell was four
2455 /// members and this function was a fixed sequence: the value or the link, then
2456 /// the tokens strip, then the actions strip. The run says the order itself, so
2457 /// a cell holding a tag between two words draws that way instead of hoisting
2458 /// the tag to the end.
2459 ///
2460 /// Consecutive tokens and consecutive acts still share one wrapping strip.
2461 /// `cell-tokens` and `cell-actions` carry the gap between siblings, so a span
2462 /// each would space them as though they were unrelated, and the common case --
2463 /// a status column of three badges -- is exactly the consecutive one.
2464 ///
2465 /// Writes into a buffer the caller owns rather than answering with one. The
2466 /// caller is [`cells_row_html`], which needs every cell of a row to exist at
2467 /// once because `cells_html` takes them together -- but needs that only within
2468 /// the row, so the buffers are reused down the table and a fresh `String` per
2469 /// cell per row was the emitter's largest remaining cost.
2470 fn cell_run_html(cell: &Cell, opts: &Emit, out: &mut String) {
2471 // A cell that is one piece of text says so on the container through
2472 // `CellPart::Value`, so a wrapper span here would say nothing the container
2473 // has not. Anything else names its parts inside, or the content colour on
2474 // the cell reaches the tokens and the controls beside the text -- the drift
2475 // makeover-layout 0.14.0 named and makeover-webview 0.25.0 stopped
2476 // emitting.
2477 if let [Node::Text { text, .. }] = cell.content.as_slice() {
2478 escape_into(text, out);
2479 return;
2480 }
2481
2482 let mut rest = cell.content.as_slice();
2483 while let Some((head, tail)) = rest.split_first() {
2484 match head {
2485 Node::Text { text, .. } => {
2486 out.push_str("<span");
2487 class_attr(&[cell_part_class(layout::CellPart::Value)], opts, out);
2488 out.push('>');
2489 escape_into(text, out);
2490 out.push_str("</span>");
2491 rest = tail;
2492 }
2493 // The row is a `div` and not an anchor even when it activates, so
2494 // this nests nothing: the href lands on the row element as an
2495 // attribute htmx reads, and the only `<a>` in the row is the one a
2496 // cell asked for.
2497 //
2498 // `data-act` for the same reason a button carries it. The row's
2499 // `ClickBeside` filter keys on that attribute, so without it a click
2500 // on the title would follow the link and open the row underneath.
2501 Node::Link { text, action } => {
2502 let (open, close) = control_tag(action);
2503 out.push_str(open);
2504 class_attr(&[cell_part_class(layout::CellPart::Link)], opts, out);
2505 out.push_str(" data-act");
2506 action_attrs(action, Fires::Click, None, None, false, out);
2507 out.push('>');
2508 escape_into(text, out);
2509 out.push_str(close);
2510 rest = tail;
2511 }
2512 Node::Token(_) => {
2513 let run = rest.iter().take_while(|p| matches!(p, Node::Token(_)));
2514 out.push_str("<span");
2515 class_attr(&[cell_part_class(layout::CellPart::Tokens)], opts, out);
2516 out.push('>');
2517 let mut taken = 0;
2518 for part in run {
2519 if let Node::Token(tag) = part {
2520 tag_html(tag, opts, out);
2521 }
2522 taken += 1;
2523 }
2524 out.push_str("</span>");
2525 rest = &rest[taken..];
2526 }
2527 // Deliberately not `row-actions`. That was a hover-reveal rule
2528 // until makeover-webview 0.23.0 retired it, and it is a list row's
2529 // class besides: `cell-actions` is the table's own, and it carries
2530 // no colour so a button here is not painted as text.
2531 Node::Act(_) => {
2532 let run = rest.iter().take_while(|p| matches!(p, Node::Act(_)));
2533 out.push_str("<span");
2534 class_attr(&[cell_part_class(layout::CellPart::Actions)], opts, out);
2535 out.push('>');
2536 let mut taken = 0;
2537 for part in run {
2538 if let Node::Act(act) = part {
2539 act_html(act, opts, &Doc::bare(), out);
2540 }
2541 taken += 1;
2542 }
2543 out.push_str("</span>");
2544 rest = &rest[taken..];
2545 }
2546 // Every other leaf the model now admits into a run. A meter and a
2547 // figure in a cell were the two gaps the enumeration left open and
2548 // could not close without a member each; here they arrive by
2549 // already being leaves.
2550 other => {
2551 // No fills: a bespoke region is a block and cannot reach a run,
2552 // which the containment bound is what guarantees.
2553 node_html(other, opts, &Doc::bare(), out);
2554 rest = tail;
2555 }
2556 }
2557 }
2558 }
2559
2560 /// The buffers a table's rows take turns in.
2561 ///
2562 /// `cells_html` takes a row's cells together, so the markup of every cell in one
2563 /// row has to exist at once. Nothing says it has to be new: cleared and
2564 /// refilled, these keep the capacity the first row bought, and a table's second
2565 /// row onwards writes into memory that already exists.
2566 ///
2567 /// [`Emitted`] is not here, because it borrows from `filled` and a struct
2568 /// holding both would be self-referential. It is a `Vec` per row and stays one.
2569 #[derive(Default)]
2570 struct RowBuffers {
2571 /// One cell's markup each, in column order.
2572 filled: Vec<String>,
2573 /// Which cells are a single piece of text, which is what earns the
2574 /// container the part class.
2575 parts: Vec<Option<layout::CellPart>>,
2576 }
2577
2578 /// One row of a table.
2579 ///
2580 /// Takes the columns already borrowed rather than the described ones. They are
2581 /// the same for every row of the table, and this built the borrowed list again
2582 /// per row until the emitter's allocations were counted.
2583 fn cells_row_html(
2584 cells: &Row,
2585 columns: &[layout::Column<'_>],
2586 folded: bool,
2587 buffers: &mut RowBuffers,
2588 opts: &Emit,
2589 out: &mut String,
2590 ) {
2591 let classes = row_classes(cells, RowLook::Table);
2592 out.push_str(RowLook::Table.open_tag());
2593 class_attr(&classes, opts, out);
2594 row_attrs_html(cells, RowLook::Table, folded, out);
2595 // The activate route, which only a table row carries in its opening tag: a
2596 // list row's is written by its run instead. Kept here rather than moved
2597 // into the shared attrs because the two really do differ.
2598 if let Some(action) = &cells.activate {
2599 let carries_control = cells.cells.iter().any(Cell::carries_control);
2600 let fires = if carries_control || cells.selected.is_some() {
2601 Fires::ClickBeside
2602 } else {
2603 Fires::Click
2604 };
2605 action_attrs(action, fires, None, None, cells.chosen.is_some(), out);
2606 }
2607 out.push('>');
2608
2609 // The tick, in a cell of its own before the columns. A list row draws this
2610 // in its gutter and a table has no gutter, so the gutter is a cell -- the
2611 // head emits a matching one, because `display: table` aligns by position.
2612 //
2613 // The same `row-select` class a list row's tick carries, and deliberately:
2614 // it is the selector a commit control gathers by, so a screen mixing a list
2615 // and a table has one selection rather than two that look alike. A cell
2616 // class as well, for the width, since this one is not a column and takes no
2617 // `col-` class.
2618 // The disclosure, in a cell of its own before the tick, on the grounds the
2619 // tick takes its own: it is not a value in the grid, it does not narrow
2620 // with the columns and it does not sort. The head emits a matching cell,
2621 // because `display: table` aligns by position.
2622 if let Some(open) = cells.open {
2623 out.push_str("<div");
2624 class_attr(&["cell", "table-disclose"], opts, out);
2625 out.push_str("><button type=\"button\"");
2626 class_attr(&["row-disclose"], opts, out);
2627 out.push_str(" data-disclose aria-expanded=\"");
2628 out.push_str(if open { "true" } else { "false" });
2629 out.push_str("\" aria-label=\"");
2630 out.push_str(if open { "Collapse" } else { "Expand" });
2631 out.push_str("\"></button></div>");
2632 }
2633
2634 if let Some(ticked) = cells.selected {
2635 // A `div` with no role, matching the cells makeover emits beside it.
2636 // The row says `role="row"` and its cells say nothing, which is
2637 // makeover's shape; a lone `role="cell"` here would be the one cell in
2638 // the table announcing itself differently from its neighbours.
2639 out.push_str("<div");
2640 class_attr(&["cell", "table-select"], opts, out);
2641 out.push_str("><input type=\"checkbox\"");
2642 class_attr(&["row-select"], opts, out);
2643 if ticked {
2644 out.push_str(" checked");
2645 }
2646 if let Some(value) = &cells.value {
2647 out.push_str(" name=\"");
2648 escape_into(quasi_router::Node::TICKED, out);
2649 out.push_str("\" value=\"");
2650 escape_into(value, out);
2651 out.push('"');
2652 }
2653 // No `toggle` counterpart, unlike a list row. A table row's tick is a
2654 // member of a set by construction (`Row::ticking` is the only way to
2655 // set it), where a list row's may be the write itself.
2656 out.push_str(" aria-label=\"Select\"></div>");
2657 }
2658
2659 // The cell contents are escaped here and handed over as Markup, which is
2660 // makeover-webview's contract: it owns the structure, the caller owns what
2661 // goes in. Ours is text from a description plus, since `022f0c59`, whatever
2662 // controls the cell carries, and `cells_html` is what knows the column
2663 // classes and the narrowing.
2664 let RowBuffers { filled, parts } = buffers;
2665 filled.truncate(cells.cells.len());
2666 for (at, cell) in cells.cells.iter().enumerate() {
2667 match filled.get_mut(at) {
2668 Some(buffer) => buffer.clear(),
2669 None => filled.push(String::new()),
2670 }
2671 cell_run_html(cell, opts, &mut filled[at]);
2672 }
2673 // The container says what the cell is only when the cell is nothing but one
2674 // piece of text, which is the case where a wrapper span would say nothing
2675 // the container has not already said. Anything else names its parts inside
2676 // -- the anchor is a `cell-link`, the strips are `cell-tokens` and
2677 // `cell-actions` -- because a colour on the container would reach all of
2678 // them, and that is the drift makeover-layout 0.14.0 named.
2679 parts.clear();
2680 parts.extend(cells.cells.iter().map(|cell| {
2681 matches!(cell.content.as_slice(), [Node::Text { .. }]).then_some(layout::CellPart::Value)
2682 }));
2683 let emitted: Vec<Emitted<'_>> = columns
2684 .iter()
2685 .zip(filled.iter())
2686 .zip(parts.iter().copied())
2687 .map(|((column, value), part)| Emitted {
2688 column: column.name,
2689 part,
2690 content: Markup(value),
2691 })
2692 .collect();
2693 let mut marked = crate::stage::Cursor::open(&cells.marks);
2694 if marked.watching() {
2695 // Told where each block went rather than looking for it. A cell that
2696 // renders to nothing cannot be searched for, and a cell whose markup
2697 // repeats in the same row would be found twice.
2698 let mut placed = Vec::new();
2699 makeover_webview::list::cells_html_placed(columns, &emitted, opts, out, &mut placed);
2700 for block in &placed {
2701 marked.wrote(block.start, block.end);
2702 }
2703 } else {
2704 cells_html_into(columns, &emitted, opts, out);
2705 }
2706
2707 // After the cells, and outside the grid: a menu is not a value in the row,
2708 // so it takes no column and the head emits nothing to match it. That is the
2709 // one way it differs from the tick above, which is a cell because it is
2710 // drawn in line with them.
2711 //
2712 // `data-menu="row"` is the list row's own attribute, deliberately: it is what
2713 // the host's menu code binds to, and a second name for "this row's menu"
2714 // would make a screen mixing a list and a table need two bindings for one
2715 // gesture. The class is `table-row-menu`, matching how `table-row-current`
2716 // shadows `row-current` -- that half is styling, and a table row's menu is
2717 // positioned against a grid rather than against a line.
2718 //
2719 // `hidden` keeps it out of the accessibility tree, which is also what makes
2720 // it legal here: a `role="row"` whose visible children are all cells stays
2721 // well-formed, and a `display: none` child takes no track in either a table
2722 // or a grid, so the columns above line up unchanged.
2723 if !cells.menu.is_empty() {
2724 out.push_str("<div");
2725 class_attr(&["table-row-menu"], opts, out);
2726 out.push_str(" data-menu=\"row\" hidden>");
2727 for act in &cells.menu {
2728 marked.starts(out);
2729 act_html(act, opts, &Doc::bare(), out);
2730 marked.ends(out);
2731 }
2732 out.push_str("</div>");
2733 }
2734
2735 marked.close(&cells.marks);
2736 out.push_str(RowLook::Table.close_tag());
2737 }
2738
2739 /// The class saying when a region member drops.
2740 ///
2741 /// `makeover-webview`'s table half already maps a [`layout::Priority`] onto
2742 /// one of these, and the stylesheet already hides them at the right size
2743 /// classes: `cell-drops-first` at `Medium` and below, `cell-drops-next` at
2744 /// `Compact`. Both rules are written against the bare class rather than
2745 /// against a table, so a region member wearing one narrows with the tables
2746 /// beside it and by the same declared rule.
2747 ///
2748 /// The names say "cell" because that is where they were needed first. Reusing
2749 /// them is deliberate: a second set of names would be a second encoding of one
2750 /// fact, and the two would have to be kept in agreement by hand. Renaming the
2751 /// pair to something placement-neutral belongs in `makeover-webview`, and the
2752 /// mapping wants to be public there rather than copied here -- it is private
2753 /// today, which is the only reason this function exists.
2754 fn drop_class(priority: layout::Priority) -> Option<&'static str> {
2755 match priority {
2756 layout::Priority::Optional => Some("cell-drops-first"),
2757 layout::Priority::Secondary => Some("cell-drops-next"),
2758 // A tier added upstream keeps its place. Of the two ways to be wrong
2759 // about a priority this renderer has not learned, showing something
2760 // that should have dropped is the one the reader can see and work
2761 // around.
2762 _ => None,
2763 }
2764 }
2765
2766 /// One member of a region, and what it is worth when room runs out.
2767 ///
2768 /// The wrapper appears only for a member that can drop, so every region
2769 /// written before [`quasi_router::Ranked`] existed -- which is all of them,
2770 /// since [`Slot::with`] still ranks at `Essential` -- emits exactly the markup
2771 /// it always did.
2772 fn ranked_html(placed: &quasi_router::Ranked, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
2773 let drops = drop_class(placed.priority);
2774 let divides = share_attr(placed.width).is_some();
2775 if drops.is_none() && !divides {
2776 node_html(&placed.node, opts, doc, out);
2777 return;
2778 }
2779 out.push_str("<div");
2780 if let Some(drops) = drops {
2781 class_attr(&[drops], opts, out);
2782 }
2783 if let Some(share) = share_attr(placed.width) {
2784 out.push_str(" data-width=\"");
2785 out.push_str(share);
2786 out.push('"');
2787 }
2788 out.push('>');
2789 node_html(&placed.node, opts, doc, out);
2790 out.push_str("</div>");
2791 }
2792
2793 /// How much of its row a member asked for, where it is not the default.
2794 ///
2795 /// `width_attr`'s attribute and the opposite default, which is the whole
2796 /// difference between the two positions. A control's width defaults to
2797 /// [`layout::Width::Fill`] because that is what a control did before the member
2798 /// existed; a run member's defaults to [`layout::Width::Content`] because a
2799 /// flex item with `min-width: min-content` and no grow is content-sized. So
2800 /// each side omits the value that changes nothing, and they omit opposite ones.
2801 ///
2802 /// [`layout::Width::Fixed`] is stated and not implemented. A run carries no
2803 /// size by design, so there is nothing to fix a member at, and the honest
2804 /// answer is to carry the description's word into the markup and let the
2805 /// stylesheet say nothing about it yet.
2806 fn share_attr(width: layout::Width) -> Option<&'static str> {
2807 match width {
2808 layout::Width::Content => None,
2809 layout::Width::Fixed => Some("fixed"),
2810 layout::Width::Fill => Some("fill"),
2811 // `Width` is `#[non_exhaustive]`; a member added upstream is one this
2812 // renderer has not learned, and drawing it as content is what it drew
2813 // before the member existed.
2814 _ => None,
2815 }
2816 }
2817
2818 /// One thing on a screen.
2819 pub(crate) fn node_html(node: &Node, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
2820 match node {
2821 Node::Heading { level, text } => {
2822 let tag = match level {
2823 layout::Heading::Page => "h1",
2824 layout::Heading::Section => "h2",
2825 layout::Heading::Subsection => "h3",
2826 };
2827 out.push('<');
2828 out.push_str(tag);
2829 class_attr(&["heading"], opts, out);
2830 out.push('>');
2831 escape_into(text, out);
2832 out.push_str("</");
2833 out.push_str(tag);
2834 out.push('>');
2835 }
2836
2837 Node::Text { text, tone } => {
2838 out.push_str("<p");
2839 class_attr(&["text"], opts, out);
2840 tone_attr(*tone, out);
2841 out.push('>');
2842 escape_into(text, out);
2843 out.push_str("</p>");
2844 }
2845
2846 // `19d7602d`. The runs are already classified, so this writes a span
2847 // per run carrying the class name `layout::Syntax` spells and nothing
2848 // else: no highlighter enters this crate, and a host's stylesheet says
2849 // what each class looks like. `docengine::Emphasis` crosses the seam
2850 // the same way.
2851 //
2852 // A run at `Syntax::Plain` gets no span at all. It is most of a file,
2853 // its class would say only "ordinary", and a span per word triples the
2854 // document for nothing.
2855 Node::Code {
2856 runs,
2857 language,
2858 inline,
2859 } => {
2860 let (open, close) = if *inline {
2861 ("<code", "</code>")
2862 } else {
2863 ("<pre", "</pre>")
2864 };
2865 out.push_str(open);
2866 class_attr(&["code"], opts, out);
2867 // The hint, as an attribute rather than a class: a language is app
2868 // text and a class built from it would be a class name built from
2869 // arbitrary input. A host keying off it has `[data-language]`.
2870 if let Some(language) = language {
2871 out.push_str(" data-language=\"");
2872 escape_into(language, out);
2873 out.push('"');
2874 }
2875 out.push('>');
2876 for run in runs {
2877 if run.syntax == layout::Syntax::Plain {
2878 escape_into(&run.text, out);
2879 continue;
2880 }
2881 out.push_str("<span");
2882 // Not `class_attr`: that prefixes with the emitter's own
2883 // namespace, and these are one class each from a closed set the
2884 // vocabulary spells. `lex-` rather than a bare `keyword`,
2885 // because `.string` and `.comment` are names a host stylesheet
2886 // will already have used for something else.
2887 out.push_str(" class=\"lex-");
2888 out.push_str(run.syntax.name());
2889 out.push_str("\">");
2890 escape_into(&run.text, out);
2891 out.push_str("</span>");
2892 }
2893 out.push_str(close);
2894 }
2895
2896 Node::StandIn {
2897 state,
2898 message,
2899 act,
2900 marks,
2901 } => {
2902 // The markup is makeover-webview's, unchanged, for the reason every
2903 // other emitter here defers to it: the CSS that has to match it is
2904 // emitted there too. The way out arrives as `Markup` because a
2905 // button is an address and no crate down there names one.
2906 let mut way_out = String::new();
2907 if let Some(act) = act {
2908 act_html(act, opts, doc, &mut way_out);
2909 }
2910 let action = (!way_out.is_empty()).then_some(Markup(way_out.as_str()));
2911 // The way out is the one member a placeholder holds, and the branch
2912 // covers the wrapper with it: `placeholder-action` is emitted only
2913 // when there is something to put in it, so it goes when the way out
2914 // goes.
2915 let mut marked = crate::stage::Cursor::open(marks);
2916 if marked.watching() {
2917 let mut placed = None;
2918 makeover_webview::placeholder::placeholder_html_placed(
2919 *state,
2920 message,
2921 action,
2922 opts,
2923 out,
2924 &mut placed,
2925 );
2926 if let Some(block) = placed {
2927 marked.wrote(block.start, block.end);
2928 }
2929 marked.close(marks);
2930 } else {
2931 placeholder_html_into(*state, message, action, opts, out);
2932 }
2933 }
2934
2935 Node::Rich {
2936 source,
2937 richness,
2938 trust,
2939 } => {
2940 out.push_str("<div");
2941 class_attr(&["rich"], opts, out);
2942 // The fence hyperscript owes, wherever text a user typed becomes
2943 // markup. `0081563e`: a loaded interpreter reads `_` attributes
2944 // anywhere in the document, creator-authored content included, and
2945 // this is the one node whose content is authored by somebody other
2946 // than the app.
2947 //
2948 // Belt and braces rather than the only guard, and it earns its
2949 // place now that `Trust::Trusted` exists: an untrusted source has
2950 // its raw HTML stripped and never reaches this fence with an
2951 // attribute in it, but a trusted one keeps its markup, so the fence
2952 // is what stops the app's own copy from carrying a `_` attribute
2953 // into a document with a loaded interpreter. The guarantee no
2954 // longer depends on which way the trust axis is set.
2955 out.push_str(" data-disable-scripting");
2956 out.push('>');
2957 out.push_str(&rich_html(source, *richness, *trust));
2958 out.push_str("</div>");
2959 }
2960
2961 Node::Act(act) => act_html(act, opts, doc, out),
2962
2963 // Text that goes somewhere. `control_tag` picks the anchor or the
2964 // button from the method, the same way a cell link already did, and
2965 // `link` is the class makeover names it with.
2966 Node::Link { text, action } => {
2967 let (open, close) = control_tag(action);
2968 out.push_str(open);
2969 class_attr(&["link"], opts, out);
2970 out.push_str(" data-act");
2971 action_attrs(action, Fires::Click, None, None, false, out);
2972 out.push('>');
2973 escape_into(text, out);
2974 out.push_str(close);
2975 }
2976
2977 Node::Token(tag) => tag_html(tag, opts, out),
2978
2979 // The readouts derived from the current time. The first words are made
2980 // here and every set after them by `clock.js`, which reads the two
2981 // attributes this writes; see `crate::clock` for why the format is
2982 // stated twice and `Shell::clock_src` for what a document without the
2983 // script gets.
2984 Node::Since { at } => clock_html(Clock::Since, *at, opts, out),
2985 Node::Until { at } => clock_html(Clock::Until, *at, opts, out),
2986 Node::Age { at } => clock_html(Clock::Age, *at, opts, out),
2987
2988 // One figure, through the same emitter the strip uses. What differs is
2989 // that nothing wraps it: the run it sits in is already the grouping.
2990 Node::Figure(figure) => {
2991 figure_html_into(&figure.as_layout(), opts, out);
2992 }
2993
2994 Node::Image(picture) => {
2995 // A captioned picture is a `<figure>`, which is what the element is
2996 // for and what the shipped carousel already writes by hand. An
2997 // uncaptioned one is the bare `<img>`: wrapping it would put a
2998 // grouping element around a group of one.
2999 let captioned = picture.caption.is_some();
3000 if captioned {
3001 out.push_str("<figure");
3002 class_attr(&["picture"], opts, out);
3003 out.push('>');
3004 }
3005
3006 img_tag(picture, opts, out);
3007
3008 if let Some(caption) = &picture.caption {
3009 out.push_str("<figcaption");
3010 class_attr(&["picture-caption"], opts, out);
3011 out.push('>');
3012 escape_into(caption, out);
3013 out.push_str("</figcaption></figure>");
3014 }
3015 }
3016
3017 Node::Notice {
3018 kind,
3019 tone,
3020 text,
3021 act,
3022 } => {
3023 out.push_str("<div");
3024 class_attr(
3025 &[match kind {
3026 layout::Notice::Toast => "toast",
3027 layout::Notice::Banner => "banner",
3028 }],
3029 opts,
3030 out,
3031 );
3032 tone_attr(*tone, out);
3033 // The hook the clock script takes a toast away by. An attribute
3034 // rather than the class above, for `data-clock`'s reason: a class
3035 // is prefixed by host configuration and a script shipped by this
3036 // crate cannot know the prefix. `4453bf82`.
3037 //
3038 // Emitted for the transient kind only, because it means "this one
3039 // is on a clock" rather than "this one is a notice". A banner has
3040 // no deadline and nothing to say to the script.
3041 if kind.transient() {
3042 out.push_str(" data-notice=\"toast\"");
3043 }
3044 // A danger or warning notice interrupts; anything else waits for a
3045 // pause. The description already says which through its tone, so
3046 // the renderer does not need a second field to be told.
3047 let assertive = matches!(tone, layout::Tone::Danger | layout::Tone::Warning);
3048 if assertive {
3049 out.push_str(" role=\"alert\"");
3050 } else {
3051 out.push_str(" role=\"status\" aria-live=\"polite\"");
3052 }
3053 out.push('>');
3054 escape_into(text, out);
3055 // The one thing to do about it, inside the notice rather than
3056 // beside it: a control that scrolled away from the sentence
3057 // explaining it is a button with no subject. `bde35298`.
3058 if let Some(act) = act {
3059 act_html(act, opts, doc, out);
3060 }
3061 out.push_str("</div>");
3062 }
3063
3064 Node::Field(field) => field_group_html(field, opts, doc, out),
3065
3066 Node::Form {
3067 action,
3068 submit,
3069 fields,
3070 marks,
3071 } => {
3072 out.push_str("<form");
3073 class_attr(&["form"], opts, out);
3074 action_attrs(action, Fires::Submit, None, None, false, out);
3075 out.push('>');
3076 let mut marked = crate::stage::Cursor::open(marks);
3077 for field in fields {
3078 marked.starts(out);
3079 field_group_html(field, opts, doc, out);
3080 marked.ends(out);
3081 }
3082 marked.close(marks);
3083 out.push_str("<button type=\"submit\"");
3084 class_attr(&["button", "act-submit"], opts, out);
3085 out.push('>');
3086 escape_into(submit, out);
3087 out.push_str("</button></form>");
3088 }
3089
3090 Node::Timeline {
3091 track,
3092 entries,
3093 focus,
3094 ..
3095 } => {
3096 // The axis first, then the things on it. Two children of one
3097 // positioned box, so the entries resolve their percentages against
3098 // the same height the slots fill.
3099 out.push_str("<div");
3100 class_attr(&["track"], opts, out);
3101 // The focus travels as data rather than as a scroll offset: the app
3102 // knows the interesting hour, the host decides how to get there.
3103 // goingson's JS hardcodes `targetHour = 9` inside its renderer,
3104 // which is the arrangement this replaces.
3105 if let Some(minute) = focus {
3106 let _ = write!(out, " data-focus=\"{minute}\"");
3107 }
3108 out.push('>');
3109
3110 // The ruler. Slots are the grid the eye reads against; a tick every
3111 // `tick` minutes carries the label. Both are the axis describing
3112 // itself, so neither is an entry and neither is addressable.
3113 let slots = track.slots();
3114 let tick_every = if track.slot == 0 || track.tick == 0 {
3115 0
3116 } else {
3117 track.tick / track.slot
3118 };
3119 for slot in 0..slots {
3120 out.push_str("<div");
3121 class_attr(&["track-slot"], opts, out);
3122 out.push('>');
3123 if tick_every > 0 && slot % tick_every == 0 {
3124 let minute = track.span.from() + slot * track.slot;
3125 out.push_str("<span");
3126 class_attr(&["track-tick"], opts, out);
3127 out.push('>');
3128 // The label the axis's unit asks for. This assumed minutes
3129 // until 2026-08-15 and printed `00:00` over a month strip,
3130 // which is the defect `layout::Unit` exists to close: the
3131 // geometry above is unit-agnostic and correct either way,
3132 // so nothing else here noticed.
3133 match track.unit {
3134 // Wall clock, wrapped, so a span running past midnight
3135 // labels 02:00 rather than 26:00. The wrap is
3136 // presentation: `Span` deliberately counts past 1440 so
3137 // it needs no date, and how that reads to a person is
3138 // this renderer's call.
3139 layout::Unit::Minutes => {
3140 let _ = write!(out, "{:02}:{:02}", (minute / 60) % 24, minute % 60);
3141 }
3142 // Day one, not day zero. A strip's offsets are
3143 // zero-based like every other axis here, and nobody
3144 // calls the first of the month the zeroth.
3145 layout::Unit::Days => {
3146 let _ = write!(out, "{}", minute + 1);
3147 }
3148 // A unit added later lands here rather than silently
3149 // taking the clock, which is exactly how this bug was
3150 // shipped in the first place.
3151 _ => {}
3152 }
3153 out.push_str("</span>");
3154 }
3155 out.push_str("</div>");
3156 }
3157
3158 // Lanes. Overlapping entries sit side by side, and which lane each
3159 // takes is worked out here rather than described, because it is a
3160 // fact about how wide the box is and not about the day. The
3161 // description said when things happen; `Placement::overlaps` turns
3162 // that into who collides.
3163 //
3164 // Greedy first-fit against the entries already placed, which is the
3165 // standard day-view packing: an entry takes the lowest lane no
3166 // occupant of which it overlaps. O(n^2) in the worst case and n is a
3167 // day's worth of appointments, so the clever interval graph is not
3168 // worth its own bugs here.
3169 let mut lanes: Vec<usize> = Vec::with_capacity(entries.len());
3170 for (i, entry) in entries.iter().enumerate() {
3171 let mut lane = 0;
3172 loop {
3173 let taken = entries[..i]
3174 .iter()
3175 .zip(&lanes)
3176 .any(|(other, &l)| l == lane && entry.overlaps(other));
3177 if !taken {
3178 break;
3179 }
3180 lane += 1;
3181 }
3182 lanes.push(lane);
3183 }
3184 // One width for the whole track rather than per collision cluster.
3185 // Per-cluster is denser and is a layout decision this renderer can
3186 // revisit without the description changing, which is the point of
3187 // it being here.
3188 let width = lanes.iter().copied().max().map_or(1, |m| m + 1);
3189
3190 for (entry, lane) in entries.iter().zip(&lanes) {
3191 let at = track.fraction(entry.placement.at());
3192 let end = track.fraction(entry.placement.end());
3193 out.push_str("<div");
3194 class_attr(&["track-entry"], opts, out);
3195 // The only inline style this crate writes, and it carries no
3196 // colour, no size and no opinion: four numbers makeover's rules
3197 // read. A stylesheet cannot hold these, because they are the
3198 // data.
3199 let _ = write!(
3200 out,
3201 " style=\"--track-at:{:.4}%;--track-for:{:.4}%;--track-lane:{lane};--track-lanes:{width}\"",
3202 at * 100.0,
3203 (end - at) * 100.0
3204 );
3205 out.push('>');
3206 row_html(&entry.row, false, opts, out);
3207 out.push_str("</div>");
3208 }
3209
3210 out.push_str("</div>");
3211 }
3212
3213 Node::Table {
3214 columns,
3215 rows,
3216 more,
3217 marks,
3218 } => {
3219 // **A table that declared no columns is a list, and draws as one.**
3220 // One node since the 2026-09-06 collapse, and this is the whole of
3221 // where the two arrangements part company in this renderer.
3222 //
3223 // `<ul>` rather than `role="table"` over a feed, deliberately. The
3224 // model is unified and the presentation is not: a feed of items is a
3225 // list to a screen reader, and announcing it as a table with one
3226 // column would be a worse document rather than a more consistent
3227 // one. The rows are the same `Row` either way, which is the point --
3228 // nothing about what the description *says* changes here, only how
3229 // it is arranged.
3230 if columns.is_empty() {
3231 out.push_str("<ul");
3232 class_attr(&["list"], opts, out);
3233 out.push('>');
3234 // Which rows a shut branch folds away, asked once for the list
3235 // rather than per row: it is a fact about the run of rows and a
3236 // row cannot see the ones above it. Every row is emitted either
3237 // way and the folded ones are `hidden`, so opening a branch is a
3238 // class change in the browser rather than a round trip for rows
3239 // the document already holds.
3240 let folded = quasi_router::folded(rows);
3241 // A list declared no columns, so its index space is the rows
3242 // and then the pager. `Table::placed` counts the columns first
3243 // and there are none, so the two agree without a special case.
3244 let mut marked = crate::stage::Cursor::open(marks);
3245 for (row, folded) in rows.iter().zip(folded) {
3246 marked.starts(out);
3247 row_html(row, folded, opts, out);
3248 marked.ends(out);
3249 }
3250 out.push_str("</ul>");
3251
3252 // Outside the list, because it is not one of the things in it.
3253 if let Some(rest) = more {
3254 marked.starts(out);
3255 rest_html(rest, opts, out);
3256 marked.ends(out);
3257 }
3258 marked.close(marks);
3259 return;
3260 }
3261
3262 let borrowed: Vec<layout::Column<'_>> = columns
3263 .iter()
3264 .map(quasi_router::screen::Column::as_layout)
3265 .collect();
3266 // No CSS travels with the table, and none can. A described table's
3267 // columns are known here rather than at build time, so a track list
3268 // would have to be emitted per table: a `<style>` element beside it,
3269 // which needs `style-src 'unsafe-inline'`, or a head block, which a
3270 // fragment swap does not carry. makeover's stylesheet lays the table
3271 // out with `display: table` instead, aligning columns across rows
3272 // knowing nothing about how many there are, and hides a dropped
3273 // column by the drop class its cells carry. The markup owes the
3274 // cells and headings those classes and nothing more.
3275 out.push_str("<div role=\"table\"");
3276 class_attr(&["table"], opts, out);
3277 out.push('>');
3278
3279 // A tick takes no column, so the header owes it a cell that names
3280 // none. `display: table` aligns by position, so the gutter has to
3281 // exist in the head or every heading sits one place left of the
3282 // values under it. Empty rather than holding a select-all box: the
3283 // description says which rows exist and this crate emits no script
3284 // of its own, so ticking them all is a route the app offers (the
3285 // task list's `ticked=all`) rather than something a renderer can
3286 // invent.
3287 let ticks = rows.iter().any(|row| row.selected.is_some());
3288 // The disclosure gutter, on the same terms: a cell in the head or
3289 // every heading sits one place left of the values under it.
3290 let branches = rows.iter().any(|row| row.open.is_some());
3291
3292 out.push_str("<div role=\"row\"");
3293 class_attr(&["table-head"], opts, out);
3294 out.push('>');
3295 if branches {
3296 out.push_str("<span role=\"columnheader\"");
3297 class_attr(&["table-heading", "table-disclose-head"], opts, out);
3298 out.push_str(" aria-label=\"Expand\"></span>");
3299 }
3300 if ticks {
3301 // `table-heading` as well, so it takes the head row's own
3302 // treatment and the gutter does not read as a gap. Labelled
3303 // rather than captioned: there is nothing to write above a
3304 // column of checkboxes that is not noise for everyone who can
3305 // see them, and a bare `columnheader` announces as an empty
3306 // heading to everyone who cannot.
3307 out.push_str("<span role=\"columnheader\"");
3308 class_attr(&["table-heading", "table-select-head"], opts, out);
3309 out.push_str(" aria-label=\"Select\"></span>");
3310 }
3311 let mut marked = crate::stage::Cursor::open(marks);
3312 for (column, described) in borrowed.iter().zip(columns) {
3313 marked.starts(out);
3314 out.push_str("<span role=\"columnheader\"");
3315 // The heading carries the same classes its column's cells do,
3316 // or the header and the body disagree about which column just
3317 // dropped and every heading below the cut sits over the wrong
3318 // values. `column_classes` is makeover's, so the two lists
3319 // cannot be assembled differently in two places.
3320 out.push_str(" class=\"");
3321 class_into("table-heading", opts, out);
3322 out.push(' ');
3323 // Not escaped, unlike every other app-supplied string here.
3324 // makeover reduces a column name to identifier characters
3325 // rather than escaping it (0.41.0), because the same name is
3326 // written into a CSS selector by `narrowing_css` and an escaped
3327 // one would be safe in the attribute and unmatchable from the
3328 // stylesheet. Escaping the result again would encode nothing
3329 // and is the one way this heading could stop matching the cells
3330 // below it.
3331 push_column_classes(out, column, opts);
3332 out.push('"');
3333 // `aria-sort` is what a table actually says about its order;
3334 // the caret in `state_rules` is this renderer's expression of
3335 // the same fact for everyone not using a screen reader.
3336 if let Some(sort) = column.sorted {
3337 out.push_str(" aria-sort=\"");
3338 out.push_str(sort.as_str());
3339 out.push('"');
3340 }
3341 if column.sortable {
3342 out.push_str(" data-sortable");
3343 }
3344 out.push('>');
3345 match &described.reorder {
3346 // A button inside the header cell rather than attributes on
3347 // the cell itself: `role="columnheader"` is not a control,
3348 // and a screen reader offered a press on something that
3349 // announces itself as a heading has been lied to.
3350 Some(action) => {
3351 let (open, close) = control_tag(action);
3352 out.push_str(open);
3353 class_attr(&["table-sort"], opts, out);
3354 action_attrs(action, Fires::Click, None, None, false, out);
3355 out.push('>');
3356 escape_into(column.name, out);
3357 out.push_str(close);
3358 }
3359 None => escape_into(column.name, out),
3360 }
3361 out.push_str("</span>");
3362 marked.ends(out);
3363 }
3364 out.push_str("</div>");
3365
3366 let mut buffers = RowBuffers::default();
3367 // The fold, asked once for the table rather than per row: it is a
3368 // fact about the run of rows and a row cannot see the ones above it.
3369 let folds = quasi_router::folded(rows);
3370 for (cells, folded) in rows.iter().zip(folds) {
3371 marked.starts(out);
3372 cells_row_html(cells, &borrowed, folded, &mut buffers, opts, out);
3373 marked.ends(out);
3374 }
3375 out.push_str("</div>");
3376
3377 // After the table's own element and not inside it, the way a list's
3378 // pager sits outside the `<ul>`: a pager is not a row, and a
3379 // `role="table"` with a non-row child is a table that says something
3380 // untrue about its own shape.
3381 if let Some(rest) = more {
3382 marked.starts(out);
3383 rest_html(rest, opts, out);
3384 marked.ends(out);
3385 }
3386 marked.close(marks);
3387 }
3388
3389 // The trough and its tones are makeover-webview's, unchanged, for the
3390 // same reason the field markup is: a second emitter here would be the
3391 // same anatomy with a different escaping story, and the CSS it has to
3392 // match is emitted there too.
3393 Node::Meter(meter) => meter_html_into(&meter.as_layout(), opts, out),
3394 Node::Chart { axis, bars, marks } => {
3395 // Told where each bar went rather than looking for one. A bar's
3396 // markup is a function of its magnitude, so two bars of the same
3397 // height are the same bytes and searching would find either.
3398 let mut marked = crate::stage::Cursor::open(marks);
3399 if marked.watching() {
3400 let mut placed = Vec::new();
3401 makeover_webview::chart::chart_html_placed(
3402 &axis.as_layout(),
3403 bars.iter().map(Bar::as_layout),
3404 opts,
3405 out,
3406 &mut placed,
3407 );
3408 for block in &placed {
3409 marked.wrote(block.start, block.end);
3410 }
3411 marked.close(marks);
3412 } else {
3413 chart_html_into(
3414 &axis.as_layout(),
3415 bars.iter().map(Bar::as_layout),
3416 opts,
3417 out,
3418 );
3419 }
3420 }
3421
3422 // The strip and its tones are makeover-webview's too, and the actions
3423 // are this crate's: a figure that answers a click becomes a control
3424 // wrapped round the emitted markup rather than a second figure emitter
3425 // that knows about routes.
3426 Node::Stats { figures, marks } => {
3427 out.push_str("<div");
3428 class_attr(&["figures"], opts, out);
3429 out.push('>');
3430 let mut marked = crate::stage::Cursor::open(marks);
3431 for (figure, action) in figures {
3432 marked.starts(out);
3433 match action {
3434 Some(action) => {
3435 let (open, close) = control_tag(action);
3436 out.push_str(open);
3437 class_attr(&["figure-act"], opts, out);
3438 action_attrs(action, Fires::Click, None, None, false, out);
3439 out.push('>');
3440 figure_html_into(&figure.as_layout(), opts, out);
3441 out.push_str(close);
3442 }
3443 None => figure_html_into(&figure.as_layout(), opts, out),
3444 }
3445 marked.ends(out);
3446 }
3447 marked.close(marks);
3448 out.push_str("</div>");
3449 }
3450
3451 // The one member whose content this renderer does not build. `48a6e9e5`.
3452 //
3453 // The markup goes out as it arrived, which is the whole of what a canvas
3454 // is for and the whole of its risk: it is written by somebody who is not
3455 // the app, and the app is what sanitised it. Nothing here parses it,
3456 // because a second opinion formed from less context than the sanitiser
3457 // had is not a safety net -- it is a place for the two to disagree.
3458 //
3459 // The element carries the app's own class and id and none of this
3460 // renderer's. That is deliberate rather than an omission: the scope is a
3461 // contract between the app and the stylesheet it re-scoped, so a name
3462 // this crate prefixed or renamed would leave a sheet aimed at a selector
3463 // that matches nothing. They are escaped, being app strings in attribute
3464 // values, the same way `Document::body_class` is.
3465 Node::Canvas(canvas) => {
3466 out.push_str("<div");
3467 if let Some(class) = &canvas.class {
3468 out.push_str(" class=\"");
3469 escape_into(class, out);
3470 out.push('"');
3471 }
3472 if let Some(id) = &canvas.id {
3473 out.push_str(" id=\"");
3474 escape_into(id, out);
3475 out.push('"');
3476 }
3477 out.push('>');
3478 out.push_str(&canvas.markup);
3479 // After the markup and inside the scope, which is what lets a
3480 // creator's sheet reach the platform's own blocks. See
3481 // `Canvas::within`.
3482 for node in &canvas.within {
3483 node_html(node, opts, doc, out);
3484 }
3485 out.push_str("</div>");
3486 }
3487
3488 Node::Region(slot) => slot_html(slot, Asks::ForItself, opts, doc, out),
3489
3490 // A member added since this renderer last learned the vocabulary.
3491 // [`Node`] is `#[non_exhaustive]` so that landing one is not a lockstep
3492 // release across three renderers, and this arm is the price.
3493 //
3494 // It emits the same placeholder [`Node::StandIn`] does, because it is
3495 // the same situation said by the renderer instead of by the handler:
3496 // something is here and you are not seeing it. Emitting nothing would
3497 // leave a hole no reader could tell from a screen that never had the
3498 // part, and the markup and CSS for saying so already exist.
3499 //
3500 // `Readiness::Empty` and not `Failed`: nothing went wrong. The content
3501 // arrived intact and this renderer has not learned the word for it, so
3502 // a danger tone would report a fault the server did not have.
3503 _ => placeholder_html_into(
3504 layout::Readiness::Empty,
3505 "Not shown here yet.",
3506 None,
3507 opts,
3508 out,
3509 ),
3510 }
3511 }
3512
3513 /// A readout the browser keeps writing.
3514 ///
3515 /// A `<span>` and not a `<time>`: a `<time>` element's text has to be a machine
3516 /// readable datetime when it carries no `datetime` attribute, and "3h ago" is
3517 /// not one. Carrying both would mean emitting the instant twice in two
3518 /// spellings for an element nothing in these apps styles differently.
3519 fn clock_html(clock: Clock, at: std::time::SystemTime, opts: &Emit, out: &mut String) {
3520 out.push_str("<span");
3521 class_attr(&["clock"], opts, out);
3522 let _ = write!(
3523 out,
3524 " data-clock=\"{}\" data-at=\"{}\">",
3525 match clock {
3526 Clock::Since => "since",
3527 Clock::Until => "until",
3528 Clock::Age => "age",
3529 },
3530 crate::clock::epoch_millis(at)
3531 );
3532 // Escaped like every other string this crate emits, even though the only
3533 // thing that can be here is digits and colons this file made. The rule is
3534 // that text goes through the escaper; an exception argued from what the
3535 // value happens to be today is how the next one is written by hand.
3536 escape_into(
3537 &crate::clock::text(clock, at, std::time::SystemTime::now()),
3538 out,
3539 );
3540 out.push_str("</span>");
3541 }
3542
3543 /// A small labelled thing sitting inside something else.
3544 ///
3545 /// Took eight arguments, one per field of `Node::Token`, until the payload
3546 /// became [`Tag`] so a row could carry one. The `too_many_arguments` allow went
3547 /// with them.
3548 ///
3549 /// # What this renderer does with a hint
3550 ///
3551 /// Emits it as `title`. That is what the shipped goingson badges used and is
3552 /// the only standing-help affordance a browser gives a `span`, so a described
3553 /// badge draws what the hand-written one drew. It is hover-only and therefore
3554 /// unreachable by touch and unreliable to a screen reader, which is why
3555 /// [`Tag::hint`] says nothing may live only there.
3556 fn tag_html(tag: &Tag, opts: &Emit, out: &mut String) {
3557 let Tag {
3558 kind,
3559 label,
3560 tone,
3561 latched,
3562 action,
3563 hint,
3564 } = tag;
3565 let (kind, tone, latched) = (*kind, *tone, *latched);
3566 let action = action.as_ref();
3567
3568 let mut classes = vec![match kind {
3569 layout::Token::Badge => "badge",
3570 layout::Token::Chip { .. } => "chip",
3571 }];
3572 // `latched`, which is what makeover styles: `.chip.latched` is the pressed
3573 // depth a chip holds itself down with. This said `chip-latched`, a third
3574 // name for it, and a latched chip therefore looked exactly like an
3575 // unlatched one.
3576 if latched {
3577 classes.push("latched");
3578 }
3579
3580 // A badge answers no click, so it is not a button however it is styled.
3581 // The description says which through the kind, which is the whole reason
3582 // the two are separate members rather than one with a flag.
3583 let interactive = kind.interactive() && action.is_some();
3584 let close = if interactive {
3585 // An interactive tag whose destination leaves the app is an anchor, for
3586 // the reason `control_tag` gives. A tag that answers nothing stays a
3587 // span either way.
3588 let (open, close) = action.map_or(("<span", "</span>"), control_tag);
3589 out.push_str(open);
3590 close
3591 } else {
3592 out.push_str("<span");
3593 "</span>"
3594 };
3595 class_attr(&classes, opts, out);
3596 tone_attr(tone, out);
3597
3598 if interactive {
3599 // `data-act` for the same reason a button carries it: a chip that
3600 // answers a click is a control, and a table row filtering its own
3601 // trigger has to be able to tell one apart from its own text. A badge
3602 // never gets it, because a badge answers nothing.
3603 out.push_str(" data-act");
3604 if latched {
3605 // A chip standing for a filter is on or off, and its latched class
3606 // carries that fact visually through Depth::pressed either way.
3607 // Which word says it depends on what the chip turned out to be:
3608 // aria-pressed is a button's state and means nothing on an anchor,
3609 // and a link that is the view you are looking at is the one thing
3610 // aria-current exists to say.
3611 if action.is_some_and(is_link) {
3612 out.push_str(" aria-current=\"true\"");
3613 } else {
3614 out.push_str(" aria-pressed=\"true\"");
3615 }
3616 }
3617 if let Some(action) = action {
3618 action_attrs(action, Fires::Click, None, None, false, out);
3619 }
3620 }
3621
3622 // `436bc223`: the detail behind the label, as `title`, which is what the
3623 // shipped goingson badges use and is the only standing-help affordance a
3624 // browser gives a span. Hover-only, so it is genuinely a hint and the
3625 // vocabulary says so -- nothing may live only here.
3626 if let Some(hint) = hint {
3627 out.push_str(" title=\"");
3628 escape_into(hint, out);
3629 out.push('"');
3630 }
3631
3632 out.push('>');
3633 escape_into(label, out);
3634 if matches!(kind, layout::Token::Chip { removable: true }) {
3635 out.push_str("<span");
3636 class_attr(&["chip-remove"], opts, out);
3637 out.push_str(" aria-hidden=\"true\"></span>");
3638 }
3639 out.push_str(close);
3640 }
3641
3642 /// What tells the swap to leave this element alone.
3643 ///
3644 /// The third function `hx-` is allowed to appear in, and it is here rather than
3645 /// inlined for the reason the other two are named: decision 13 says the
3646 /// transport is replaceable, and a claim like that is only worth anything if
3647 /// something checks it. `htmx_enters_in_exactly_two_functions` is that check
3648 /// and it counts three now.
3649 ///
3650 /// A third and not a fourth reading of one fact. [`action_attrs`] says where a
3651 /// control sends and where its answer lands, [`oob_html`] says where a piece of
3652 /// the answer lands that no control asked for, and this says what the answer
3653 /// may not touch on the way in. Swapping htmx for something else rewrites the
3654 /// three of them together.
3655 ///
3656 /// Written without a leading space, which is [`Filling::control_attrs`]'s
3657 /// contract: the caller separates them.
3658 ///
3659 /// [`Filling::control_attrs`]: makeover_webview::form::Filling::control_attrs
3660 fn preserve_attr(out: &mut String) {
3661 out.push_str("hx-preserve=\"true\"");
3662 }
3663
3664 /// A region the answer changed without being aimed at it.
3665 ///
3666 /// The second half of the transport, and the reason this file's htmx test
3667 /// names two functions rather than one. [`action_attrs`] says where a control
3668 /// sends and where the answer lands; this says where a piece of the answer
3669 /// lands that no control asked for. Both are the same fact — how htmx is told
3670 /// to put markup somewhere — and a transport swapped for fixi moves both, which
3671 /// is what decision 13's claim needs.
3672 ///
3673 /// # Why `innerHTML:` and not a bare `true`
3674 ///
3675 /// A bare `hx-swap-oob="true"` replaces the element carrying the matching id
3676 /// outright, and the element in the document is [`slot_html`]'s `<div>` with
3677 /// the region's classes on it. Replacing it with what is emitted here would
3678 /// strip them, so the region would keep its contents and lose its layout.
3679 /// Addressing the swap by selector instead puts the markup *inside* the slot
3680 /// and leaves the wrapper alone, which is what [`Serves::fragment`] already
3681 /// means for the targeted region. The wrapper emitted here is htmx's envelope
3682 /// and never reaches the document.
3683 ///
3684 /// [`Serves::fragment`]: quasi_http::Serves::fragment
3685 pub(crate) fn oob_html(region: &str, node: &Node, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
3686 out.push_str("<div hx-swap-oob=\"innerHTML:#");
3687 // Escaped as an attribute, and otherwise untouched for `slot_html`'s
3688 // reason: this has to match the id that function emitted, and a slot id is
3689 // the address the description chose.
3690 escape_into(region, out);
3691 out.push_str("\">");
3692 node_html(node, opts, doc, out);
3693 out.push_str("</div>");
3694 }
3695
3696 /// Write a frame's `class="..."`, prefixed, plus `current` if it is the one,
3697 /// plus what addresses it.
3698 ///
3699 /// `current` is written bare rather than through [`class_attr`], because
3700 /// `showing_rules` writes `.showing-frame:not(.current)` and the state half of
3701 /// that pair does not move under a prefix. Same shape as makeover's `chosen`
3702 /// and `latched`: the thing is prefixed, the state qualifying it is not.
3703 ///
3704 /// `data-frame` and `data-shown` are what the emitted chrome selects on:
3705 /// which region the frame belongs to, and which of that region's children it
3706 /// is. The region is named on every frame rather than the frames being found by
3707 /// descent, because a region showing one child at a time can contain another
3708 /// one -- a tab group over a gallery -- and a descendant query would step the
3709 /// outer region through the inner one's frames.
3710 ///
3711 /// Attributes and not classes for `data-shows`' reason: these are addresses
3712 /// this renderer emits for its own chrome, and a class is the app's styling
3713 /// surface.
3714 fn frame_attrs(slot: &Slot, at: usize, current: bool, opts: &Emit, out: &mut String) {
3715 out.push_str(" class=\"");
3716 class_into("showing-frame", opts, out);
3717 if current {
3718 out.push_str(" current");
3719 }
3720 out.push_str("\" data-frame=\"");
3721 escape_into(&slot.id, out);
3722 let _ = write!(out, "\" data-shown=\"{at}\"");
3723 }
3724
3725 /// Who asks for a region's content, when the region says where it comes from.
3726 ///
3727 /// A labelled tab group is the second: the strip button carries the panel's
3728 /// address, so the panel must not carry it as well or pressing a tab would
3729 /// fetch it twice and the four unpressed ones would fetch on load.
3730 ///
3731 /// A two-member enum rather than a `bool`, because the call sites that pass it
3732 /// are twenty lines apart and `slot_html(child, false, ..)` says nothing about
3733 /// which false.
3734 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
3735 pub(crate) enum Asks {
3736 /// The region asks for its own content, which is every region but one.
3737 ForItself,
3738 /// Something above it asks, so it stays silent and waits to be given.
3739 AndIsAskedFor,
3740 }
3741
3742 /// The address of one child of a region that shows them one at a time.
3743 ///
3744 /// `Slot::fed_by` on such a child means the panel is a route, and the strip is
3745 /// what calls it, so this is read from the parent rather than acted on by the
3746 /// child. Every other region reads `fed_by` the ordinary way: it is the parent
3747 /// showing one child at a time that changes who asks.
3748 fn fed_child(slot: &Slot, at: usize) -> Option<&Action> {
3749 match &slot.body.get(at)?.node {
3750 Node::Region(child) => child.fed_by.as_deref(),
3751 _ => None,
3752 }
3753 }
3754
3755 /// A region whose children are answers to one question, and the chrome for it.
3756 ///
3757 /// [`Slot::repeating`] says the children are slots; this draws each under its
3758 /// number, with the control that takes it away, and the control that adds one
3759 /// under the lot.
3760 ///
3761 /// # Everything here is derived as nodes and rendered through the ordinary walk
3762 ///
3763 /// The caption is a [`Node::Heading`] and both controls are [`Node::Act`], so
3764 /// this function invents no markup and no class: a slot's number comes out as
3765 /// the same heading a described one does, and the two controls take everything
3766 /// [`act_html`] already knows about tone, confirmation and waiting. The
3767 /// alternative was a `slot-caption` class this crate defined and makeover did
3768 /// not, which is the SSH-keys bug the class-coverage test exists to catch.
3769 ///
3770 /// # The floor and the ceiling are drawn, not hidden
3771 ///
3772 /// A remove past the floor and an add past the ceiling are emitted disabled
3773 /// rather than left out. audiofiles' rule editor already made that call for its
3774 /// last condition -- "offered dead rather than hidden", following its own
3775 /// last-vault Delete -- and a control that vanishes at a boundary is a control
3776 /// the reader has to discover twice.
3777 ///
3778 /// What changed is who enforces it. The app disabled its own last Remove and
3779 /// every renderer would have had to be told separately; now
3780 /// [`Repeating::least`] says it once and [`Repeating::may_remove`] is what all
3781 /// three read.
3782 fn repeating_html(
3783 slot: &Slot,
3784 repeating: &quasi_router::Repeating,
3785 opts: &Emit,
3786 doc: &Doc<'_>,
3787 out: &mut String,
3788 ) {
3789 let standing = slot.body.len();
3790 for (at, placed) in slot.body.iter().enumerate() {
3791 // One-based, because it is read by a person: "Condition 1" and not
3792 // "Condition 0". The number is the renderer's for the reason
3793 // `Repeating::one` is a singular noun -- a description that wrote the
3794 // numbers would go stale the moment a slot left the middle.
3795 node_html(
3796 &Node::section(format!("{} {}", repeating.one, at + 1)),
3797 opts,
3798 doc,
3799 out,
3800 );
3801 ranked_html(placed, opts, doc, out);
3802
3803 // The child's own, because only the child knows which slot it is. See
3804 // `Slot::removes` for why it is not one action on the parent.
3805 if let Node::Region(child) = &placed.node
3806 && let Some(removes) = &child.removes
3807 {
3808 let mut act = (**removes).clone();
3809 if !repeating.may_remove(standing) {
3810 act = act.disabled();
3811 }
3812 act_html(&act, opts, doc, out);
3813 }
3814 }
3815
3816 let mut add = repeating.add.clone();
3817 if !repeating.may_add(standing) {
3818 add = add.disabled();
3819 }
3820 act_html(&add, opts, doc, out);
3821 }
3822
3823 /// The chrome for a region showing one child at a time.
3824 ///
3825 /// Derived, once, for every widget there will ever be. Nothing here reads
3826 /// [`quasi_router::RegionKind::Widget`]'s name, and that is the point of the
3827 /// whole design: a carousel, a tab group and a disclosure are one region that
3828 /// shows some of its children, and which idiom comes out falls out of what the
3829 /// children carry rather than out of what the assembly is called.
3830 ///
3831 /// # The three shapes, and what picks between them
3832 ///
3833 /// - Children carrying labels get a strip of them, which is
3834 /// [`layout::Selector::Tabs`] markup verbatim. A tab strip is already a
3835 /// described thing here; deriving a second spelling of one would be this
3836 /// renderer inventing a name makeover would then not style, which is the
3837 /// SSH-keys bug with extra steps.
3838 /// - One dismissible child with a name gets that name as a control, which is a
3839 /// summary line that opens.
3840 /// - Anything else gets previous, position, next.
3841 ///
3842 /// # Where the bytes come from, and what picks between the two
3843 ///
3844 /// A carousel's frames are in the document already, so its controls call no
3845 /// route: what changes is which of several children the reader has downloaded
3846 /// is showing, and a round trip to reveal those bytes is worse on every one of
3847 /// the three MNW galleries this was measured against. Those controls carry
3848 /// `data-shows` and whatever binds the region binds them, the same relationship
3849 /// `data-act` and `data-bespoke` already have with their host.
3850 ///
3851 /// A tab group over routed panels is the other case, `dfbc88ce`. When a child
3852 /// carries [`Slot::fed_by`], that action is the tab's address and the strip
3853 /// button calls it, so the reader downloads the panel they asked for and not
3854 /// the four they did not. Both marks go on the button: htmx makes the request
3855 /// and the binder moves the frame, which is the same division of labour as a
3856 /// control that acts and navigates.
3857 ///
3858 /// The child does not also fetch itself, which is the half of this that is not
3859 /// visible from here -- see [`slot_html`]. It is uniform rather than sparing
3860 /// the shown child because a screen renders once at final geometry, with no
3861 /// placeholder before first content, and the shown panel arriving on `load` is
3862 /// a placeholder on the one panel being looked at.
3863 ///
3864 /// Presence is the only thing that picks between the two, so nothing here reads
3865 /// the region's kind and a description that mixes them is describing a carousel
3866 /// whose frames are routes, which is a coherent thing to want.
3867 fn showing_html(slot: &Slot, opts: &Emit, out: &mut String) {
3868 let labels = slot.labels();
3869 let current = slot.current();
3870 let total = slot.body.len();
3871 // What performs the move, when there is something that can address the
3872 // region. `0081563e`: the frames are in the document, so moving between
3873 // them is local, and this is the renderer emitting the behaviour instead of
3874 // waiting for a host to bind one. See `crate::hyperscript` for the whole of
3875 // why a program may be written into an attribute and why an id that is not
3876 // a handle gets the mark and nothing else.
3877 let handle = crate::hyperscript::handle(&slot.id);
3878
3879 // A named single child that can close is a disclosure, and the check comes
3880 // first because such a child is also a labelled one: a strip of one tab is
3881 // not what a summary line is.
3882 if slot.showing().dismissible()
3883 && total == 1
3884 && let [label] = labels.as_slice()
3885 {
3886 out.push_str("<button type=\"button\"");
3887 // `button`, which is makeover's name for a control and carries its whole
3888 // interactive set. `act_html` learned this the expensive way.
3889 class_attr(&["button"], opts, out);
3890 out.push_str(" data-shows=\"0\" aria-expanded=\"");
3891 out.push_str(if current.is_some() {
3892 "true\""
3893 } else {
3894 "false\""
3895 });
3896 if let Some(handle) = handle {
3897 crate::hyperscript::disclosure(handle, out);
3898 }
3899 out.push('>');
3900 escape_into(label, out);
3901 out.push_str("</button>");
3902 return;
3903 }
3904
3905 if !labels.is_empty() {
3906 let tab = option_class(layout::Selector::Tabs);
3907 out.push_str("<div");
3908 class_attr(&["selector"], opts, out);
3909 out.push_str(" data-selector=\"");
3910 out.push_str(tab);
3911 out.push_str("\" role=\"tablist\"");
3912 // `aad33ecc`. The strip is the element carrying `role="tablist"`, so it
3913 // is the element a screen reader announces and the one the region's own
3914 // name belongs on. `slot_html` skips the name when it emits a strip, so
3915 // the two never both write it.
3916 name_attr(slot.name.as_deref(), out);
3917 out.push('>');
3918 for (at, label) in labels.iter().enumerate() {
3919 let picked = current == Some(at);
3920 out.push_str("<button type=\"button\" class=\"");
3921 class_into(tab, opts, out);
3922 if picked {
3923 out.push_str(" chosen");
3924 }
3925 let _ = write!(out, "\" data-shows=\"{at}\" role=\"tab\" aria-selected=\"");
3926 out.push_str(if picked { "true\"" } else { "false\"" });
3927 // The panel's address, when the panel is a route rather than bytes
3928 // already here. No target is written, for the reason `slot_html`
3929 // does not write one either: the router answers with a fragment
3930 // naming the slot it changed and aims it with `HX-Retarget`, so the
3931 // party that knows stays the party that says.
3932 if let Some(action) = fed_child(slot, at) {
3933 action_attrs(action, Fires::ClickInStrip, None, None, false, out);
3934 }
3935 // Last, after whatever transport this tab carries, because the two
3936 // are separate halves of one press and reading them apart is how
3937 // the markup stays readable: htmx fetches the panel if there is one
3938 // to fetch, and this moves the frame either way.
3939 if let Some(handle) = handle {
3940 crate::hyperscript::tab(handle, at, out);
3941 }
3942 out.push('>');
3943 escape_into(label, out);
3944 out.push_str("</button>");
3945 }
3946 out.push_str("</div>");
3947 return;
3948 }
3949
3950 // Previous, position, next. In flow, under the content, nothing overlaid:
3951 // a terminal cannot honestly overlay anything, and the dot strip this
3952 // replaces had no form at all past a handful of children -- two of the
3953 // three galleries it shipped on are creator uploads of arbitrary length.
3954 out.push_str("<div");
3955 class_attr(&["showing"], opts, out);
3956 out.push('>');
3957
3958 out.push_str("<button type=\"button\"");
3959 class_attr(&["button"], opts, out);
3960 out.push_str(" data-shows=\"previous\"");
3961 if let Some(handle) = handle {
3962 crate::hyperscript::step(handle, crate::hyperscript::Step::Back, total, out);
3963 }
3964 out.push_str(">Prev</button>");
3965
3966 out.push('<');
3967 out.push_str("span");
3968 class_attr(&["showing-position"], opts, out);
3969 // Named as well as classed, because the step controls write into it: a
3970 // class is what a stylesheet is entitled to rename, and this is an address.
3971 if let Some(handle) = handle {
3972 out.push_str(" data-position=\"");
3973 escape_into(handle, out);
3974 out.push('"');
3975 }
3976 // Zero when a dismissible region is closed, which is a true statement about
3977 // how many of its children are showing and needs no glyph of its own.
3978 let _ = write!(out, ">{} / {total}</span>", current.map_or(0, |at| at + 1));
3979
3980 out.push_str("<button type=\"button\"");
3981 class_attr(&["button"], opts, out);
3982 out.push_str(" data-shows=\"next\"");
3983 if let Some(handle) = handle {
3984 crate::hyperscript::step(handle, crate::hyperscript::Step::Forward, total, out);
3985 }
3986 out.push_str(">Next</button>");
3987
3988 out.push_str("</div>");
3989 }
3990
3991 /// The chrome for a set that arrived in parts.
3992 ///
3993 /// The same control [`showing_html`] draws, bound the other way. A carousel's
3994 /// frames are in the document already, so its buttons carry `data-shows` and the
3995 /// move is the host's; these rows were never fetched, so the buttons carry
3996 /// addresses and the move is a request. Presence is the only difference, which
3997 /// is why `quasi_router::Rest` is a `layout::Paging` plus addresses rather than
3998 /// a second kind of position.
3999 ///
4000 /// # The two labels
4001 ///
4002 /// A set with a page size prints "3 / 8", which is `showing_html`'s spelling of
4003 /// position and deliberately the same one. A set without prints what it has:
4004 /// "150 of 400", or bare "Show more" when nothing counted it. The description
4005 /// decides which by whether it is paged, and no renderer infers it from the
4006 /// numbers.
4007 ///
4008 /// # A direction with no address is left out, not drawn disabled
4009 ///
4010 /// Ruled by Max 2026-09-08, reversing what stood here. The argument for the
4011 /// disabled control was "first paint is final paint": a pager that gains a
4012 /// button on page two moves everything beside it. The argument against is this
4013 /// renderer's own, written under [`rest_strip_html`] about the page a reader is
4014 /// already on -- a control that answers nothing is the lying control
4015 /// [`control_tag`] exists to prevent -- and a Prev that cannot go back is that
4016 /// control. The layout shift is the price, and `.rest` is the place to pay it
4017 /// if it turns out to matter.
4018 ///
4019 /// It is also what makes a paged screen compilable, which is not why it was
4020 /// decided but is why it was asked. A guard derives as a branch only when
4021 /// turning it off deletes bytes; a disabled control substitutes for an enabled
4022 /// one, and the derivation silently baked whichever it happened to render. See
4023 /// wiki `quasi-declare-form` section 29.
4024 fn rest_html(rest: &Rest, opts: &Emit, out: &mut String) {
4025 let paging = rest.as_layout();
4026
4027 out.push_str("<div");
4028 class_attr(&["rest"], opts, out);
4029 out.push('>');
4030
4031 // Back, then the jumps, then forward, matching `Rest::placed`. A pager is
4032 // the container whose declaration order and render order genuinely differ:
4033 // the renderer puts Prev first whatever order the declaration said its
4034 // directions in, which is what `emit::pager_order` makes true rather than
4035 // hoped for.
4036 let mut marked = crate::stage::Cursor::open(&rest.marks);
4037
4038 if let Some(action) = &rest.back {
4039 marked.starts(out);
4040 let (open, close) = control_tag(action);
4041 out.push_str(open);
4042 class_attr(&["button", "rest-previous"], opts, out);
4043 action_attrs(action, Fires::Click, None, None, false, out);
4044 out.push_str(">Prev");
4045 out.push_str(close);
4046 marked.ends(out);
4047 }
4048
4049 if rest.jumps.is_empty() {
4050 out.push('<');
4051 out.push_str("span");
4052 class_attr(&["rest-position"], opts, out);
4053 out.push('>');
4054 match (paging.page(), paging.pages_total()) {
4055 (Some(page), Some(total)) => {
4056 let _ = write!(out, "{page} / {total}");
4057 }
4058 _ => match paging.total() {
4059 Some(total) => {
4060 let _ = write!(out, "{} of {total}", paging.shown());
4061 }
4062 None => out.push_str("Show more"),
4063 },
4064 }
4065 out.push_str("</span>");
4066 } else {
4067 rest_strip_html(rest, &mut marked, opts, out);
4068 }
4069
4070 // Written out rather than shared with the Prev branch above. The class name
4071 // has to sit in the `class_attr` call as a literal or `tests/vocabulary.rs`
4072 // cannot see it, and a renderer whose emitted names are invisible to that
4073 // scan is how the SSH-keys drift came back. The duplication is the price of
4074 // the check working.
4075 if let Some(action) = &rest.forward {
4076 marked.starts(out);
4077 let (open, close) = control_tag(action);
4078 out.push_str(open);
4079 class_attr(&["button", "rest-next"], opts, out);
4080 action_attrs(action, Fires::Click, None, None, false, out);
4081 out.push_str(">Next");
4082 out.push_str(close);
4083 marked.ends(out);
4084 }
4085
4086 marked.close(&rest.marks);
4087 out.push_str("</div>");
4088 }
4089
4090 /// The numbered pages, where the description offered any.
4091 ///
4092 /// In place of the "3 / 8" readout rather than beside it: the strip says both
4093 /// numbers already -- the marked one is where the reader is and the last is
4094 /// how many there are -- and printing the position twice is a control arguing
4095 /// with itself.
4096 ///
4097 /// The page the reader is on is drawn as text and not as a control, which is
4098 /// what `templates/pages/feed.html` already did with a `<span class="current">`.
4099 /// A button that reloads the page it is on is an affordance that does nothing,
4100 /// and a control that answers nothing is the lying control `control_tag` exists
4101 /// to prevent.
4102 ///
4103 /// A description that offered a strip the reader is not in draws every page as
4104 /// a control and marks none, which is a host that windowed its pages wrong. It
4105 /// is worth being able to see, and it is not worth refusing to draw a list
4106 /// over.
4107 fn rest_strip_html(rest: &Rest, marked: &mut crate::stage::Cursor, opts: &Emit, out: &mut String) {
4108 out.push_str("<div");
4109 class_attr(&["rest-pages"], opts, out);
4110 out.push('>');
4111 for jump in &rest.jumps {
4112 marked.starts(out);
4113 if jump.here {
4114 out.push_str("<span");
4115 class_attr(&["rest-page", "rest-page-here"], opts, out);
4116 out.push_str(" aria-current=\"page\">");
4117 let _ = write!(out, "{}", jump.page);
4118 out.push_str("</span>");
4119 marked.ends(out);
4120 continue;
4121 }
4122 let (open, close) = control_tag(&jump.action);
4123 out.push_str(open);
4124 class_attr(&["button", "rest-page"], opts, out);
4125 action_attrs(&jump.action, Fires::Click, None, None, false, out);
4126 out.push('>');
4127 let _ = write!(out, "{}", jump.page);
4128 out.push_str(close);
4129 marked.ends(out);
4130 }
4131 out.push_str("</div>");
4132 }
4133
4134 /// A region, and everything under it.
4135 /// The region's leading row: whatever the region puts there itself, then the
4136 /// members the description said share it.
4137 ///
4138 /// Ruling: wiki `layout-room-and-fallback`. What this replaces is goingson
4139 /// pinning a toolbar over a tab strip in a stylesheet, so the property worth
4140 /// stating is the one the wrapper buys: both are inside one flex row with a
4141 /// min-content floor under each, which is `makeover-webview`'s `.run`. Nothing
4142 /// here positions anything, and nothing here names a size.
4143 ///
4144 /// `strip` is the caller's, not recomputed here: a tab group's strip and a
4145 /// carousel's counter row are both `showing_html`, they sit on opposite sides
4146 /// of the frames, and only the caller knows which side it is on. A region with
4147 /// no run and no strip writes nothing at all, which is what every region
4148 /// written before either existed did.
4149 fn run_html(slot: &Slot, strip: bool, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
4150 let Some(run) = slot.run.as_ref() else {
4151 if strip {
4152 showing_html(slot, opts, out);
4153 }
4154 return;
4155 };
4156
4157 // The wrapper appears only when there is a run, so the strip a tab group
4158 // has always emitted is unwrapped until the description says something
4159 // shares its row. That keeps the change additive in the markup as well as
4160 // in the type.
4161 out.push_str("<div class=\"");
4162 out.push_str(&class("run", opts));
4163 out.push(' ');
4164 out.push_str(&class(fallback_class(run.fallback), opts));
4165 out.push_str("\">");
4166 if strip {
4167 showing_html(slot, opts, out);
4168 }
4169 for placed in &run.members {
4170 ranked_html(placed, opts, doc, out);
4171 }
4172 out.push_str("</div>");
4173 }
4174
4175 /// A region's own accessible name, when it has one.
4176 ///
4177 /// `aria-label` rather than a visible caption: the description says the region
4178 /// is called this, and a region that also wants it on the screen carries a
4179 /// [`Heading`](quasi_router::layout::Heading) in its body. Writing both from one
4180 /// member would be this renderer deciding a screen's copy.
4181 fn name_attr(name: Option<&str>, out: &mut String) {
4182 if let Some(name) = name {
4183 out.push_str(" aria-label=\"");
4184 escape_into(name, out);
4185 out.push('"');
4186 }
4187 }
4188
4189 pub(crate) fn slot_html(slot: &Slot, asks: Asks, opts: &Emit, doc: &Doc<'_>, out: &mut String) {
4190 // A region's own questions, each on a wrapper outside the region rather
4191 // than on the region itself. Two reasons, and the first is the one that
4192 // makes it mandatory: htmx takes one verb and one address per element, and
4193 // a region that also names a `fed_by` already spent its. The second is
4194 // `Field::consults`' -- one wrapper each, so a panel recomputing from one
4195 // set of dials at two rates is two questions and not one that overwrote the
4196 // other.
4197 //
4198 // Outside rather than inside, so the gathered set is exactly the region's
4199 // contents: the `find input, find select, find textarea` this trigger
4200 // carries reaches the whole region from here, and from inside it would
4201 // reach whatever the wrapper happened to enclose.
4202 for consult in &slot.consults {
4203 out.push_str("<div");
4204 class_attr(&["region-consults"], opts, out);
4205 // What rides along from outside the region, named by field name. The
4206 // dials inside need no naming: they are what the trigger's own include
4207 // gathers, which is the whole of what moving the consult up to a region
4208 // bought.
4209 let sends = sends_selector(&consult.sends);
4210 action_attrs(
4211 &consult.action,
4212 Fires::Working {
4213 after: consult.after,
4214 at_least: consult.at_least,
4215 },
4216 None,
4217 sends.as_deref(),
4218 false,
4219 out,
4220 );
4221 out.push('>');
4222 }
4223
4224 let kind = match &slot.kind {
4225 quasi_router::RegionKind::Band => "band",
4226 quasi_router::RegionKind::Sidebar => "sidebar",
4227 quasi_router::RegionKind::Pane => "pane",
4228 quasi_router::RegionKind::Group => "group",
4229 quasi_router::RegionKind::TabGroup => "tabgroup",
4230 quasi_router::RegionKind::Modal => "modal",
4231 quasi_router::RegionKind::Handover { .. } | quasi_router::RegionKind::Ceded { .. } => {
4232 "bespoke"
4233 }
4234 quasi_router::RegionKind::Widget { .. } => "widget",
4235 };
4236
4237 // Every region is a `div` except the one whose whole meaning is "these
4238 // things belong together", which is what `section` means in HTML. This is
4239 // the renderer picking the expression its host has for an intent the
4240 // description states, the same way a terminal picks a rule and egui picks a
4241 // frame; nothing in the description asked for an element name.
4242 //
4243 // Only `Group`, deliberately. A `section` per pane or per band would be the
4244 // usual mistake of reaching for the semantic element because it sounds
4245 // better, and a document of nested sections says less than one that names
4246 // the one thing it means.
4247 let tag = match &slot.kind {
4248 quasi_router::RegionKind::Group => "section",
4249 _ => "div",
4250 };
4251
4252 let _ = write!(out, "<{tag} id=\"");
4253 // The id is the fragment's address. It is escaped and otherwise untouched:
4254 // rewriting it would break the HX-Retarget the router just sent, which
4255 // names the slot's own id.
4256 escape_into(&slot.id, out);
4257 out.push('"');
4258 class_attr(&["region", kind], opts, out);
4259
4260 if let quasi_router::RegionKind::Handover { name } | quasi_router::RegionKind::Ceded { name } =
4261 &slot.kind
4262 {
4263 // Never interpreted, per decision 4. Handed to the app under a name it
4264 // chose, which is the entire contract for a bespoke region.
4265 out.push_str(" data-bespoke=\"");
4266 escape_into(name, out);
4267 out.push('"');
4268 }
4269
4270 if let quasi_router::RegionKind::Widget { name } = &slot.kind {
4271 // Also never interpreted here, and for a different purpose: this is the
4272 // hook a stylesheet or a script attaches to in order to draw the
4273 // assembly the way a browser does it. It is an attribute rather than a
4274 // class because a widget name is app vocabulary and classes here are
4275 // this renderer's.
4276 //
4277 // What makes it safe to emit and ignore is that the body under it is
4278 // already the whole assembly in primitives. Nothing recognises
4279 // `data-widget` and the region still renders -- a carousel degrades to
4280 // its frames, which is the JS-off fallback the shipped partial has and
4281 // the reason the tier does not need three renderers to release in step.
4282 out.push_str(" data-widget=\"");
4283 escape_into(name, out);
4284 out.push('"');
4285 }
4286
4287 // What a binder looks for, and the one attribute that says this region has
4288 // chrome to bind. It is not the widget's name: a script that moved between
4289 // a carousel's frames by matching `data-widget="carousel"` would have to be
4290 // written again for the next assembly, which is the per-widget code the
4291 // whole derivation exists to stop.
4292 //
4293 // Nothing is emitted for `Showing::All`, so the hook is present exactly
4294 // when there is something to bind.
4295 match slot.showing() {
4296 layout::Showing::One => out.push_str(" data-showing=\"one\""),
4297 layout::Showing::AtMostOne => out.push_str(" data-showing=\"at-most-one\""),
4298 _ => {}
4299 }
4300
4301 // What brings the region out, when it is not simply out. `079a011e`: the
4302 // condition is the region's own, so it is emitted here rather than on
4303 // whatever control the reveal script will read.
4304 //
4305 // Nothing decides the initial state on this side. The emitter sees one node
4306 // at a time and the control may be anywhere on the screen or, for a
4307 // fragment, not in this response at all; the script settles every
4308 // conditional region on parse and after every swap, which is one answer for
4309 // both. The cost is that a document served without the script shows them
4310 // all, which is stated where the script is.
4311 if let Some(reveal) = &slot.revealed_by {
4312 reveal_attrs(reveal, out);
4313 }
4314
4315 if matches!(asks, Asks::ForItself) && matches!(slot.readiness, layout::Readiness::Pending) {
4316 out.push_str(" aria-busy=\"true\"");
4317 }
4318
4319 // The region says where its content is coming from, so it asks for it
4320 // itself as soon as it exists. This is what a hand-split route was doing
4321 // before there was a word for it: MNW's payout summary is its own tab
4322 // because it calls a payment provider while the rest of the screen reads the
4323 // database, and under this it is a region of the screen that arrives late.
4324 //
4325 // Every `hx-` attribute comes out of `action_attrs`, including these. The
4326 // answer is aimed by `HX-Retarget` naming this slot's id, which is why
4327 // nothing here writes a target: the router is the party that knows what it
4328 // changed, and that is unchanged by the request having been started here.
4329 //
4330 // Unless something above it is asking on its behalf, which is
4331 // [`Asks::AndIsAskedFor`] and is one case: a panel of a labelled tab group,
4332 // whose strip button carries this same address. Both halves are suppressed
4333 // together, `aria-busy` above included, because a panel nobody has asked
4334 // for is not waiting on anything.
4335 if matches!(asks, Asks::ForItself)
4336 && let Some(action) = &slot.fed_by
4337 {
4338 // A live region asks on a cadence instead of once. The description says
4339 // only that the contents move; the interval is `crate::CADENCE`, picked
4340 // for every screen this crate draws rather than per template, which is
4341 // what MNW's two hand-written `every 10s` triggers were doing without
4342 // anything making them comparable.
4343 let fires = if slot.live { Fires::Live } else { Fires::Load };
4344 action_attrs(action, fires, None, None, false, out);
4345 }
4346
4347 if matches!(slot.kind, quasi_router::RegionKind::Modal) {
4348 // A modal takes input until dismissed, which is what modal means, and
4349 // saying so is the renderer's job rather than the app's.
4350 out.push_str(" role=\"dialog\" aria-modal=\"true\"");
4351 }
4352
4353 // `aad33ecc`. The region's own name, on the region itself -- unless a tab
4354 // strip is about to be emitted, in which case the strip is the element with
4355 // the role and `showing_html` writes it there instead. A named `div` with no
4356 // role is announced by nothing, so putting it in both places would be one
4357 // announcement and one wasted attribute rather than two announcements.
4358 if !slot.showing().selective() || slot.labels().is_empty() {
4359 name_attr(slot.name.as_deref(), out);
4360 }
4361
4362 out.push('>');
4363 if slot.showing().selective() {
4364 let current = slot.current();
4365 let labelled = !slot.labels().is_empty();
4366
4367 // A strip sits above the panes it opens and a counter row sits under
4368 // the content it counts. That is the only placement decision here, and
4369 // it is the folder semantic rather than a preference: a tab that came
4370 // after its pane would not read as the tab of it.
4371 if labelled {
4372 run_html(slot, true, opts, doc, out);
4373 }
4374
4375 // Each child is wrapped, because the rule that collapses the stack has
4376 // to have something to select and a described child emits whatever
4377 // element it is. The wrapper appears only here, so a region that shows
4378 // everything -- which is every region written before `Showing` existed
4379 // -- emits exactly the markup it always did.
4380 let mut marked = crate::stage::Cursor::open(&slot.marks);
4381 for (at, placed) in slot.body.iter().enumerate() {
4382 marked.starts(out);
4383 out.push_str("<div");
4384 frame_attrs(slot, at, current == Some(at), opts, out);
4385 out.push('>');
4386 // A labelled strip has just emitted every panel's address on its
4387 // own buttons, so the panels are asked for rather than asking. The
4388 // drop-class wrapper is rebuilt here rather than reached through
4389 // `ranked_html`, which has no reason to learn about this and no
4390 // other caller that would pass it.
4391 match (labelled, &placed.node) {
4392 (true, Node::Region(child)) => {
4393 let drops = drop_class(placed.priority);
4394 if let Some(drops) = drops {
4395 out.push_str("<div");
4396 class_attr(&[drops], opts, out);
4397 out.push('>');
4398 }
4399 slot_html(child, Asks::AndIsAskedFor, opts, doc, out);
4400 if drops.is_some() {
4401 out.push_str("</div>");
4402 }
4403 }
4404 _ => ranked_html(placed, opts, doc, out),
4405 }
4406 out.push_str("</div>");
4407 marked.ends(out);
4408 }
4409 marked.close(&slot.marks);
4410
4411 if !labelled {
4412 run_html(slot, true, opts, doc, out);
4413 }
4414 } else if let Some(repeating) = &slot.repeating {
4415 run_html(slot, false, opts, doc, out);
4416 repeating_html(slot, repeating, opts, doc, out);
4417 } else {
4418 run_html(slot, false, opts, doc, out);
4419 let mut marked = crate::stage::Cursor::open(&slot.marks);
4420 for placed in slot.body.iter() {
4421 marked.starts(out);
4422 ranked_html(placed, opts, doc, out);
4423 marked.ends(out);
4424 }
4425 marked.close(&slot.marks);
4426 }
4427
4428 // The host's markup for this region, after whatever the description put
4429 // here, and only for a bespoke one: a fill named against a pane is a host
4430 // reaching into a region the description already owns.
4431 //
4432 // Verbatim. See `Webview::fills` for why that is not the hole it looks
4433 // like: the string is host code's, never a description's, so the escaping
4434 // guarantee the whole vocabulary rests on is untouched.
4435 if matches!(
4436 slot.kind,
4437 quasi_router::RegionKind::Handover { .. } | quasi_router::RegionKind::Ceded { .. }
4438 ) && let Some(fill) = doc.fills.get(&slot.id)
4439 {
4440 out.push_str(fill);
4441 }
4442
4443 // `ae8e8836`. The region's own popover container, for an anchored screen to
4444 // land in. Emitted for every region rather than for the ones an app anchors
4445 // to, because nothing in a description says which those are; the cost is one
4446 // empty element per region and it is paid knowingly.
4447 //
4448 // Inside the region for every kind but one, and last, so that an anchored
4449 // screen draws over what the description put here rather than under it.
4450 // Inside is what makes it the region's: a fragment replacing this region
4451 // takes the menu with it, and a menu left open over contents that have since
4452 // been swapped is the state nobody asked for.
4453 //
4454 // A bespoke region is the exception and gets a sibling instead. Decision 4
4455 // says that one is a place and nothing else -- the host's markup is the
4456 // whole of what is between its tags -- and a renderer slipping its own
4457 // element in there would be the interpretation the decision forbids. The
4458 // menu then outlives a re-fill of that region, which is the cost of not
4459 // reaching inside, and it is the smaller of the two.
4460 let anchor = crate::hyperscript::handle(&slot.id).map(anchored_id);
4461 let bespoke = matches!(
4462 slot.kind,
4463 quasi_router::RegionKind::Handover { .. } | quasi_router::RegionKind::Ceded { .. }
4464 );
4465 if let Some(id) = &anchor
4466 && !bespoke
4467 {
4468 anchor_container_html(id, opts, out);
4469 }
4470
4471 let _ = write!(out, "</{tag}>");
4472
4473 if let Some(id) = &anchor
4474 && bespoke
4475 {
4476 anchor_container_html(id, opts, out);
4477 }
4478
4479 // The consult wrappers close outside the anchor container for a bespoke
4480 // region, which is the one case where the container is a sibling. Both are
4481 // the region's, and the question is the outer of the two: a menu that
4482 // opened over the region belongs to it, and a recompute is asked about it.
4483 for _ in &slot.consults {
4484 out.push_str("</div>");
4485 }
4486 }
4487