Skip to main content

makeover_webview/
form.rs

1//! Phase B, the forms half: [`makeover_layout::Field`] rendered to HTML.
2//!
3//! # Why this emits strings
4//!
5//! Both webview apps build their markup as strings and hand it to `innerHTML`:
6//! goingson's `renderFormField` returns a template literal that fifteen call
7//! sites interpolate into larger literals, and Balanced Breakfast's builds
8//! nodes but appends them into the same string-built forms. Returning nodes
9//! would rewrite the surrounding templates as well, which makes it a migration
10//! rather than an adoption. So: strings, and the escaping comes with them.
11//!
12//! # Why one escaper is enough here
13//!
14//! goingson carries four escapers and 543 call sites that must pick between
15//! them, because `escapeHtml` is built on `textContent` serialization and
16//! **`textContent` refuses to encode `"`**. That is what makes it unsound in an
17//! attribute, and it is the whole reason the choice exists. Its `escape.js`
18//! records the finding as the CHRONIC-XSS seal, and its test suite has a gate
19//! keeping the unsafe one off the namespace.
20//!
21//! [`escape`] here is not built on that, so it encodes the quote along with
22//! everything else, which makes one function sound in both sinks. The four-way
23//! choice does not move into Rust: it disappears. Nothing in this module hands
24//! an unescaped value to the output except through [`Markup`], which a caller
25//! has to name.
26//!
27//! # What the description does not carry
28//!
29//! One thing: the **current value**, which arrives in [`Filling`].
30//!
31//! It used to be three. Writing this emitter is what found them, and the other
32//! two turned out not to be renderer state at all — the placeholder is
33//! user-facing text that sits with `label` and `hint`, and a select's options
34//! are needed by every renderer, which is how each of them ends up inventing a
35//! near-miss of the same struct. Both moved down into `makeover-layout` 0.8.0,
36//! `Choice` included, and this crate reads them off [`Field`] now.
37//!
38//! The value stays, and it is not a leftover. A webview reads it back out of
39//! the DOM, an immediate-mode renderer writes through a `&mut`, and a terminal
40//! keeps an edit buffer; a description carrying it would have to carry a way to
41//! write it back, at which point it is a form model.
42
43use crate::{Emit, class, push_class};
44use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, Tone};
45use std::fmt::Write as _;
46
47/// Every class this module can put in markup.
48///
49/// [`crate::facet::FACET_CLASSES`]' obligation, and the module where it was
50/// missing longest. Most of these carry no rule and never will: `.form-group`,
51/// `.form-label`, `.form-hint` and `.form-error` are the apps' own names, kept
52/// so adoption deletes goingson's `renderFormField` rather than restyling
53/// anything, and phase A emits only what it can generate from the description.
54/// A class with no rule is invisible to [`crate::vocabulary::vocabulary`],
55/// which reads the generated sheet, so the unruled half of a renderer's
56/// vocabulary can only be written down.
57///
58/// What went wrong without it: an app checking its stylesheet against
59/// [`crate::vocabulary::names`] concluded that its live `.form-group` and
60/// `.form-label` rules matched nothing and were safe to delete. quasi-webview
61/// carried them in a `MAKEOVER_UNLISTED` constant of its own until 0.59.0
62/// rather than let that happen.
63pub const FIELD_CLASSES: &[&str] = &[
64    "field",
65    "form-checkbox-label",
66    "form-editor-modes",
67    "form-editor-preview",
68    "form-error",
69    "form-group",
70    "form-hint",
71    "form-interval",
72    "form-label",
73    "form-note",
74    "form-option-reason",
75    "form-radio-group",
76    "form-radio-label",
77    "form-unit",
78];
79
80// `form-suggestions`, `form-suggestion` and `form-suggestion-detail` are
81// deliberately absent: [`suggestion_rules`] writes their look and
82// `quasi-webview` writes their markup, because a suggestion source is a route
83// and no description layer carries one. They reach the vocabulary through the
84// generated sheet, which is where a name this crate rules but does not emit
85// belongs.
86
87/// The state classes a field carries, which take no prefix.
88///
89/// `chosen` and `latched`'s convention, stated in
90/// [`crate::vocabulary::vocabulary`]: a state qualifies a prefixed component
91/// (`.mk-form-group.has-error`) rather than standing on its own, so a prefix
92/// moves the thing and not its state.
93///
94/// `has-error` marks the group and `visible` marks the message, which is
95/// [`makeover_layout::Field::invalid`]'s own reasoning: a renderer with no
96/// descendant selectors cannot find the group from the message, so both are
97/// told.
98pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"];
99
100/// A string that is already markup, and is emitted without escaping.
101///
102/// The one hole in the escaping, and it has to be named to be used. goingson
103/// has two live callers that need it, both passing a recurrence-config block
104/// built elsewhere, and both would otherwise have their markup rendered as
105/// visible angle brackets. A caller constructing this is stating that the
106/// contents are trusted; nothing here can check that for them.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct Markup<'a>(pub &'a str);
109
110/// What the field currently holds.
111///
112/// An enum rather than a bag of optional fields, on the same reasoning
113/// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable
114/// here, where a struct would let it be said and then have to cope.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum Value<'a> {
117    /// Nothing yet.
118    #[default]
119    Absent,
120    /// The value of anything that takes typed text, a select included: what a
121    /// select holds is the `value` of one of [`Field::options`]'s
122    /// [`Choice`]s.
123    ///
124    /// It carried the options too until makeover-layout 0.8.0 moved them onto
125    /// the field, which collapsed a `Chosen { options, value }` variant into
126    /// this one. `makeover-immediate` arrived at the same single-variant shape
127    /// on its own, from the other direction.
128    Text(&'a str),
129    /// A checkbox, on or off.
130    On(bool),
131    /// Both ends of a [`FieldKind::Interval`], lower first.
132    ///
133    /// Two values rather than one string with a separator, for
134    /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
135    /// interval submits under two names, so it comes back as two values, and a
136    /// delimiter this crate owned could appear inside either of them.
137    ///
138    /// Either end may be empty while the other stands. "Over 120 BPM" is a
139    /// lower end and no upper one, and it is an answer rather than a
140    /// half-filled form.
141    ///
142    /// Added 0.56.0 with makeover-layout 0.34.0.
143    Between {
144        /// What the lower box holds now.
145        lower: &'a str,
146        /// What the upper box holds now.
147        upper: &'a str,
148    },
149}
150
151impl<'a> Value<'a> {
152    /// The value as text, for the kinds that submit one.
153    const fn as_text(&self) -> &'a str {
154        match self {
155            Self::Text(text) | Self::Between { lower: text, .. } => text,
156            Self::Absent | Self::On(_) => "",
157        }
158    }
159}
160
161impl<'a> Value<'a> {
162    /// The upper end, for the one variant that has one.
163    const fn upper_text(&self) -> &'a str {
164        match self {
165            Self::Between { upper, .. } => upper,
166            Self::Absent | Self::Text(_) | Self::On(_) => "",
167        }
168    }
169}
170
171/// Everything about the field that the description does not carry.
172#[derive(Debug, Clone, Copy, Default)]
173pub struct Filling<'a> {
174    /// What the field holds now.
175    pub value: Value<'a>,
176    /// Markup appended inside the group, after the hint. Not escaped.
177    pub trailing: Option<Markup<'a>>,
178    /// Attributes written onto the control element itself. Not escaped.
179    ///
180    /// [`trailing`](Self::trailing)'s argument at attribute scale: a host knows
181    /// facts about the control that no description layer carries, and until
182    /// this existed the only way to attach one was to stop calling this emitter
183    /// and write a second one. quasi's suggestion source is the first caller —
184    /// a field that owns a list of candidates is a `role="combobox"` pointing
185    /// at the list it owns, and neither half is anything
186    /// [`makeover_layout::Field`] can say.
187    ///
188    /// Written verbatim, so a caller supplies `attr="value"` pairs with no
189    /// leading space and does its own escaping. It is [`Markup`]'s hole in the
190    /// same wall, named the same way so a caller has to state that the contents
191    /// are trusted.
192    ///
193    /// A [`FieldKind::Radio`] drops them, and that is deliberate rather than an
194    /// oversight: a radio group is a set of sibling inputs with no one control
195    /// element, so there is nowhere honest to put an attribute meant for the
196    /// control. The group carries the descriptions for the same reason.
197    pub control_attrs: Option<Markup<'a>>,
198    /// Scopes the `id` attributes to one instance of the form.
199    ///
200    /// The field's `name` is what the value submits under and is the same
201    /// wherever the form appears; its `id` has to be unique in the document,
202    /// and those two facts stop agreeing the moment a form appears twice.
203    /// goingson hits this directly: its new-task and edit-task modals are the
204    /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep
205    /// `label for` and `aria-describedby` pointing at the right control.
206    ///
207    /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to
208    /// `name`, which would change what the form submits.
209    pub id_prefix: Option<&'a str>,
210}
211
212impl<'a> Filling<'a> {
213    /// A filling that carries a value and nothing else.
214    #[must_use]
215    pub const fn of(value: Value<'a>) -> Self {
216        Self {
217            value,
218            trailing: None,
219            control_attrs: None,
220            id_prefix: None,
221        }
222    }
223
224    /// The document-unique id for a field of this name.
225    fn id_for(&self, name: &str) -> String {
226        let mut id = String::new();
227        if let Some(prefix) = self.id_prefix {
228            escape_into(prefix, &mut id);
229            id.push('-');
230        }
231        escape_into(name, &mut id);
232        id
233    }
234}
235
236/// Encode the five characters that let a value stop being a value, into a
237/// buffer the caller already has.
238///
239/// The form the emitters use. [`escape`] is this with a `String` allocated
240/// around it, and the allocation is the whole difference: a described screen
241/// escapes once per attribute and once per run of text, so a function that
242/// returns a `String` allocates a few thousand times to produce one page, where
243/// a template engine writes its escaped bytes straight into the output buffer.
244/// Measured 2026-08-14 against a real pane, that gap was 85% of a 42x rendering
245/// cost, and this is the half of the fix that lives in this crate.
246///
247/// Sound in element text and in a double-quoted attribute alike, which is the
248/// property `textContent`-based escaping cannot have. Both sinks are covered by
249/// one function so that no call site has to choose, here or downstream.
250///
251/// Copies in runs rather than per character. All five encoded characters are
252/// ASCII, so a byte scan cannot land inside a multi-byte character and the
253/// slice between two of them is always a valid `&str`. Text with nothing to
254/// encode — which is most text — is one `push_str` of the whole thing.
255pub fn escape_into(text: &str, out: &mut String) {
256    let mut start = 0;
257    for (index, byte) in text.bytes().enumerate() {
258        let encoded = match byte {
259            b'&' => "&amp;",
260            b'<' => "&lt;",
261            b'>' => "&gt;",
262            b'"' => "&quot;",
263            b'\'' => "&#39;",
264            _ => continue,
265        };
266        out.push_str(&text[start..index]);
267        out.push_str(encoded);
268        start = index + 1;
269    }
270    out.push_str(&text[start..]);
271}
272
273/// Encode the five characters that let a value stop being a value.
274///
275/// [`escape_into`] with a buffer of its own, for the callers that want a value
276/// rather than an append: a caller assembling an attribute out of several
277/// pieces, and everything outside this crate that took this function before the
278/// buffer-writing form existed. Emitting into a buffer you already hold is the
279/// cheaper path and the one this crate's own emitters take.
280#[must_use]
281pub fn escape(text: &str) -> String {
282    let mut out = String::with_capacity(text.len());
283    escape_into(text, &mut out);
284    out
285}
286
287/// The `type` an input takes for a kind.
288///
289/// [`FieldKind::Secret`] is `password`, which both apps already map by hand.
290const fn input_type(kind: FieldKind) -> &'static str {
291    match kind {
292        FieldKind::Secret => "password",
293        FieldKind::Number => "number",
294        FieldKind::Checkbox => "checkbox",
295        FieldKind::File => "file",
296        FieldKind::Hidden => "hidden",
297        // Not decoration. Each of these changes the keyboard a touch device
298        // offers and turns on the platform's own validation, which is why the
299        // description names them apart from text rather than letting the app
300        // pass an HTML type through.
301        FieldKind::Email => "email",
302        FieldKind::Url => "url",
303        FieldKind::Tel => "tel",
304        // The same argument, and it buys more here than anywhere else in this
305        // list: a native picker as well as the keyboard and the validation.
306        // Both submit the format `makeover-layout` names, `DATE_FORMAT` and
307        // `DATETIME_FORMAT`, so honouring it costs this renderer nothing.
308        FieldKind::Date => "date",
309        FieldKind::DateTime => "datetime-local",
310        FieldKind::Radio => "radio",
311        // The clearest case in this list that a kind is not decoration: a
312        // number and a range submit the same value and are different controls,
313        // and the browser is the one drawing the difference.
314        FieldKind::Range => "range",
315        // Select and Textarea are not inputs at all; they never reach here.
316        // Radio is one, but it is emitted once per option by `radio_html` and
317        // so does not reach here either.
318        FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text",
319        // A kind added to the description since this renderer was built. Text
320        // accepts any value the others would, so it degrades rather than
321        // dropping the field.
322        _ => "text",
323    }
324}
325
326/// The attributes every visible control carries, error state included.
327///
328/// `aria-invalid` is the whole reason the error state is readable at all: the
329/// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather
330/// than on a class, so a control rendered already-invalid without it is styled
331/// as if nothing were wrong. goingson's runtime validation path sets the
332/// attribute and its initial render does not, which is exactly the drift one
333/// emitter removes.
334/// `id` and `name` arrive separately because they are not the same fact. The
335/// name is what submits and is fixed by the description; the id has to be
336/// unique in the document and so carries [`Filling::id_prefix`] when a form
337/// appears more than once.
338/// The `accept` attribute, from the description's accept list.
339///
340/// makeover-layout 0.31.0. The list is comma-joined because that is the
341/// attribute's own format, and each entry writes itself: a family is its
342/// wildcard media type, a media type is itself, a suffix is itself with its
343/// leading dot. Nothing is normalised on the way through -- `.tar.gz` is two
344/// dots and the browser is fine with it.
345///
346/// An empty list emits no attribute at all, which is the browser's own "any
347/// file" and is what the description means by listing nothing. Emitting
348/// `accept=""` instead would be a filter that matches nothing on some browsers
349/// and everything on others.
350///
351/// It is a filter and not a guarantee, on the browser's side as much as here:
352/// the picker keeps an "All Files" escape and the user may take it. Whoever
353/// validated still validates.
354fn push_accept(out: &mut String, field: &Field<'_>) {
355    if field.accept.is_empty() {
356        return;
357    }
358    out.push_str(" accept=\"");
359    for (index, one) in field.accept.iter().enumerate() {
360        if index > 0 {
361            out.push(',');
362        }
363        escape_into(one.as_str(), out);
364    }
365    out.push('"');
366}
367
368/// The extent and the granularity, as the browser spells them.
369///
370/// Its own function because an interval writes them onto both of its ends: they
371/// describe the axis rather than either end of it, which is what
372/// [`FieldKind::Interval`] says and what the six audiofiles filter axes are.
373fn push_bounds(out: &mut String, field: &Field<'_>) {
374    if let Some(min) = field.min {
375        out.push_str(" min=\"");
376        escape_into(min, out);
377        out.push('"');
378    }
379    if let Some(max) = field.max {
380        out.push_str(" max=\"");
381        escape_into(max, out);
382        out.push('"');
383    }
384    // The browser's own default is `step="1"`, which turns a 0-to-1 threshold
385    // into a two-position control. That is the granularity the description
386    // means when it says nothing, so this is emitted only when an app has said
387    // otherwise rather than defaulted here.
388    //
389    // A range takes its granularity from its curve as of makeover-layout
390    // 0.32.0, and every other kind keeps `Field::step`. See the crate header on
391    // what this renderer can and cannot do with a curve.
392    let step = if field.kind == FieldKind::Range {
393        field.curve.step()
394    } else {
395        field.step
396    };
397    if let Some(step) = step {
398        out.push_str(" step=\"");
399        escape_into(step, out);
400        out.push('"');
401    }
402}
403
404fn push_control_attributes(
405    out: &mut String,
406    field: &Field<'_>,
407    filling: &Filling<'_>,
408    id: &str,
409    name: &str,
410) {
411    let _ = write!(out, " id=\"{id}\" name=\"");
412    escape_into(name, out);
413    out.push('"');
414    if field.required {
415        out.push_str(" required");
416    }
417    // makeover-layout 0.11.0's constraints. The description carries the rule and
418    // this emits the browser's idiom for it, which is the model `required` has
419    // been using since before the crate wrote down that it carried none.
420    // Enforcement is still whoever validated's, and arrives back as `error`.
421    if let Some(limit) = field.max_length {
422        let _ = write!(out, " maxlength=\"{limit}\"");
423    }
424    push_bounds(out, field);
425    if field.invalid() {
426        out.push_str(" aria-invalid=\"true\"");
427    }
428
429    push_described_by(out, field, id);
430
431    // Last, so that a host attaching a fact of its own can see everything this
432    // emitter decided and cannot be overwritten by it. Duplicate attributes are
433    // the caller's to avoid: HTML takes the first of a repeated pair, so an
434    // attribute spelled here as well as there keeps this crate's answer.
435    if let Some(Markup(attrs)) = filling.control_attrs {
436        out.push(' ');
437        out.push_str(attrs);
438    }
439}
440
441/// The `aria-describedby` naming whatever of the hint and the error exist.
442///
443/// Both associations, in the order they are useful: the standing help, then
444/// what is currently wrong. goingson's runtime path points describedby at the
445/// error alone and drops the hint association it never made in the first place;
446/// naming both here means the hint survives an error appearing.
447///
448/// Its own function because a radio group carries it on the group rather than
449/// on a control, and one reading of "what describes this field" is the point.
450fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
451    let unit = unit_of(field).is_some();
452    if field.hint.is_none() && field.error.is_none() && field.note.is_none() && !unit {
453        return;
454    }
455    let mut written = false;
456    out.push_str(" aria-describedby=\"");
457    if field.hint.is_some() {
458        let _ = write!(out, "{id}-hint");
459        written = true;
460    }
461    // The unit before the error and after the hint, which is the order they are
462    // useful in: what the number is measured in is standing context like the
463    // hint, and what is wrong with it now comes last.
464    if unit {
465        if written {
466            out.push(' ');
467        }
468        let _ = write!(out, "{id}-unit");
469        written = true;
470    }
471    // The note after the unit and before the error, matching the order the
472    // three are drawn in and the order they are useful in: what the answer
473    // costs is context, and what is wrong with it now still comes last.
474    if field.note.is_some() {
475        if written {
476            out.push(' ');
477        }
478        let _ = write!(out, "{id}-note");
479        written = true;
480    }
481    if field.error.is_some() {
482        if written {
483            out.push(' ');
484        }
485        let _ = write!(out, "{id}-error");
486    }
487    out.push('"');
488}
489
490/// The unit to draw beside this field's value, if there is one to draw.
491///
492/// Two conditions rather than one: the field has to carry a unit and its kind
493/// has to be one that means anything by it. `FieldKind::measurable` is the
494/// description answering the second, so this renderer keeps no list of its own
495/// of which kinds are quantities.
496fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
497    field.unit.filter(|_| field.kind.measurable())
498}
499
500/// Whether the field's control is a set of elements rather than one.
501///
502/// A DOM concern rather than a description one, which is why it is decided here
503/// and not in `makeover-layout`: `for` and `id` are an HTML association and
504/// egui has no counterpart to get wrong. A `<label for>` aimed at a radio group
505/// points at nothing, because no single element carries the group's id, so the
506/// association has to invert — the label takes an id and the group names itself
507/// with `aria-labelledby`.
508const fn is_group_control(kind: FieldKind) -> bool {
509    matches!(kind, FieldKind::Radio | FieldKind::Interval)
510}
511
512/// An interval: two number boxes inside one labelled group.
513///
514/// The markup MNW's discover sidebar writes by hand -- a `role="group"` with
515/// `aria-labelledby` pointing at the question, holding `min_price` and
516/// `max_price` -- which is HTML saying by hand exactly what
517/// [`FieldKind::Interval`] now says in the description. So this emits what that
518/// page already proved is right, rather than inventing a shape.
519///
520/// The group carries the error state and the descriptions, for
521/// [`push_radio`]'s reason: what is wrong is the answer, and marking one box
522/// invalid would name the wrong half of a fault that belongs to both ends.
523///
524/// # Both boxes take the same extent
525///
526/// [`Field::min`], [`Field::max`] and [`Field::step`] describe the axis rather
527/// than either end, so [`push_bounds`] writes them onto both. The crossing rule
528/// is not emitted, because the description does not carry it and the browser
529/// has no attribute for it: an upper end below the lower one is a refusal
530/// whoever validated hands back as [`Field::error`], which lands on the group.
531///
532/// # Which end is which, in words
533///
534/// `aria-label`, because the description states direction structurally -- the
535/// lower end's name is [`Field::name`] and the upper one's is
536/// [`Field::upper_name`] -- and never in words. Words for the ends are the
537/// host's, the same way a slider's readout is, and a page with visible Min and
538/// Max captions supplies them through [`Filling::trailing`] rather than having
539/// this crate own two strings of English.
540fn push_interval(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
541    let id = filling.id_for(field.name);
542
543    out.push_str("<div class=\"");
544    push_class(out, "form-interval", opts);
545    let _ = write!(out, "\" role=\"group\" aria-labelledby=\"{id}-label\"");
546    if field.invalid() {
547        out.push_str(" aria-invalid=\"true\"");
548    }
549    push_described_by(out, field, &id);
550    out.push('>');
551
552    // An interval with no upper name has one end that can be submitted, which
553    // is what the description said and is drawn honestly rather than repaired:
554    // `Field::interval` is what makes it unsayable, and inventing a name here
555    // would submit a parameter no handler is reading.
556    let ends: [(&str, &str, &str); 2] = [
557        ("lower", field.name, filling.value.as_text()),
558        (
559            "upper",
560            field.upper_name.unwrap_or(""),
561            filling.value.upper_text(),
562        ),
563    ];
564    for (end, name, value) in ends {
565        if name.is_empty() {
566            continue;
567        }
568        out.push_str("<input type=\"number\" class=\"");
569        push_class(out, "field", opts);
570        let _ = write!(out, "\" id=\"{id}-{end}\" name=\"");
571        escape_into(name, out);
572        let _ = write!(out, "\" aria-label=\"{end}\"");
573        if field.required {
574            out.push_str(" required");
575        }
576        push_bounds(out, field);
577        if let Some(text) = field.placeholder {
578            out.push_str(" placeholder=\"");
579            escape_into(text, out);
580            out.push('"');
581        }
582        out.push_str(" value=\"");
583        escape_into(value, out);
584        out.push_str("\">");
585    }
586
587    out.push_str("</div>");
588}
589
590/// A radio group: the options as sibling inputs sharing one `name`.
591///
592/// The group carries the error state and the descriptions, and the inputs carry
593/// what submits. That split is [`Field::invalid`]'s reasoning applied one level
594/// down: marking a single input invalid would say the wrong thing, since what
595/// is wrong is the answer to the question and not one of the alternatives.
596///
597/// Ids are numbered rather than built from the option values, which can hold
598/// anything a `&str` can — spaces and quotes included — and would otherwise
599/// have to be slugged into something unique by a rule this crate would then own.
600///
601/// `required` lands on every input, which is how HTML says a group is
602/// compulsory: the constraint is satisfied when any one of them is checked.
603fn push_radio(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
604    let id = filling.id_for(field.name);
605    let value = filling.value.as_text();
606    let name = escape(field.name);
607
608    out.push_str("<div class=\"");
609    push_class(out, "form-radio-group", opts);
610    let _ = write!(out, "\" role=\"radiogroup\" aria-labelledby=\"{id}-label\"");
611    if field.invalid() {
612        out.push_str(" aria-invalid=\"true\"");
613    }
614    push_described_by(out, field, &id);
615    out.push('>');
616
617    // A group described with no options emits an empty group, for the reason
618    // `Field::options` gives: an app whose option list has not loaded has
619    // exactly that, and an empty group says so on screen rather than in a log.
620    for (index, opt) in field.options.iter().enumerate() {
621        out.push_str("<label class=\"");
622        push_class(out, "form-radio-label", opts);
623        let _ = write!(
624            out,
625            "\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" value=\""
626        );
627        escape_into(opt.value, out);
628        out.push('"');
629        if opt.value == value {
630            out.push_str(" checked");
631        }
632        if field.required {
633            out.push_str(" required");
634        }
635        // A radio group has room a `<select>` does not, so the reason gets its
636        // own element beside the label rather than being run into it. The class
637        // is what a stylesheet mutes; the text is there either way, which is
638        // the half that matters — the finding was a greyed control with its
639        // explanation behind a hover.
640        if let Some(reason) = opt.unavailable {
641            out.push_str(" disabled");
642            out.push_str("><span>");
643            escape_into(opt.label, out);
644            out.push_str("</span><span class=\"");
645            push_class(out, "form-option-reason", opts);
646            out.push_str("\">");
647            escape_into(reason, out);
648            out.push_str("</span></label>");
649            continue;
650        }
651        out.push_str("><span>");
652        escape_into(opt.label, out);
653        out.push_str("</span></label>");
654    }
655
656    out.push_str("</div>");
657}
658
659/// The options of a select: the unanswered instruction, an unmatched current
660/// value carried as its own, then the options themselves.
661///
662/// A select handed a value no option carries renders with nothing selected, the
663/// browser falls back to the first option, and the next save writes a value
664/// nobody chose. goingson hit exactly that with a backup-retention default of
665/// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
666/// here so the second app gets it without hitting the bug first.
667fn push_options(out: &mut String, field: &Field<'_>, options: &[Choice<'_>], value: &str) {
668    // The unanswered state, which HTML has no attribute for: `placeholder` is
669    // not a `<select>` attribute, and the idiom is an empty option that cannot
670    // be chosen back. `disabled` is what stops it being re-selected once the
671    // user has answered, and `selected` is what puts it in the closed control
672    // while the value is empty; together they read as an instruction rather
673    // than as an option.
674    //
675    // `required` keeps working through it rather than around it: the option's
676    // value is empty, so a required select with this showing is invalid, which
677    // is the true report on a question nobody has answered.
678    //
679    // Emitted only while the value is empty, so it does not sit in the open
680    // list once the field is answered. A non-empty value no option carries is a
681    // wrong answer rather than an absent one and takes the stray-option path
682    // below.
683    if value.is_empty()
684        && let Some(text) = field.placeholder
685    {
686        out.push_str("<option value=\"\" disabled selected>");
687        escape_into(text, out);
688        out.push_str("</option>");
689    }
690    if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
691        // The one place an escaped value is worth keeping: it is written twice,
692        // as the option's value and as its text.
693        let escaped = escape(value);
694        let _ = write!(
695            out,
696            "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
697        );
698    }
699    for opt in options {
700        out.push_str("<option value=\"");
701        escape_into(opt.value, out);
702        out.push('"');
703        if opt.value == value {
704            out.push_str(" selected");
705        }
706        // `disabled` is what the browser reads, and it says nothing about why.
707        // The reason goes in the option's own text, because a `<select>` gives
708        // its options no room for anything else: no title attribute the
709        // keyboard reaches, no second line, no element inside. So the row reads
710        // "Multi-sample: Drop a second sample onto the keyboard." and is the
711        // one place the precondition can be both attached to its option and
712        // read without a pointer.
713        if let Some(reason) = opt.unavailable {
714            out.push_str(" disabled");
715            out.push('>');
716            escape_into(opt.label, out);
717            out.push_str(": ");
718            escape_into(reason, out);
719            out.push_str("</option>");
720            continue;
721        }
722        out.push('>');
723        escape_into(opt.label, out);
724        out.push_str("</option>");
725    }
726}
727
728/// The control itself, without its label, hint or error.
729fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
730    // Emitted before anything else is computed: a radio group carries its
731    // descriptions on the group rather than on a control, so none of the
732    // attributes below belong to it.
733    if matches!(field.kind, FieldKind::Radio) {
734        push_radio(out, field, filling, opts);
735        return;
736    }
737    // The same split one kind along: an interval is two inputs and one
738    // question, so the group carries the error and the descriptions and the
739    // boxes carry what submits.
740    if matches!(field.kind, FieldKind::Interval) {
741        push_interval(out, field, filling, opts);
742        return;
743    }
744
745    let id = filling.id_for(field.name);
746    let placeholder = |out: &mut String| {
747        if let Some(text) = field.placeholder {
748            out.push_str(" placeholder=\"");
749            escape_into(text, out);
750            out.push('"');
751        }
752    };
753
754    match field.kind {
755        // Both multi-line kinds are a `<textarea>`, and the markdown one says so
756        // in an attribute rather than in a class: what the value *is* is not a
757        // styling hook, and a progressive enhancement looking for editors to
758        // upgrade needs a selector that survives `Emit`'s class prefixing.
759        // Without the mark, a described editor is a plain box and the four
760        // hand-written MNW editors have nothing to convert onto.
761        //
762        // `data-format` and not `data-value`: this names the shape of the
763        // value, and `facet` already spends `data-facet-value` on carrying an
764        // actual one. Two attributes a letter apart meaning opposite things is
765        // how a renderer's own vocabulary starts drifting.
766        kind if kind.multiline() => {
767            let rich = matches!(kind, FieldKind::Rich);
768            if rich {
769                push_editor_open(out, opts);
770            }
771            out.push_str("<textarea class=\"");
772            push_class(out, "field", opts);
773            out.push('"');
774            if rich {
775                out.push_str(" data-format=\"markdown\"");
776            }
777            push_control_attributes(out, field, filling, &id, field.name);
778            placeholder(out);
779            out.push('>');
780            escape_into(filling.value.as_text(), out);
781            out.push_str("</textarea>");
782            if rich {
783                push_editor_close(out, opts);
784            }
785        }
786        FieldKind::Select => {
787            out.push_str("<select class=\"");
788            push_class(out, "field", opts);
789            out.push('"');
790            push_control_attributes(out, field, filling, &id, field.name);
791            out.push('>');
792            // A select described with no options emits an empty select, which
793            // says so on screen rather than in a log. That is the description's
794            // own position on `Field::options`, not a fallback invented here.
795            push_options(out, field, field.options, filling.value.as_text());
796            out.push_str("</select>");
797        }
798        FieldKind::Checkbox => {
799            out.push_str("<label class=\"");
800            push_class(out, "form-checkbox-label", opts);
801            out.push_str("\"><input type=\"checkbox\"");
802            push_control_attributes(out, field, filling, &id, field.name);
803            if matches!(filling.value, Value::On(true)) {
804                out.push_str(" checked");
805            }
806            out.push_str("><span>");
807            escape_into(field.label, out);
808            out.push_str("</span></label>");
809        }
810        // A secret never carries its value into the markup. `FieldKind::secret`
811        // is documented as a value that must not be round-tripped through
812        // anything that might persist it, and the DOM is such a thing: it is
813        // read by every extension on the page and is the first thing a crash
814        // reporter serialises. Neither app pre-fills one today, so this costs
815        // nothing and closes the door before something does.
816        FieldKind::Secret => {
817            out.push_str("<input type=\"password\" class=\"");
818            push_class(out, "field", opts);
819            out.push('"');
820            push_control_attributes(out, field, filling, &id, field.name);
821            placeholder(out);
822            out.push('>');
823        }
824        // A file input carries no value, and this is the browser's rule rather
825        // than a preference: setting one from markup is refused, because a page
826        // that could preselect a path could read a file the user never offered.
827        // Nothing upstream needs to know, which is why the exception is here.
828        FieldKind::File => {
829            out.push_str("<input type=\"file\" class=\"");
830            push_class(out, "field", opts);
831            out.push('"');
832            push_control_attributes(out, field, filling, &id, field.name);
833            push_accept(out, field);
834            if field.multiple {
835                out.push_str(" multiple");
836            }
837            out.push('>');
838        }
839        kind => {
840            let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
841            push_class(out, "field", opts);
842            out.push('"');
843            push_control_attributes(out, field, filling, &id, field.name);
844            placeholder(out);
845            out.push_str(" value=\"");
846            escape_into(filling.value.as_text(), out);
847            out.push_str("\">");
848        }
849    }
850}
851
852/// The chrome a markdown field gets and a plain textarea does not: the two
853/// modes, and the pane a preview lands in.
854///
855/// # Why this is the one field with markup around it
856///
857/// [`FieldKind::Rich`]'s own doc says the mark buys a renderer permission to
858/// offer a preview or a syntax pass, and that a renderer with neither draws a
859/// textarea. A renderer taking the permission and emitting the same box as
860/// [`FieldKind::Textarea`] leaves an app converting onto the member with less
861/// than it had written by hand: MNW's `partial-item-text-editor.js` has a
862/// Write/Preview pair and a pane behind it, and describing the field without
863/// this would delete both. So the pair is here, on `facet`'s argument one
864/// field down -- the markup it replaces is not markup an app is keeping.
865///
866/// # Nothing here renders markdown, and that is where the sanitising stays
867///
868/// The pane arrives empty and this crate never turns a value into markup.
869/// Converting markdown is the host's, which is where the sanitiser already is:
870/// MNW renders through `docengine` over ammonia and holds an allowlist beside
871/// it. A converter here would move that guarantee into a crate with no view of
872/// the host's content-security posture, and `Rich`'s doc is explicit that a
873/// host with its own sanitiser still owns it. What this emits is a hook, and
874/// whatever fills it fills it with markup it has already made safe.
875///
876/// # The direction the enhancement runs
877///
878/// [`crate::stylesheet`]'s rule for a showing region, and for its reason: a
879/// control rendered into a document with no script is a control that looks live
880/// and answers nothing. Nothing is hidden here and no control is shown until
881/// whatever binds the editor sets `data-ready` on the wrapper, so a reader with
882/// no script gets the textarea alone -- what 0.50.0 emitted -- and a reader with
883/// script gets the modes. A bound editor says which mode it is in with
884/// `data-mode`, and [`editor_rules`] reads that.
885fn push_editor_open(out: &mut String, opts: &Emit) {
886    // The mark sits on the wrapper as well as on the control, saying one thing
887    // about two: this control's value is markdown, and this editor edits
888    // markdown. The rules gate on the wrapper and they are attribute rules
889    // rather than class rules for `data-format`'s own reason -- the gate has to
890    // survive `Emit`'s class prefixing, because the enhancement selects on it
891    // too.
892    out.push_str("<div data-format=\"markdown\"><div class=\"");
893    push_class(out, "form-editor-modes", opts);
894    out.push_str("\">");
895    push_mode(out, "write", "Write", true, opts);
896    push_mode(out, "preview", "Preview", false, opts);
897    out.push_str("</div>");
898}
899
900/// One of the two modes, as a segment of the pair.
901///
902/// [`crate::option_class`] for [`Selector::Segmented`] rather than a name of
903/// its own: a Write/Preview pair is a segmented control, and spelling it as one
904/// gets it the depth, the focus ring and the chosen state every described
905/// selector gets, from rules that already exist. The words are written here for
906/// the reason `facet`'s exclude button writes its own: a description carrying
907/// them would be choosing them for the terminal as well.
908fn push_mode(out: &mut String, mode: &str, label: &str, chosen: bool, opts: &Emit) {
909    out.push_str("<button type=\"button\" class=\"");
910    push_class(out, crate::option_class(Selector::Segmented), opts);
911    if chosen {
912        // The sheet keys the held-in segment on the class and a screen reader
913        // reads the attribute. Both, because they are two readings of one fact,
914        // which is the arrangement a facet value already has.
915        out.push_str(" chosen");
916    }
917    let _ = write!(
918        out,
919        "\" data-editor-mode=\"{mode}\" aria-pressed=\"{chosen}\">{label}</button>"
920    );
921}
922
923/// The preview pane, and the wrapper closing over both halves.
924fn push_editor_close(out: &mut String, opts: &Emit) {
925    out.push_str("<div class=\"");
926    push_class(out, "form-editor-preview", opts);
927    // `data-editor-preview` and not an id: a form appears twice in a document
928    // often enough that `Filling::id_prefix` exists for it, and a binder holding
929    // the control can reach this without either of them being unique.
930    out.push_str("\" data-editor-preview></div></div>");
931}
932
933/// The rules the markdown editor's chrome needs.
934///
935/// The one place this module writes CSS. The class names [`field_html`] emits
936/// are goingson's and are deliberately unruled -- `.form-group`, `.form-label`,
937/// `.form-hint` and `.form-error` are the app's own, and phase A emits only what
938/// it can generate from the description -- but the two names here have no app
939/// counterpart to keep, because the chrome did not exist before the member did.
940///
941/// Every rule is gated on `[data-format="markdown"]`, which is what keeps them
942/// off a plain textarea, and every rule that hides content is gated on
943/// `data-ready` as well, which is what keeps them out of a document with no
944/// script.
945pub(crate) fn editor_rules(opts: &Emit) -> String {
946    let mut css = String::new();
947    let modes = class("form-editor-modes", opts);
948    let preview = class("form-editor-preview", opts);
949    let field = class("field", opts);
950
951    // Hidden until something binds the editor, which is the whole argument in
952    // `push_editor_open`.
953    let _ = writeln!(
954        css,
955        "[data-format=\"markdown\"] > .{modes} {{\n    display: none;\n}}"
956    );
957    // Block, and nothing about how the two segments sit in it. A button is
958    // inline already, so they make a row without this crate saying so, and
959    // saying so is where a gap would follow -- a magnitude, and
960    // `makeover-geometry`'s.
961    let _ = writeln!(
962        css,
963        "[data-format=\"markdown\"][data-ready] > .{modes} {{\n    display: block;\n}}"
964    );
965
966    // The pane is empty until the host fills it, so it is out of flow in every
967    // state but the one where a bound editor is showing it. An empty box under
968    // the control is chrome claiming a preview nobody rendered.
969    let _ = writeln!(
970        css,
971        "[data-format=\"markdown\"] > .{preview} {{\n    display: none;\n}}"
972    );
973    let _ = writeln!(
974        css,
975        "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
976         {{\n    display: block;\n}}"
977    );
978    // One at a time. The source and the preview are the same content read two
979    // ways, and a field showing both answers its own question twice.
980    let _ = writeln!(
981        css,
982        "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
983         {{\n    display: none;\n}}"
984    );
985
986    // The pane stands where the control stood, so it reads as the surface the
987    // control was: `.field` is a well, and this is the well it stands in for.
988    // Nothing about size -- how tall a preview is is the app's, the way the
989    // height of a track is.
990    let _ = write!(
991        css,
992        "[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
993        crate::depth_declarations(Depth::Well)
994    );
995
996    css
997}
998
999/// The rule a field's unit needs.
1000///
1001/// [`suggestion_rules`]' precedent and its argument: `.form-group`,
1002/// `.form-label`, `.form-hint` and `.form-error` are the apps' own names and
1003/// stay unruled here, and this one has no app counterpart to keep because
1004/// nothing emitted it before `Field::unit` existed.
1005///
1006/// One declaration, and it is the whole look. A unit is a fact about the number
1007/// beside it rather than a second thing to read, so it takes the muted content
1008/// intent -- the same reading `.figure-caption` and `.track-tick` take, and for
1009/// the same reason.
1010///
1011/// Nothing about placement or spacing. Where the span sits relative to the
1012/// control is the app's layout, exactly as `.form-hint`'s is, and a margin
1013/// asserted here would be this crate deciding a magnitude that belongs to
1014/// `makeover-geometry`.
1015/// The rules a field's note needs.
1016///
1017/// [`unit_rules`]' precedent and its argument: `.form-hint` and `.form-error`
1018/// are the apps' own names and stay unruled here, and this one has no app
1019/// counterpart to keep because nothing emitted it before [`Field::note`]
1020/// existed.
1021///
1022/// Colour only, and the tones are the four a badge carries. The bare class is
1023/// `content` rather than `content-muted`: a note is a consequence the user is
1024/// meant to read before answering, so muting it by default would be this crate
1025/// deciding it does not matter.
1026pub(crate) fn note_rules(opts: &Emit) -> String {
1027    let note = class("form-note", opts);
1028    let mut css = String::new();
1029    let _ = writeln!(css, ".{note} {{\n    color: var(--content);\n}}");
1030    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
1031        let _ = writeln!(
1032            css,
1033            ".{note}[data-tone=\"{0}\"] {{\n    color: var(--{0});\n}}",
1034            tone.token()
1035        );
1036    }
1037    css
1038}
1039
1040pub(crate) fn unit_rules(opts: &Emit) -> String {
1041    let unit = class("form-unit", opts);
1042    let mut css = String::new();
1043    let _ = writeln!(css, ".{unit} {{\n    color: var(--content-muted);\n}}");
1044    css
1045}
1046
1047/// The rules a field's suggestion list needs.
1048///
1049/// [`editor_rules`]' precedent and its argument: the class names this module's
1050/// markup emits are the apps' own and stay unruled, and these three have no app
1051/// counterpart to keep because the list did not exist before the member did.
1052/// The markup is `quasi-webview`'s rather than this crate's — a suggestion
1053/// source is a route, which no description layer carries — and the look is
1054/// still this crate's, because a renderer inventing how a list of candidates
1055/// reads is the drift the vocabulary check exists to catch.
1056///
1057/// # In flow, and not floating
1058///
1059/// An absolutely positioned list needs a positioned ancestor, and the only
1060/// candidate is `.form-group`, which is the app's class and deliberately
1061/// unruled here. So the list stands under the control and moves what is below
1062/// it. An app that wants it over the form positions the group itself, which is
1063/// one declaration and is the app's call about its own layout.
1064///
1065/// `:empty` is what takes it away, so a route that answers with no candidates
1066/// leaves no box behind. It is a content question rather than a whitespace one
1067/// only because the emitter writes no whitespace inside the container, which is
1068/// stated in `quasi-webview`'s own test.
1069///
1070/// # Nothing about size
1071///
1072/// No height, no scroll ceiling, no padding. How tall a list of candidates gets
1073/// to be before it scrolls is a magnitude, and magnitudes are
1074/// `makeover-geometry`'s, exactly as the preview pane's height is.
1075pub(crate) fn suggestion_rules(opts: &Emit) -> String {
1076    let list = class("form-suggestions", opts);
1077    let entry = class("form-suggestion", opts);
1078    let detail = class("form-suggestion-detail", opts);
1079    let mut css = String::new();
1080
1081    let _ = writeln!(css, ".{list}:empty {{\n    display: none;\n}}");
1082    // Over what it covers, which is what a list of candidates is even in flow:
1083    // it is answering the box above it and goes away when the answer is taken.
1084    css.push_str(&crate::depth_rule(&list, Depth::Overlay));
1085    // An entry answers a click, so it gets every state one implies.
1086    css.push_str(&crate::interactive_rules(&entry, Depth::Flat, opts));
1087    // The keyboard's highlight and the pointer's are the same surface. They are
1088    // the same fact told two ways, and a list where arrowing and hovering look
1089    // different is a list that has two current entries.
1090    //
1091    // Keyed on `aria-selected` rather than on a class, for the reason
1092    // `aria-invalid` carries the error state: it is what a screen reader hears,
1093    // so a look keyed on it cannot drift from what is announced. A `.current`
1094    // class would also be a name apps already spell for their own reasons --
1095    // the MNW server has one -- and unlayered app CSS beats this layer in
1096    // silence.
1097    let _ = writeln!(
1098        css,
1099        ".{entry}[aria-selected=\"true\"] {{\n    background: var(--hover-surface);\n}}"
1100    );
1101    // The second line, muted rather than disabled. `1fcf2e9b` replaced the
1102    // unavailable reason this rule used to draw: a candidate carries no
1103    // `unavailable`, and what sits beside the label now is what tells one row
1104    // from another that reads the same. Disabled would say the row cannot be
1105    // picked, which is the opposite of what the detail is for.
1106    let _ = writeln!(css, ".{detail} {{\n    color: var(--content-muted);\n}}");
1107
1108    css
1109}
1110
1111/// One field, as the group the app drops into its form.
1112///
1113/// The shape is goingson's, down to the class names, so adoption there deletes
1114/// `renderFormField` rather than restyling anything. That is also why the class
1115/// names are not emitted by [`crate::stylesheet`]: `.form-group`, `.form-label`,
1116/// `.form-hint` and `.form-error` are the apps' own, and phase A deliberately
1117/// emits only what it can generate from the description. Whether they should
1118/// move into the description is the next question this raises, not one it
1119/// answers.
1120///
1121/// A [`FieldKind::Hidden`] field is the input alone: no group, no label, and
1122/// nothing drawn, which is what [`FieldKind::visible`] means.
1123///
1124/// The error marks the group as well as the control. That is
1125/// [`Field::invalid`]'s own reasoning: a renderer with no descendant selectors
1126/// cannot find the group from the message, so the group has to be told.
1127///
1128/// ```
1129/// use makeover_layout::{Field, FieldKind};
1130/// use makeover_webview::{Emit, form::{Filling, Value, field_html}};
1131///
1132/// let field = Field::new(FieldKind::Text, "title", "Title");
1133/// let html = field_html(&field, &Filling::of(Value::Text("Ship it")), &Emit::default());
1134///
1135/// assert!(html.contains(r#"<label class="form-label" for="title">Title</label>"#));
1136/// assert!(html.contains(r#"value="Ship it""#));
1137/// ```
1138#[must_use]
1139pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
1140    let mut html = String::new();
1141    field_html_into(field, filling, opts, &mut html);
1142    html
1143}
1144
1145/// One field, written into a buffer the caller already has.
1146///
1147/// [`field_html`]'s streaming form, byte-identical to it. A form is a run of
1148/// these, so a host building one should hold a single buffer and append each
1149/// field into it rather than take a `String` per field and concatenate.
1150pub fn field_html_into(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit, out: &mut String) {
1151    let id = filling.id_for(field.name);
1152
1153    if !field.kind.visible() {
1154        // Name only, no id: a hidden field is never pointed at by a label or a
1155        // description, so the one attribute it needs is the one that submits.
1156        out.push_str("<input type=\"hidden\" name=\"");
1157        escape_into(field.name, out);
1158        out.push_str("\" value=\"");
1159        escape_into(filling.value.as_text(), out);
1160        out.push_str("\">");
1161        return;
1162    }
1163
1164    out.push_str("<div class=\"");
1165    push_class(out, "form-group", opts);
1166    if field.invalid() {
1167        out.push_str(" has-error");
1168    }
1169    if field.extended {
1170        // The disclosure that hides these is a property of the form, not of the
1171        // field, so the field is marked and the app opens or closes the group.
1172        out.push_str("\" data-extended=\"true");
1173    }
1174    out.push_str("\">");
1175
1176    // A checkbox labels itself, on the right of the box. Both apps special-case
1177    // this inline today, which is the tell that it belongs in the description;
1178    // `FieldKind::labels_itself` is where it went.
1179    if !field.kind.labels_itself() {
1180        out.push_str("<label class=\"");
1181        push_class(out, "form-label", opts);
1182        // A group control is named *by* its label rather than pointing at it,
1183        // so the two carry opposite halves of the association. See
1184        // `is_group_control`.
1185        if is_group_control(field.kind) {
1186            let _ = write!(out, "\" id=\"{id}-label\">");
1187        } else {
1188            let _ = write!(out, "\" for=\"{id}\">");
1189        }
1190        escape_into(field.label, out);
1191        out.push_str("</label>");
1192    }
1193
1194    push_control(out, field, filling, opts);
1195
1196    // Adjacent text, because HTML has no unit attribute and inventing one would
1197    // be markup nothing reads. Pointed at by `aria-describedby` so it is not
1198    // decoration a screen reader skips: the number and what it is measured in
1199    // are one fact, and reading the first without the second is reading it
1200    // wrong.
1201    if let Some(unit) = unit_of(field) {
1202        out.push_str("<span class=\"");
1203        push_class(out, "form-unit", opts);
1204        let _ = write!(out, "\" id=\"{id}-unit\">");
1205        escape_into(unit, out);
1206        out.push_str("</span>");
1207    }
1208
1209    if let Some(hint) = field.hint {
1210        out.push_str("<div class=\"");
1211        push_class(out, "form-hint", opts);
1212        let _ = write!(out, "\" id=\"{id}-hint\">");
1213        escape_into(hint, out);
1214        out.push_str("</div>");
1215    }
1216    // A consequence of the answer, between the standing help and the failure.
1217    // The tone rides on `data-tone` -- the same attribute every other toned
1218    // thing in this crate takes -- and it also picks the live region: Warning
1219    // and Danger are assertive, which is quasi-webview's own reading at
1220    // `node.rs:1403` and is honoured here rather than restated differently.
1221    if let Some((tone, note)) = field.note {
1222        out.push_str("<div class=\"");
1223        push_class(out, "form-note", opts);
1224        let assertive = matches!(tone, Tone::Warning | Tone::Danger);
1225        let _ = write!(
1226            out,
1227            "\" id=\"{id}-note\" role=\"{}\"",
1228            if assertive { "alert" } else { "status" }
1229        );
1230        // Neutral is the bare class rather than a variant, matching every
1231        // other toned component here: it is the absence of a status.
1232        if tone != Tone::Neutral {
1233            let _ = write!(out, " data-tone=\"{}\"", tone.token());
1234        }
1235        out.push('>');
1236        escape_into(note, out);
1237        out.push_str("</div>");
1238    }
1239    if let Some(Markup(markup)) = filling.trailing {
1240        out.push_str(markup);
1241    }
1242    if let Some(error) = field.error {
1243        out.push_str("<div class=\"");
1244        push_class(out, "form-error", opts);
1245        let _ = write!(out, " visible\" id=\"{id}-error\" role=\"alert\">");
1246        escape_into(error, out);
1247        out.push_str("</div>");
1248    }
1249
1250    out.push_str("</div>");
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255    use super::*;
1256    use makeover_layout::{Accepted, Curve, Family};
1257
1258    fn field(kind: FieldKind) -> Field<'static> {
1259        Field::new(kind, "title", "Title")
1260    }
1261
1262    #[test]
1263    fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
1264        // The payload from goingson's own CHRONIC-XSS regression test.
1265        let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
1266        let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1267        // The payload survives as text, which is the point: it is inert
1268        // because the quote that would have closed the attribute is encoded,
1269        // not because the words were filtered.
1270        assert!(!html.contains("\" onfocus"), "{html}");
1271        assert!(
1272            html.contains("value=\"x&quot; onfocus=alert(1) autofocus=&quot;\""),
1273            "{html}"
1274        );
1275    }
1276
1277    /// The seam quasi's suggestion source needs: a host's own attributes land
1278    /// on the control, unescaped, and after everything this crate decided.
1279    #[test]
1280    fn a_host_can_write_its_own_attributes_onto_the_control() {
1281        let mut filling = Filling::of(Value::Text("ru"));
1282        filling.control_attrs = Some(Markup(
1283            r#"role="combobox" aria-expanded="false" aria-controls="title-suggestions""#,
1284        ));
1285        let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1286        assert!(html.contains(r#"role="combobox""#), "{html}");
1287        assert!(
1288            html.contains(r#"aria-controls="title-suggestions""#),
1289            "{html}"
1290        );
1291        // After the id, which is what "last" buys: a host can read what this
1292        // emitter wrote and cannot be overwritten by it.
1293        let id = html.find(r#"id="title""#).expect("id");
1294        let role = html.find(r#"role="combobox""#).expect("role");
1295        assert!(id < role, "{html}");
1296    }
1297
1298    /// A radio group has no one control element, so there is nowhere honest to
1299    /// put an attribute meant for the control. Documented on the member.
1300    #[test]
1301    fn a_radio_group_drops_control_attributes() {
1302        let mut f = field(FieldKind::Radio);
1303        let options = [Choice::new("a", "A")];
1304        f.options = &options;
1305        let filling = Filling {
1306            control_attrs: Some(Markup(r#"data-host="1""#)),
1307            ..Filling::default()
1308        };
1309        let html = field_html(&f, &filling, &Emit::default());
1310        assert!(!html.contains("data-host"), "{html}");
1311    }
1312
1313    #[test]
1314    fn a_label_cannot_open_a_tag() {
1315        let mut f = field(FieldKind::Text);
1316        f.label = "<script>alert(1)</script>";
1317        let html = field_html(&f, &Filling::default(), &Emit::default());
1318        assert!(!html.contains("<script>"), "{html}");
1319        assert!(html.contains("&lt;script&gt;"), "{html}");
1320    }
1321
1322    #[test]
1323    fn every_escaped_sink_is_covered_by_the_one_escaper() {
1324        assert_eq!(escape("&<>\"'"), "&amp;&lt;&gt;&quot;&#39;");
1325        // The character `textContent` serialization leaves alone, which is why
1326        // the app needs two escapers and this needs one.
1327        assert!(escape("\"").contains("&quot;"));
1328    }
1329
1330    /// The streaming escaper is the one the emitters call and [`escape`] is a
1331    /// buffer around it, so the two cannot be allowed to drift. It copies in
1332    /// runs between the encoded characters, which is where a multi-byte
1333    /// character would break it if the scan were not restricted to ASCII.
1334    #[test]
1335    fn the_streaming_escaper_appends_what_the_returning_one_returns() {
1336        for text in [
1337            "",
1338            "plain",
1339            "&<>\"'",
1340            "&&&",
1341            "a & b",
1342            "trailing&",
1343            "&leading",
1344            "é世 & <b>naïve</b> \u{1f600}",
1345        ] {
1346            let mut out = String::from("kept: ");
1347            escape_into(text, &mut out);
1348            assert_eq!(out, format!("kept: {}", escape(text)), "{text:?}");
1349        }
1350    }
1351
1352    /// Same obligation one layer up: a form is a run of fields appended into one
1353    /// buffer, and the two ways to get one have to agree byte for byte.
1354    #[test]
1355    fn a_streamed_field_is_the_field_the_other_form_returns() {
1356        let kinds = [
1357            FieldKind::Text,
1358            FieldKind::Secret,
1359            FieldKind::Number,
1360            FieldKind::Checkbox,
1361            FieldKind::Radio,
1362            FieldKind::Select,
1363            FieldKind::Textarea,
1364            FieldKind::File,
1365            FieldKind::Hidden,
1366        ];
1367        let choices = [Choice::plain("one"), Choice::plain("two")];
1368        let opts = Emit {
1369            class_prefix: "mk-",
1370            ..Emit::default()
1371        };
1372        for kind in kinds {
1373            let described = Field {
1374                hint: Some("a hint"),
1375                error: Some("wrong <here>"),
1376                placeholder: Some("x\" y"),
1377                options: &choices,
1378                required: true,
1379                max_length: Some(40),
1380                min: Some("1"),
1381                max: Some("9"),
1382                extended: true,
1383                ..Field::new(kind, "the & name", "The <label>")
1384            };
1385            let filling = Filling {
1386                value: Value::Text("one"),
1387                trailing: Some(Markup("<i>t</i>")),
1388                control_attrs: Some(Markup(r#"data-host="1""#)),
1389                id_prefix: Some("modal"),
1390            };
1391            let mut streamed = String::new();
1392            field_html_into(&described, &filling, &opts, &mut streamed);
1393            assert_eq!(
1394                streamed,
1395                field_html(&described, &filling, &opts),
1396                "{kind:?}"
1397            );
1398
1399            // And the bare field, where every optional half is absent.
1400            let plain = Field::new(kind, "name", "Label");
1401            let mut streamed = String::new();
1402            field_html_into(&plain, &Filling::default(), &opts, &mut streamed);
1403            assert_eq!(
1404                streamed,
1405                field_html(&plain, &Filling::default(), &opts),
1406                "{kind:?}"
1407            );
1408        }
1409    }
1410
1411    #[test]
1412    fn markup_is_the_only_way_past_the_escaping() {
1413        let filling = Filling {
1414            trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
1415            ..Filling::default()
1416        };
1417        let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
1418        assert!(
1419            html.contains("<div class=\"recurrence-config\"></div>"),
1420            "{html}"
1421        );
1422    }
1423
1424    #[test]
1425    fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
1426        let mut f = field(FieldKind::Text);
1427        f.error = Some("Required");
1428        let opts = Emit::default();
1429        let html = field_html(&f, &Filling::default(), &opts);
1430        assert!(html.contains("aria-invalid=\"true\""), "{html}");
1431        // The selector the CSS side emits for exactly this state.
1432        assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
1433        // And the group is marked too, which a renderer without descendant
1434        // selectors depends on.
1435        assert!(html.contains("has-error"), "{html}");
1436    }
1437
1438    #[test]
1439    fn a_valid_field_claims_nothing_about_being_invalid() {
1440        let html = field_html(
1441            &field(FieldKind::Text),
1442            &Filling::default(),
1443            &Emit::default(),
1444        );
1445        assert!(!html.contains("aria-invalid"), "{html}");
1446        assert!(!html.contains("has-error"), "{html}");
1447    }
1448
1449    #[test]
1450    fn a_note_sits_between_the_hint_and_the_error_and_carries_its_tone() {
1451        let mut f = field(FieldKind::Text);
1452        f.hint = Some("Keep it short");
1453        f.note = Some((Tone::Warning, "Re-encoding drops embedded BWF"));
1454        f.error = Some("Required");
1455        let html = field_html(&f, &Filling::default(), &Emit::default());
1456
1457        // All three associated, in the order they are drawn.
1458        assert!(
1459            html.contains(r#"aria-describedby="title-hint title-note title-error""#),
1460            "{html}"
1461        );
1462        assert!(
1463            html.contains(r#"id="title-note" role="alert" data-tone="warning""#),
1464            "{html}"
1465        );
1466        // And in that order in the document, so the reading order matches.
1467        let hint = html.find("title-hint").unwrap();
1468        let note = html.rfind("title-note").unwrap();
1469        let err = html.rfind("title-error").unwrap();
1470        assert!(hint < note && note < err, "{html}");
1471    }
1472
1473    #[test]
1474    fn a_quiet_note_is_polite_and_wears_no_tone_attribute() {
1475        // Neutral is the bare class, matching every other toned component
1476        // here, and only Warning and Danger interrupt.
1477        let mut f = field(FieldKind::Text);
1478        f.note = Some((Tone::Info, "This is what that setting implies"));
1479        let html = field_html(&f, &Filling::default(), &Emit::default());
1480        assert!(html.contains(r#"role="status" data-tone="info""#), "{html}");
1481
1482        f.note = Some((Tone::Neutral, "An ordinary fact"));
1483        let html = field_html(&f, &Filling::default(), &Emit::default());
1484        assert!(html.contains(r#"id="title-note" role="status">"#), "{html}");
1485        assert!(!html.contains("data-tone"), "{html}");
1486    }
1487
1488    #[test]
1489    fn a_note_does_not_mark_the_group_invalid() {
1490        // `Field::invalid` stays `error.is_some()`, and the renderer's
1491        // `has-error` follows it rather than any message being present.
1492        let mut f = field(FieldKind::Text);
1493        f.note = Some((Tone::Danger, "This cannot be undone"));
1494        let html = field_html(&f, &Filling::default(), &Emit::default());
1495        assert!(!html.contains("has-error"), "{html}");
1496        assert!(!html.contains(r#"aria-invalid="true""#), "{html}");
1497    }
1498
1499    #[test]
1500    fn the_hint_survives_an_error_arriving() {
1501        let mut f = field(FieldKind::Text);
1502        f.hint = Some("Keep it short");
1503        f.error = Some("Required");
1504        let html = field_html(&f, &Filling::default(), &Emit::default());
1505        assert!(
1506            html.contains("aria-describedby=\"title-hint title-error\""),
1507            "{html}"
1508        );
1509    }
1510
1511    #[test]
1512    fn a_secret_never_carries_its_value_into_the_markup() {
1513        let filling = Filling::of(Value::Text("hunter2"));
1514        let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
1515        assert!(!html.contains("hunter2"), "{html}");
1516        assert!(html.contains("type=\"password\""), "{html}");
1517    }
1518
1519    #[test]
1520    fn a_hidden_field_is_the_input_and_nothing_else() {
1521        let filling = Filling::of(Value::Text("42"));
1522        let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
1523        assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
1524    }
1525
1526    #[test]
1527    fn a_checkbox_labels_itself_and_takes_no_separate_label() {
1528        let html = field_html(
1529            &field(FieldKind::Checkbox),
1530            &Filling::of(Value::On(true)),
1531            &Emit::default(),
1532        );
1533        assert!(!html.contains("form-label"), "{html}");
1534        assert!(html.contains("checked"), "{html}");
1535        assert!(html.contains("<span>Title</span>"), "{html}");
1536    }
1537
1538    #[test]
1539    fn a_select_keeps_a_value_no_option_carries() {
1540        let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
1541        let f = Field::select("title", "Title", &options);
1542        let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1543        assert!(html.contains("data-unmatched=\"true\""), "{html}");
1544        // Selected, so the next save round-trips it rather than writing the
1545        // first option over the top of it.
1546        assert!(html.contains("<option value=\"10\" selected"), "{html}");
1547    }
1548
1549    #[test]
1550    fn a_select_with_no_options_emits_an_empty_select() {
1551        // The description says a select with no options is sayable, because an
1552        // app whose option list has not loaded has exactly that. Emitting the
1553        // empty select reports it on screen rather than in a log.
1554        let f = Field::select("title", "Title", &[]);
1555        let html = field_html(&f, &Filling::default(), &Emit::default());
1556        assert!(html.contains("<select"), "{html}");
1557        assert!(!html.contains("<option"), "{html}");
1558    }
1559
1560    #[test]
1561    fn an_unanswered_select_shows_its_ghost_text_and_cannot_be_chosen_back() {
1562        let options = [Choice::new("sp404", "SP-404")];
1563        let f = Field {
1564            placeholder: Some("Select device..."),
1565            ..Field::select("device", "Conform for device", &options)
1566        };
1567        let html = field_html(&f, &Filling::default(), &Emit::default());
1568
1569        assert!(
1570            html.contains("<option value=\"\" disabled selected>Select device...</option>"),
1571            "{html}"
1572        );
1573        // First, so the closed control reads it rather than the first real
1574        // option.
1575        assert!(
1576            html.find("Select device...") < html.find("SP-404"),
1577            "{html}"
1578        );
1579    }
1580
1581    #[test]
1582    fn an_answered_select_drops_the_ghost_text() {
1583        // It is an instruction about an empty field, so it has nothing to say
1584        // once the field is answered, and leaving it in the list is one dead
1585        // row every time the control is opened afterwards.
1586        let options = [Choice::new("sp404", "SP-404")];
1587        let f = Field {
1588            placeholder: Some("Select device..."),
1589            ..Field::select("device", "Conform for device", &options)
1590        };
1591        let html = field_html(&f, &Filling::of(Value::Text("sp404")), &Emit::default());
1592        assert!(!html.contains("Select device..."), "{html}");
1593    }
1594
1595    #[test]
1596    fn a_wrong_answer_is_kept_and_is_not_the_ghost_text() {
1597        // The two paths through `push_options` meet here. An unmatched value is
1598        // an answer that is wrong and stays visible as itself; only the empty
1599        // value is unanswered.
1600        let options = [Choice::plain("1"), Choice::plain("7")];
1601        let f = Field {
1602            placeholder: Some("Pick one"),
1603            ..Field::select("retention", "Keep backups for", &options)
1604        };
1605        let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
1606        assert!(html.contains("data-unmatched=\"true\""), "{html}");
1607        assert!(!html.contains("Pick one"), "{html}");
1608    }
1609
1610    #[test]
1611    fn a_range_is_a_range_input_and_carries_its_extent() {
1612        let f = Field {
1613            curve: Curve::Linear { step: Some("0.01") },
1614            ..Field::range("review", "Review above", "0", "1")
1615        };
1616        let html = field_html(&f, &Filling::of(Value::Text("0.72")), &Emit::default());
1617        assert!(html.contains("type=\"range\""), "{html}");
1618        assert!(html.contains("min=\"0\""), "{html}");
1619        assert!(html.contains("max=\"1\""), "{html}");
1620        // Without it the browser steps by 1 and a 0-to-1 question becomes a
1621        // two-position control.
1622        assert!(html.contains("step=\"0.01\""), "{html}");
1623    }
1624
1625    #[test]
1626    fn a_range_reads_its_granularity_off_the_curve_and_not_off_field_step() {
1627        // The 0.32.0 narrowing, at the renderer. `Field::step` on a range is a
1628        // site that has not been moved over, and emitting it would make the
1629        // control step by a number the curve never agreed to.
1630        let f = Field {
1631            step: Some("99"),
1632            ..Field::range("review", "Review above", "0", "1")
1633        };
1634        let html = field_html(&f, &Filling::of(Value::Text("0.5")), &Emit::default());
1635        assert!(!html.contains("step="), "{html}");
1636    }
1637
1638    #[test]
1639    fn a_unit_is_adjacent_text_and_the_control_points_at_it() {
1640        // Not decoration: the number and what it is measured in are one fact,
1641        // so the association is what makes this worth emitting at all.
1642        let f = Field {
1643            unit: Some("dBFS"),
1644            ..Field::range("threshold", "Threshold", "-96", "-20")
1645        };
1646        let html = field_html(&f, &Filling::of(Value::Text("-40")), &Emit::default());
1647        assert!(html.contains(r#"id="threshold-unit""#), "{html}");
1648        assert!(html.contains(">dBFS</span>"), "{html}");
1649        assert!(
1650            html.contains(r#"aria-describedby="threshold-unit""#),
1651            "{html}"
1652        );
1653        // The label is the question's name and keeps no unit in it.
1654        assert!(html.contains(">Threshold</label>"), "{html}");
1655    }
1656
1657    #[test]
1658    fn a_unit_takes_its_place_between_the_hint_and_the_error() {
1659        let f = Field {
1660            unit: Some("ms"),
1661            hint: Some("How long the fade runs."),
1662            error: Some("Too long."),
1663            ..Field::new(FieldKind::Number, "fade", "Fade")
1664        };
1665        let html = field_html(&f, &Filling::of(Value::Text("50")), &Emit::default());
1666        assert!(
1667            html.contains(r#"aria-describedby="fade-hint fade-unit fade-error""#),
1668            "{html}"
1669        );
1670    }
1671
1672    #[test]
1673    fn a_unit_on_a_kind_that_is_not_a_quantity_is_ignored() {
1674        // Sayable and ignored, the way `options` is on a kind that offers none.
1675        // The renderer asks the description which kinds are measurable rather
1676        // than keeping its own list.
1677        let f = Field {
1678            unit: Some("s"),
1679            ..Field::new(FieldKind::Text, "name", "Name")
1680        };
1681        let html = field_html(&f, &Filling::of(Value::Text("kick")), &Emit::default());
1682        assert!(!html.contains("name-unit"), "{html}");
1683        assert!(!html.contains("aria-describedby"), "{html}");
1684    }
1685
1686    #[test]
1687    fn a_unit_cannot_break_out_of_the_span_it_sits_in() {
1688        let f = Field {
1689            unit: Some("</span><script>"),
1690            ..Field::new(FieldKind::Number, "n", "N")
1691        };
1692        let html = field_html(&f, &Filling::of(Value::Text("1")), &Emit::default());
1693        assert!(!html.contains("<script>"), "{html}");
1694        assert!(html.contains("&lt;script&gt;"), "{html}");
1695    }
1696
1697    #[test]
1698    fn a_constant_ratio_curve_still_emits_a_linear_track() {
1699        // Honest shortfall rather than a silent one: HTML has no logarithmic
1700        // range input, so the browser draws the extent linearly. The value it
1701        // submits is still a value in the field's own units, which is what
1702        // every handler on this path reads. See the crate header.
1703        let f = Field {
1704            curve: Curve::Logarithmic {
1705                step: Some("0.001"),
1706            },
1707            ..Field::range("attack", "Attack", "0.001", "5")
1708        };
1709        let html = field_html(&f, &Filling::of(Value::Text("0.005")), &Emit::default());
1710        assert!(html.contains("type=\"range\""), "{html}");
1711        assert!(html.contains("min=\"0.001\""), "{html}");
1712        assert!(html.contains("max=\"5\""), "{html}");
1713        assert!(html.contains("step=\"0.001\""), "{html}");
1714    }
1715
1716    #[test]
1717    fn a_number_with_bounds_is_still_typed_into() {
1718        // The distinction the kind exists for, at the renderer where getting it
1719        // wrong is most visible: goingson's `min="1"` duration must not come
1720        // back as a slider.
1721        let f = Field {
1722            min: Some("1"),
1723            ..Field::new(FieldKind::Number, "minutes", "Minutes")
1724        };
1725        let html = field_html(&f, &Filling::of(Value::Text("30")), &Emit::default());
1726        assert!(html.contains("type=\"number\""), "{html}");
1727        assert!(!html.contains("type=\"range\""), "{html}");
1728        // And nothing invents a step for it.
1729        assert!(!html.contains("step="), "{html}");
1730    }
1731
1732    #[test]
1733    fn an_unavailable_option_is_disabled_and_says_why() {
1734        let options = [
1735            Choice::new("chromatic", "Chromatic"),
1736            Choice::new("multi", "Multi-sample").unless("Drop a second sample."),
1737        ];
1738        let f = Field::radio("mode", "Mode", &options);
1739        let html = field_html(&f, &Filling::of(Value::Text("chromatic")), &Emit::default());
1740
1741        assert!(html.contains(" disabled"), "{html}");
1742        assert!(html.contains("Drop a second sample."), "{html}");
1743        // The option is still offered: dropping it is what costs the user the
1744        // knowledge that the mode exists.
1745        assert!(html.contains("value=\"multi\""), "{html}");
1746        // And the reason is its own element, not run into the label.
1747        assert!(html.contains("form-option-reason"), "{html}");
1748    }
1749
1750    #[test]
1751    fn an_unavailable_select_option_carries_its_reason_in_its_text() {
1752        // A `<select>` gives an option no room for a second element, so the
1753        // reason has to be in the text or be unreadable without a pointer.
1754        let options = [Choice::new("multi", "Multi-sample").unless("Drop a second sample.")];
1755        let f = Field::select("mode", "Mode", &options);
1756        let html = field_html(&f, &Filling::default(), &Emit::default());
1757        assert!(
1758            html.contains(">Multi-sample: Drop a second sample.</option>"),
1759            "{html}"
1760        );
1761        assert!(html.contains("disabled"), "{html}");
1762    }
1763
1764    #[test]
1765    fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
1766        // The association inverts, and getting it wrong is silent: a
1767        // `<label for>` aimed at a group points at no element, so the group
1768        // simply has no accessible name and nothing reports that.
1769        let options = [Choice::plain("copy"), Choice::plain("reference")];
1770        let f = Field::radio("storage", "Storage style", &options);
1771        let html = field_html(&f, &Filling::of(Value::Text("copy")), &Emit::default());
1772
1773        assert!(html.contains("id=\"storage-label\""), "{html}");
1774        assert!(!html.contains("for=\"storage\""), "{html}");
1775        assert!(html.contains("role=\"radiogroup\""), "{html}");
1776        assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
1777    }
1778
1779    #[test]
1780    fn an_interval_is_one_labelled_group_holding_both_ends() {
1781        // The markup MNW's discover sidebar writes by hand, which is the
1782        // measurement that decided the member: `role="group"` naming the
1783        // question, two number boxes under it.
1784        let f = Field::interval("min_price", "max_price", "Price");
1785        let html = field_html(
1786            &f,
1787            &Filling::of(Value::Between {
1788                lower: "5",
1789                upper: "40",
1790            }),
1791            &Emit::default(),
1792        );
1793
1794        assert!(html.contains("role=\"group\""), "{html}");
1795        assert!(
1796            html.contains("aria-labelledby=\"min_price-label\""),
1797            "{html}"
1798        );
1799        assert!(html.contains("id=\"min_price-label\""), "{html}");
1800        assert!(!html.contains("for=\"min_price\""), "{html}");
1801        assert!(html.contains("name=\"min_price\""), "{html}");
1802        assert!(html.contains("name=\"max_price\""), "{html}");
1803        assert!(html.contains("value=\"5\""), "{html}");
1804        assert!(html.contains("value=\"40\""), "{html}");
1805        assert_eq!(html.matches("type=\"number\"").count(), 2, "{html}");
1806    }
1807
1808    #[test]
1809    fn both_ends_of_an_interval_take_the_whole_extent() {
1810        // The extent describes the axis rather than either end of it, so a
1811        // browser refuses the same values in both boxes.
1812        let f = Field {
1813            min: Some("0"),
1814            max: Some("300"),
1815            step: Some("1"),
1816            ..Field::interval("bpm_min", "bpm_max", "BPM")
1817        };
1818        let html = field_html(&f, &Filling::default(), &Emit::default());
1819
1820        assert_eq!(html.matches("min=\"0\"").count(), 2, "{html}");
1821        assert_eq!(html.matches("max=\"300\"").count(), 2, "{html}");
1822        assert_eq!(html.matches("step=\"1\"").count(), 2, "{html}");
1823        // Neither box holds anything, which is the open interval rather than an
1824        // empty form: no filter on this axis at all.
1825        assert_eq!(html.matches("value=\"\"").count(), 2, "{html}");
1826    }
1827
1828    #[test]
1829    fn an_interval_carries_the_fault_on_the_group_and_not_on_one_end() {
1830        // A crossed interval is wrong about the answer, and the answer is the
1831        // pair. This is the half two `Number` fields could not say.
1832        let f = Field {
1833            error: Some("The high end is below the low one."),
1834            hint: Some("Leave an end empty for no bound."),
1835            ..Field::interval("bpm_min", "bpm_max", "BPM")
1836        };
1837        let html = field_html(&f, &Filling::default(), &Emit::default());
1838
1839        assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
1840        let group = html.find("role=\"group\"").expect("group");
1841        let invalid = html.find("aria-invalid").expect("invalid");
1842        let first_input = html.find("<input").expect("input");
1843        assert!(invalid > group && invalid < first_input, "{html}");
1844        assert!(
1845            html.contains("aria-describedby=\"bpm_min-hint bpm_min-error\""),
1846            "{html}"
1847        );
1848    }
1849
1850    #[test]
1851    fn an_interval_with_one_end_named_draws_one_box() {
1852        // Drawn as described rather than repaired. Inventing a name for the
1853        // upper end would submit a parameter no handler reads, and
1854        // `Field::interval` is what makes the omission unsayable at the source.
1855        let f = Field::new(FieldKind::Interval, "bpm_min", "BPM");
1856        let html = field_html(&f, &Filling::default(), &Emit::default());
1857
1858        assert_eq!(html.matches("<input").count(), 1, "{html}");
1859        assert!(html.contains("name=\"bpm_min\""), "{html}");
1860    }
1861
1862    #[test]
1863    fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
1864        // One `name` is what makes them one answer rather than three; distinct
1865        // ids are what keep each `<label>` wrapping its own input.
1866        let options = [
1867            Choice::plain("copy"),
1868            Choice::plain("reference"),
1869            Choice::plain("link"),
1870        ];
1871        let f = Field::radio("storage", "Storage style", &options);
1872        let html = field_html(&f, &Filling::of(Value::Text("reference")), &Emit::default());
1873
1874        assert_eq!(html.matches("name=\"storage\"").count(), 3, "{html}");
1875        assert_eq!(html.matches(" checked").count(), 1, "{html}");
1876        assert!(
1877            html.contains("value=\"reference\" checked"),
1878            "the checked one is the one held: {html}"
1879        );
1880        for index in 0..3 {
1881            assert!(html.contains(&format!("id=\"storage-{index}\"")), "{html}");
1882        }
1883    }
1884
1885    #[test]
1886    fn a_radio_group_carries_the_error_rather_than_any_one_option() {
1887        // What is wrong is the answer, not one of the alternatives, so marking
1888        // a single input invalid would say something false. Same reading
1889        // `Field::invalid` gives one level up.
1890        let options = [Choice::plain("copy"), Choice::plain("reference")];
1891        let f = Field {
1892            error: Some("Pick one."),
1893            hint: Some("Cannot be changed later."),
1894            ..Field::radio("storage", "Storage style", &options)
1895        };
1896        let html = field_html(&f, &Filling::default(), &Emit::default());
1897
1898        assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
1899        assert!(
1900            html.contains("aria-describedby=\"storage-hint storage-error\""),
1901            "{html}"
1902        );
1903        // The group is the element that carries them, so they land before the
1904        // first option rather than on it.
1905        let group = html.find("role=\"radiogroup\"").expect("group");
1906        let first = html.find("type=\"radio\"").expect("an option");
1907        assert!(group < first, "{html}");
1908    }
1909
1910    #[test]
1911    fn a_compulsory_radio_group_marks_every_option() {
1912        // How HTML says a group is compulsory: the constraint reads as
1913        // satisfied when any one of them is checked.
1914        let options = [Choice::plain("copy"), Choice::plain("reference")];
1915        let f = Field {
1916            required: true,
1917            ..Field::radio("storage", "Storage style", &options)
1918        };
1919        let html = field_html(&f, &Filling::default(), &Emit::default());
1920        assert_eq!(html.matches(" required").count(), 2, "{html}");
1921    }
1922
1923    #[test]
1924    fn a_radio_option_cannot_break_out_of_its_attribute() {
1925        // Values are `&str` and carry whatever the app put in them. The ids are
1926        // numbered rather than derived from the value for the same reason.
1927        let hostile = [Choice::new(
1928            "x\" onclick=alert(1) data-x=\"",
1929            "<script>alert(1)</script>",
1930        )];
1931        let f = Field::radio("storage", "Storage style", &hostile);
1932        let html = field_html(&f, &Filling::default(), &Emit::default());
1933
1934        // The payload survives as text; what must not survive is the quote
1935        // that would end the attribute and let the rest of it become markup.
1936        assert!(html.contains("value=\"x&quot; onclick=alert(1)"), "{html}");
1937        assert!(!html.contains("<script>"), "{html}");
1938        assert!(html.contains("id=\"storage-0\""), "{html}");
1939    }
1940
1941    #[test]
1942    fn a_radio_group_with_no_options_emits_an_empty_group() {
1943        // Same position the select takes, and the description's own.
1944        let f = Field::radio("storage", "Storage style", &[]);
1945        let html = field_html(&f, &Filling::default(), &Emit::default());
1946        assert!(html.contains("role=\"radiogroup\""), "{html}");
1947        assert!(!html.contains("type=\"radio\""), "{html}");
1948    }
1949
1950    #[test]
1951    fn a_placeholder_comes_off_the_description_and_is_escaped() {
1952        // It arrived in `Filling` until makeover-layout 0.8.0 and was never
1953        // covered here; it is a value in an attribute like any other.
1954        let f = Field {
1955            placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
1956            ..field(FieldKind::Text)
1957        };
1958        let html = field_html(&f, &Filling::default(), &Emit::default());
1959        assert!(html.contains("placeholder=\""), "{html}");
1960        assert!(!html.contains("\" onfocus"), "{html}");
1961    }
1962
1963    #[test]
1964    fn a_select_marks_the_option_that_matches() {
1965        let options = [Choice::plain("1"), Choice::plain("3")];
1966        let f = Field::select("title", "Title", &options);
1967        let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
1968        assert!(
1969            html.contains("<option value=\"3\" selected>3</option>"),
1970            "{html}"
1971        );
1972        assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
1973        assert!(!html.contains("data-unmatched"), "{html}");
1974    }
1975
1976    #[test]
1977    fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
1978        let filling = Filling::of(Value::Text("two\nlines"));
1979        let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
1980        assert!(html.contains(">two\nlines</textarea>"), "{html}");
1981    }
1982
1983    #[test]
1984    fn a_markdown_field_is_a_textarea_that_says_what_its_value_is() {
1985        // The mark is the whole difference. Without it a described editor is a
1986        // plain box, and an enhancement looking for editors to upgrade has
1987        // nothing to find -- which is the state MNW's four hand-written section
1988        // editors would have had to keep living in.
1989        let filling = Filling::of(Value::Text("# Heading"));
1990        let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
1991        assert!(html.contains("<textarea"), "{html}");
1992        assert!(html.contains(r#"data-format="markdown""#), "{html}");
1993        assert!(html.contains("># Heading</textarea>"), "{html}");
1994
1995        // A plain textarea claims nothing about its value, so the marker has to
1996        // be absent rather than present-and-different.
1997        let plain = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
1998        assert!(!plain.contains("data-format"), "{plain}");
1999
2000        // And it is not an input: the catch-all in `input_type` would have
2001        // degraded it to a single-line text box, which is the wrong shape for
2002        // markdown rather than a lossless fallback.
2003        assert!(!html.contains("<input"), "{html}");
2004    }
2005
2006    #[test]
2007    fn a_markdown_field_gets_the_preview_the_member_permits() {
2008        // The mark on its own is what 0.50.0 shipped, and nothing read it. What
2009        // a conversion needs is the pair MNW's `partial-item-text-editor.js`
2010        // already draws, so describing the field is not a way to lose it.
2011        let filling = Filling::of(Value::Text("# Heading"));
2012        let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
2013        assert!(html.contains("data-editor-mode=\"write\""), "{html}");
2014        assert!(html.contains("data-editor-mode=\"preview\""), "{html}");
2015        assert!(html.contains("data-editor-preview"), "{html}");
2016        // Write is the mode a fresh editor is in, and the segment says so twice
2017        // because the sheet reads one and a screen reader reads the other.
2018        assert!(
2019            html.contains(
2020                "class=\"segment chosen\" data-editor-mode=\"write\" aria-pressed=\"true\""
2021            ),
2022            "{html}"
2023        );
2024        assert!(
2025            html.contains("data-editor-mode=\"preview\" aria-pressed=\"false\""),
2026            "{html}"
2027        );
2028        // The value is still the textarea's, and still text rather than an
2029        // attribute. The chrome sits around the control, not in place of it.
2030        assert!(html.contains("># Heading</textarea>"), "{html}");
2031    }
2032
2033    #[test]
2034    fn a_plain_textarea_gets_no_editor_chrome() {
2035        let filling = Filling::of(Value::Text("plain"));
2036        let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
2037        assert!(!html.contains("data-editor-mode"), "{html}");
2038        assert!(!html.contains("data-editor-preview"), "{html}");
2039        assert!(!html.contains("segment"), "{html}");
2040    }
2041
2042    #[test]
2043    fn nothing_the_editor_emits_renders_the_value_as_markup() {
2044        // The whole of this crate's half of the sanitising question: the pane is
2045        // empty, so no value reaches markup through it, and the host's own
2046        // renderer keeps the guarantee it already has.
2047        let filling = Filling::of(Value::Text("<img src=x onerror=alert(1)>"));
2048        let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
2049        assert!(html.contains("data-editor-preview></div>"), "{html}");
2050        assert!(!html.contains("<img"), "{html}");
2051        assert!(
2052            html.contains("&lt;img src=x onerror=alert(1)&gt;"),
2053            "{html}"
2054        );
2055    }
2056
2057    #[test]
2058    fn the_editor_rules_gate_on_the_attribute_and_on_a_binding() {
2059        let css = editor_rules(&Emit::default());
2060        // Behind the attribute, which is the reason the mark is an attribute:
2061        // a class-keyed gate would be prefixed away from the enhancement that
2062        // selects on it.
2063        for line in css.lines().filter(|line| line.contains('{')) {
2064            assert!(line.contains("[data-format=\"markdown\"]"), "{line}");
2065        }
2066        // Nothing is hidden and no control appears until something binds the
2067        // editor. A reader with no script gets the textarea alone.
2068        assert!(
2069            css.contains(
2070                "[data-format=\"markdown\"] > .form-editor-modes {\n    display: none;\n}"
2071            )
2072        );
2073        assert!(css.contains(
2074            "[data-format=\"markdown\"][data-ready] > .form-editor-modes {\n    display: block;\n}"
2075        ));
2076        assert!(css.contains(
2077            "[data-ready][data-mode=\"preview\"] > .form-editor-preview {\n    display: block;\n}"
2078        ));
2079        assert!(
2080            css.contains("[data-ready][data-mode=\"preview\"] > .field {\n    display: none;\n}")
2081        );
2082        // No magnitude, the line this crate holds everywhere else.
2083        assert!(!css.contains("px"), "{css}");
2084        assert!(!css.contains("rem"), "{css}");
2085    }
2086
2087    /// The prefix reaches the chrome as well, and the gate deliberately does
2088    /// not: an app assembling the sheet with its own prefix still has the
2089    /// selector an enhancement finds the editors by.
2090    #[test]
2091    fn the_editor_chrome_is_prefixed_and_its_gate_is_not() {
2092        let opts = Emit {
2093            class_prefix: "mk-",
2094            ..Emit::default()
2095        };
2096        let html = field_html(&field(FieldKind::Rich), &Filling::default(), &opts);
2097        assert!(html.contains("class=\"mk-form-editor-modes\""), "{html}");
2098        assert!(html.contains("class=\"mk-form-editor-preview\""), "{html}");
2099        assert!(html.contains("class=\"mk-segment chosen\""), "{html}");
2100        assert!(html.contains("data-format=\"markdown\""), "{html}");
2101
2102        let css = editor_rules(&opts);
2103        assert!(css.contains(".mk-form-editor-modes"), "{css}");
2104        assert!(css.contains("[data-format=\"markdown\"]"), "{css}");
2105    }
2106
2107    /// Every class the editor puts in markup is one the generated sheet rules,
2108    /// which is `FACET_CLASSES`' obligation without a list to keep: these two
2109    /// have rules, so the vocabulary seal picks them up from the sheet itself.
2110    #[test]
2111    fn the_editor_classes_are_in_the_vocabulary() {
2112        let opts = Emit::default();
2113        let names = crate::vocabulary::names(&opts);
2114        for name in ["form-editor-modes", "form-editor-preview", "segment"] {
2115            assert!(names.contains(name), "{name} is not in the vocabulary");
2116        }
2117    }
2118
2119    #[test]
2120    fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
2121        let opts = Emit {
2122            class_prefix: "mk-",
2123            ..Emit::default()
2124        };
2125        let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
2126        assert!(html.contains("class=\"mk-form-group\""), "{html}");
2127        assert!(html.contains("class=\"mk-field\""), "{html}");
2128    }
2129
2130    #[test]
2131    fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
2132        let mut f = field(FieldKind::Text);
2133        f.extended = true;
2134        let html = field_html(&f, &Filling::default(), &Emit::default());
2135        assert!(html.contains("data-extended=\"true\""), "{html}");
2136    }
2137
2138    /// The prefix scopes the id and leaves the name alone. Prefixing the name
2139    /// too would change what the form submits, which is the failure this pair
2140    /// of assertions exists to catch rather than describe.
2141    #[test]
2142    fn the_id_prefix_scopes_the_id_and_never_the_name() {
2143        let mut f = field(FieldKind::Text);
2144        f.hint = Some("Keep it short");
2145        f.error = Some("Required");
2146        let filling = Filling {
2147            id_prefix: Some("form-modal-task-edit"),
2148            ..Filling::default()
2149        };
2150        let html = field_html(&f, &filling, &Emit::default());
2151
2152        assert!(
2153            html.contains(r#"id="form-modal-task-edit-title""#),
2154            "{html}"
2155        );
2156        assert!(html.contains(r#"name="title""#), "{html}");
2157        assert!(
2158            !html.contains(r#"name="form-modal-task-edit-title""#),
2159            "{html}"
2160        );
2161
2162        // The label and both associations follow the id, or they point at
2163        // nothing once the same form is on screen twice.
2164        assert!(
2165            html.contains(r#"for="form-modal-task-edit-title""#),
2166            "{html}"
2167        );
2168        assert!(
2169            html.contains(
2170                r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
2171            ),
2172            "{html}"
2173        );
2174        assert!(
2175            html.contains(r#"id="form-modal-task-edit-title-hint""#),
2176            "{html}"
2177        );
2178    }
2179
2180    #[test]
2181    fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
2182        let filling = Filling {
2183            value: Value::Text("42"),
2184            id_prefix: Some("scoped"),
2185            ..Filling::default()
2186        };
2187        let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
2188        assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
2189    }
2190
2191    /// These three exist so a touch keyboard and the platform's validation
2192    /// arrive with the field. Emitting text for any of them is the regression
2193    /// the variants were added to prevent, so the type is asserted directly.
2194    #[test]
2195    fn a_constraint_becomes_the_browsers_own_attribute() {
2196        // makeover-layout 0.11.0's model: the description carries the rule and
2197        // each renderer emits its host's idiom for it. Enforcement is still
2198        // whoever validated's, and arrives back as `error`.
2199        let html = field_html(
2200            &Field {
2201                max_length: Some(100),
2202                min: Some("1"),
2203                max: Some("240"),
2204                required: true,
2205                ..Field::new(FieldKind::Number, "minutes", "Minutes")
2206            },
2207            &Filling::default(),
2208            &Emit::default(),
2209        );
2210        assert!(html.contains(r#"maxlength="100""#));
2211        assert!(html.contains(r#"min="1""#));
2212        assert!(html.contains(r#"max="240""#));
2213        assert!(html.contains(" required"));
2214    }
2215
2216    #[test]
2217    fn a_bound_is_emitted_as_written_and_escaped_like_anything_else() {
2218        // The bound is text because it is only a number for some of the kinds
2219        // that take one; goingson's own sites are a duration and a datetime.
2220        let html = field_html(
2221            &Field {
2222                min: Some("2026-08-09T14:30"),
2223                ..Field::new(FieldKind::Text, "starts", "Starts")
2224            },
2225            &Filling::default(),
2226            &Emit::default(),
2227        );
2228        assert!(html.contains(r#"min="2026-08-09T14:30""#));
2229    }
2230
2231    #[test]
2232    fn a_file_field_is_a_file_input() {
2233        // `844b5ae0`. A field that takes any file emits no `accept` at all,
2234        // which is the browser's own "any file". `accept=""` is a filter that
2235        // means nothing on one browser and everything on another.
2236        let html = field_html(
2237            &Field::new(FieldKind::File, "attachment", "Attachment"),
2238            &Filling::default(),
2239            &Emit::default(),
2240        );
2241        assert!(html.contains(r#"type="file""#));
2242        assert!(!html.contains("accept="));
2243        assert!(!html.contains("multiple"));
2244        // And it never carries a value: a file input's value is not settable
2245        // from markup, and the browser refuses one that tries.
2246        assert!(!html.contains("value="));
2247    }
2248
2249    #[test]
2250    fn an_accept_list_is_comma_joined_in_the_attributes_own_format() {
2251        // `f7261a5a`, makeover-layout 0.31.0. Each entry writes itself: a
2252        // family is its wildcard, a media type is itself, a suffix keeps its
2253        // leading dot and however many more it has.
2254        const MIXED: &[Accepted<'_>] = &[
2255            Accepted::Family(Family::Image),
2256            Accepted::Type("text/csv"),
2257            Accepted::Suffix(".tar.gz"),
2258        ];
2259        let html = field_html(
2260            &Field {
2261                multiple: true,
2262                ..Field::upload("drop", "Drop files", MIXED)
2263            },
2264            &Filling::default(),
2265            &Emit::default(),
2266        );
2267        assert!(
2268            html.contains(r#"accept="image/*,text/csv,.tar.gz""#),
2269            "{html}"
2270        );
2271        assert!(html.contains(" multiple"), "{html}");
2272    }
2273
2274    #[test]
2275    fn an_accept_entry_cannot_end_the_attribute_it_sits_in() {
2276        // The list reaches an attribute value, so it is escaped like every
2277        // other string that does. Nothing in the tree writes a quote into one;
2278        // that it cannot is the point.
2279        const HOSTILE: &[Accepted<'_>] = &[Accepted::Type(r#"image/x" onload="x"#)];
2280        let html = field_html(
2281            &Field::upload("cover", "Cover", HOSTILE),
2282            &Filling::default(),
2283            &Emit::default(),
2284        );
2285        assert!(!html.contains(r#"onload="x"#), "{html}");
2286    }
2287
2288    #[test]
2289    fn the_typed_text_kinds_keep_their_input_type() {
2290        for (kind, expected) in [
2291            (FieldKind::Email, "email"),
2292            (FieldKind::Url, "url"),
2293            (FieldKind::Tel, "tel"),
2294            (FieldKind::Date, "date"),
2295            (FieldKind::DateTime, "datetime-local"),
2296        ] {
2297            let html = field_html(&field(kind), &Filling::default(), &Emit::default());
2298            assert!(
2299                html.contains(&format!(r#"type="{expected}""#)),
2300                "{kind:?} emitted {html}"
2301            );
2302        }
2303    }
2304
2305    #[test]
2306    fn a_temporal_field_is_a_native_control_and_not_a_hinted_text_box() {
2307        // The regression this closes: described as text with a hint reading
2308        // "YYYY-MM-DD", which loses the picker, the platform's validation and
2309        // the touch keyboard, and asks prose to do all three.
2310        for kind in [FieldKind::Date, FieldKind::DateTime] {
2311            let html = field_html(&field(kind), &Filling::default(), &Emit::default());
2312            assert!(!html.contains(r#"type="text""#), "{kind:?} emitted {html}");
2313        }
2314    }
2315
2316    #[test]
2317    fn no_prefix_leaves_the_id_as_the_name() {
2318        let html = field_html(
2319            &field(FieldKind::Text),
2320            &Filling::default(),
2321            &Emit::default(),
2322        );
2323        assert!(html.contains(r#"id="title" name="title""#), "{html}");
2324    }
2325}