Skip to main content

makeover_webview/
form.rs

1//! Phase B, the forms half: the escaper, the field classes and the rules a
2//! described field's markup is drawn with.
3//!
4//! The field markup itself is drawn by the quasi hosts (`quasi-webview` for
5//! this renderer), which own the vocabulary member. What stays here is what
6//! every caller writing HTML needs and what the generated stylesheet has to
7//! agree with: [`escape`] and [`escape_into`], [`Markup`] and [`Filling`], the
8//! class lists, and the rules behind them.
9//!
10//! # Why one escaper is enough
11//!
12//! goingson carries four escapers and 543 call sites that must pick between
13//! them, because `escapeHtml` is built on `textContent` serialization and
14//! **`textContent` refuses to encode `"`**. That is what makes it unsound in an
15//! attribute, and it is the whole reason the choice exists. Its `escape.js`
16//! records the finding as the CHRONIC-XSS seal, and its test suite has a gate
17//! keeping the unsafe one off the namespace.
18//!
19//! [`escape`] here is not built on that, so it encodes the quote along with
20//! everything else, which makes one function sound in both sinks. The four-way
21//! choice does not move into Rust: it disappears. A caller hands an unescaped
22//! value to the output only through [`Markup`], which it has to name.
23//!
24//! # What the description does not carry
25//!
26//! One thing: the **current value**, which arrives in [`Filling`]. A webview
27//! reads it back out of the DOM, an immediate-mode renderer writes through a
28//! `&mut`, and a terminal keeps an edit buffer; a description carrying it would
29//! have to carry a way to write it back, at which point it is a form model.
30
31use crate::{Emit, class, status_edge, tone_rules};
32use makeover_layout::{Depth, Intent as _, Tone, Value};
33use std::fmt::Write as _;
34
35/// Every class a field's markup can carry.
36///
37/// [`crate::facet::FACET_CLASSES`]' obligation. Every one of these is ruled by
38/// the generated sheet, the group's four through `group_rules`; the list is
39/// written down anyway, because it is what the hosts' markup is held against
40/// and what the vocabulary test holds the sheet against.
41///
42/// What goes wrong without it: an app checking its stylesheet against
43/// [`crate::vocabulary::names`] concludes that its live `.form-group` and
44/// `.form-label` rules match nothing and are safe to delete.
45pub const FIELD_CLASSES: &[&str] = &[
46    "field",
47    "form-checkbox-label",
48    "form-checklist",
49    "form-editor-modes",
50    "form-editor-preview",
51    "form-error",
52    "form-group",
53    "form-group-inline",
54    "form-hint",
55    "form-interval",
56    "form-label",
57    "form-note",
58    "form-option-detail",
59    "form-option-reason",
60    "form-radio-group",
61    "form-radio-label",
62    "form-unit",
63];
64
65// `form-suggestions`, `form-suggestion` and `form-suggestion-detail` are
66// deliberately absent: [`suggestion_rules`] writes their look and
67// `quasi-webview` writes their markup, because a suggestion source is a route
68// and no description layer carries one. They reach the vocabulary through the
69// generated sheet, which is where a name this crate rules but does not emit
70// belongs.
71
72/// The state classes a field carries, which take no prefix.
73///
74/// `chosen` and `latched`'s convention, stated in
75/// [`crate::vocabulary::vocabulary`]: a state qualifies a prefixed component
76/// (`.mk-form-group.has-error`) rather than standing on its own, so a prefix
77/// moves the thing and not its state.
78///
79/// `has-error` marks the group and `visible` marks the message: a renderer
80/// with no descendant selectors cannot find the group from the message, so
81/// both are told.
82pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"];
83
84/// A string that is already markup, and is emitted without escaping.
85///
86/// The one hole in the escaping, and it has to be named to be used. goingson
87/// has two live callers that need it, both passing a recurrence-config block
88/// built elsewhere, and both would otherwise have their markup rendered as
89/// visible angle brackets. A caller constructing this is stating that the
90/// contents are trusted; nothing here can check that for them.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct Markup<'a>(pub &'a str);
93
94/// Everything about the field that the description does not carry.
95#[derive(Debug, Clone, Copy, Default)]
96pub struct Filling<'a> {
97    /// What the field holds now.
98    pub value: Value<'a>,
99    /// Markup appended inside the group, after the hint. Not escaped.
100    pub trailing: Option<Markup<'a>>,
101    /// Attributes written onto the control element itself. Not escaped.
102    ///
103    /// [`trailing`](Self::trailing)'s argument at attribute scale: a host knows
104    /// facts about the control that no description layer carries. quasi's
105    /// suggestion source is the first caller: a field that owns a list of
106    /// candidates is a `role="combobox"` pointing at the list it owns, and
107    /// neither half is anything the described field can say.
108    ///
109    /// Written verbatim, so a caller supplies `attr="value"` pairs with no
110    /// leading space and does its own escaping. It is [`Markup`]'s hole in the
111    /// same wall, named the same way so a caller has to state that the contents
112    /// are trusted.
113    ///
114    /// A radio group drops them, and that is deliberate rather than an
115    /// oversight: a radio group is a set of sibling inputs with no one control
116    /// element, so there is nowhere honest to put an attribute meant for the
117    /// control. The group carries the descriptions for the same reason.
118    pub control_attrs: Option<Markup<'a>>,
119    /// Scopes the `id` attributes to one instance of the form.
120    ///
121    /// The field's `name` is what the value submits under and is the same
122    /// wherever the form appears; its `id` has to be unique in the document,
123    /// and those two facts stop agreeing the moment a form appears twice.
124    /// goingson hits this directly: its new-task and edit-task modals are the
125    /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep
126    /// `label for` and `aria-describedby` pointing at the right control.
127    ///
128    /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to
129    /// `name`, which would change what the form submits.
130    pub id_prefix: Option<&'a str>,
131}
132
133impl<'a> Filling<'a> {
134    /// A filling that carries a value and nothing else.
135    #[must_use]
136    pub const fn of(value: Value<'a>) -> Self {
137        Self {
138            value,
139            trailing: None,
140            control_attrs: None,
141            id_prefix: None,
142        }
143    }
144}
145
146/// Encode the five characters that let a value stop being a value, into a
147/// buffer the caller already has.
148///
149/// The form an emitter uses. [`escape`] is this with a `String` allocated
150/// around it, and the allocation is the whole difference: a described screen
151/// escapes once per attribute and once per run of text, so a function that
152/// returns a `String` allocates a few thousand times to produce one page,
153/// where a template engine writes its escaped bytes straight into the output
154/// buffer.
155///
156/// Sound in element text and in a double-quoted attribute alike, which is the
157/// property `textContent`-based escaping cannot have. Both sinks are covered by
158/// one function so that no call site has to choose, here or downstream.
159///
160/// Copies in runs rather than per character. All five encoded characters are
161/// ASCII, so a byte scan cannot land inside a multi-byte character and the
162/// slice between two of them is always a valid `&str`. Text with nothing to
163/// encode, which is most text, is one `push_str` of the whole thing.
164pub fn escape_into(text: &str, out: &mut String) {
165    let mut start = 0;
166    for (index, byte) in text.bytes().enumerate() {
167        let encoded = match byte {
168            b'&' => "&amp;",
169            b'<' => "&lt;",
170            b'>' => "&gt;",
171            b'"' => "&quot;",
172            b'\'' => "&#39;",
173            _ => continue,
174        };
175        out.push_str(&text[start..index]);
176        out.push_str(encoded);
177        start = index + 1;
178    }
179    out.push_str(&text[start..]);
180}
181
182/// Encode the five characters that let a value stop being a value.
183///
184/// [`escape_into`] with a buffer of its own, for the callers that want a value
185/// rather than an append: a caller assembling an attribute out of several
186/// pieces, and everything outside this crate that took this function before the
187/// buffer-writing form existed. Emitting into a buffer you already hold is the
188/// cheaper path and the one the hosts' emitters take.
189#[must_use]
190pub fn escape(text: &str) -> String {
191    let mut out = String::with_capacity(text.len());
192    escape_into(text, &mut out);
193    out
194}
195
196/// The rules the markdown editor's chrome needs.
197///
198/// The two names here have no app counterpart to keep, because the chrome did
199/// not exist before the member did. The markup is a Write/Preview pair of
200/// segments and an empty preview pane, drawn by the hosts; converting markdown
201/// stays the host's, where its sanitiser already is.
202///
203/// Every rule is gated on `[data-format="markdown"]`, which is what keeps them
204/// off a plain textarea, and every rule that hides content is gated on
205/// `data-ready` as well, which is what keeps them out of a document with no
206/// script.
207pub(crate) fn editor_rules(opts: &Emit) -> String {
208    let mut css = String::new();
209    let modes = class("form-editor-modes", opts);
210    let preview = class("form-editor-preview", opts);
211    let field = class("field", opts);
212
213    // Hidden until something binds the editor: a control rendered into a
214    // document with no script looks live and answers nothing, so a reader with
215    // no script gets the textarea alone.
216    let _ = writeln!(
217        css,
218        "[data-format=\"markdown\"] > .{modes} {{\n    display: none;\n}}"
219    );
220    // Block, and nothing about how the two segments sit in it. A button is
221    // inline already, so they make a row without this crate saying so, and
222    // saying so is where a gap would follow, a magnitude, and
223    // `makeover-geometry`'s.
224    let _ = writeln!(
225        css,
226        "[data-format=\"markdown\"][data-ready] > .{modes} {{\n    display: block;\n}}"
227    );
228
229    // The pane is empty until the host fills it, so it is out of flow in every
230    // state but the one where a bound editor is showing it. An empty box under
231    // the control is chrome claiming a preview nobody rendered.
232    let _ = writeln!(
233        css,
234        "[data-format=\"markdown\"] > .{preview} {{\n    display: none;\n}}"
235    );
236    let _ = writeln!(
237        css,
238        "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
239         {{\n    display: block;\n}}"
240    );
241    // One at a time. The source and the preview are the same content read two
242    // ways, and a field showing both answers its own question twice.
243    let _ = writeln!(
244        css,
245        "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
246         {{\n    display: none;\n}}"
247    );
248
249    // The pane stands where the control stood, so it reads as the surface the
250    // control was: `.field` is a well, and this is the well it stands in for.
251    // Nothing about size, how tall a preview is is the app's, the way the
252    // height of a track is.
253    let _ = write!(
254        css,
255        "[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
256        crate::depth_declarations(Depth::Well)
257    );
258
259    css
260}
261
262/// The rules a field's group needs: its label, its hint, its message, and the
263/// arrangement of the controls that answer one question.
264///
265/// These were the apps' names from phase A, left unruled so that adoption would
266/// delete goingson's `renderFormField` rather than restyle anything. Adoption
267/// happened and the argument went with it: a described form in an app that had
268/// never written the rules drew its label as body text and its error as a plain
269/// sentence, and MNW was that app. wiki `look-restoration`: the renderer owns
270/// the look.
271///
272/// The values are the ones goingson shipped, less the group's own margin. A
273/// form spaces its groups with a gap, so a margin here would space them twice;
274/// an app laying groups out in normal flow keeps a margin of its own. The radio,
275/// checkbox, reason and interval rules came from quasi-webview's arrangement
276/// sheet unchanged, where they styled names this crate emits.
277///
278/// `visible` is the message's state rather than decoration. goingson's scripts
279/// raise and lower it on a message that stays in the document, and this crate
280/// writes it on every message it emits, so a lowered message is hidden and a
281/// written one shows.
282///
283/// `form-group-inline` is a group sitting in a run: its label goes beside the
284/// control rather than over it, so a picker on a line of chips is one line
285/// tall.
286///
287/// `has-error` draws a danger edge down the group's leading side. The control
288/// already carries the danger edge through `aria-invalid`; the group's edge is
289/// what a reader scanning a long form finds first, and it says which question
290/// failed without colouring a word of it. The label and the message keep
291/// content ink: a status colour is a fill and an edge, never the colour of
292/// words a reader has to read (wiki `quasi-vocabulary-findings`).
293pub(crate) fn group_rules(opts: &Emit) -> String {
294    let group = class("form-group", opts);
295    let label = class("form-label", opts);
296    let hint = class("form-hint", opts);
297    let error = class("form-error", opts);
298    let radios = class("form-radio-group", opts);
299    let checklist = class("form-checklist", opts);
300    let radio = class("form-radio-label", opts);
301    let checkbox = class("form-checkbox-label", opts);
302    let reason = class("form-option-reason", opts);
303    let interval = class("form-interval", opts);
304    let danger = Tone::Danger.token();
305    let mut css = String::new();
306
307    let _ = writeln!(
308        css,
309        ".{label} {{\n    display: block;\n    margin-block-end: var(--gap-bound);\n    color: var(--content);\n    font-weight: bold;\n}}"
310    );
311    let _ = writeln!(
312        css,
313        ".{group}.has-error {{\n{}}}",
314        status_edge(opts.focus_width, danger)
315    );
316    // A field in a run: the label beside the box, the way a chip run's word
317    // opens it, and in the same weight as that word. Two tracks rather than a
318    // wrapping row, because a control fills its box and would wrap under its
319    // own label; whatever explains or refuses the answer spans a line under.
320    let inline = class("form-group-inline", opts);
321    let field = class("field", opts);
322    let _ = writeln!(
323        css,
324        ".{group}.{inline} {{\n    display: grid;\n    grid-template-columns: auto minmax(0, 1fr);\n    align-items: center;\n    column-gap: var(--gap-bound);\n}}\n\
325         .{group}.{inline} > .{label} {{\n    margin-block-end: 0;\n    font-weight: inherit;\n}}\n\
326         .{group}.{inline} > :not(.{label}, .{field}) {{\n    grid-column: 1 / -1;\n}}"
327    );
328    let _ = writeln!(
329        css,
330        ".{hint} {{\n    margin-block-start: var(--gap-bound);\n    max-inline-size: var(--measure-reading, 42rem);\n    color: var(--content-secondary);\n    font-size: var(--text-note);\n}}"
331    );
332    let _ = writeln!(
333        css,
334        ".{error} {{\n    margin-block-start: var(--gap-bound);\n    color: var(--content);\n    font-weight: bold;\n}}"
335    );
336    let _ = writeln!(css, ".{error}:not(.visible) {{\n    display: none;\n}}");
337
338    // A radio group is a stack of labelled choices, and a choice may say why:
339    // the box beside its label rather than centred over it. A checklist is the
340    // same stack with a box that stays ticked.
341    let _ = writeln!(
342        css,
343        ".{radios},\n.{checklist} {{\n    display: flex;\n    flex-direction: column;\n    gap: var(--gap-peer);\n}}"
344    );
345    // Two tracks: the box, and everything it labels. A label that wraps
346    // wraps in its own track beside the box, where a wrapping flex row left the
347    // box alone on the line above its words, and the box sits on the label's
348    // first line rather than above it.
349    let _ = writeln!(
350        css,
351        ".{radio},\n.{checkbox} {{\n    display: grid;\n    grid-template-columns: auto 1fr;\n    \
352         align-items: baseline;\n    column-gap: var(--gap-bound);\n}}\n\
353         .{radio} > :not(input),\n.{checkbox} > :not(input),\n.{reason} {{\n    grid-column: 2;\n    min-width: 0;\n}}"
354    );
355    // An option's second line, and an unavailable option's reason, sit under
356    // its label in the label's track: the second track above holds every child
357    // but the box, each on a row of its own.
358    let _ = writeln!(
359        css,
360        ".{interval} {{\n    display: flex;\n    align-items: center;\n    gap: var(--gap-bound);\n}}"
361    );
362    css
363}
364
365/// The rules a field's note needs.
366///
367/// Nothing emitted a note before the described field could carry one, so
368/// there was no app rule to keep.
369///
370/// The tones are the four a badge carries, drawn as a leading edge the way a
371/// toned sentence draws one; the words keep content ink. The bare class is
372/// `content` rather than `content-muted`: a note is a consequence the user is
373/// meant to read before answering, so muting it by default would be this crate
374/// deciding it does not matter.
375pub(crate) fn note_rules(opts: &Emit) -> String {
376    let note = class("form-note", opts);
377    let mut css = String::new();
378    let _ = writeln!(css, ".{note} {{\n    color: var(--content);\n}}");
379    tone_rules(&mut css, &format!(".{note}"), |tone| {
380        status_edge(opts.focus_width, tone)
381    });
382    css
383}
384
385/// The rule a field's unit needs.
386///
387/// Nothing emitted a unit before the described field could carry one, so
388/// there was no app rule to keep.
389///
390/// One declaration, and it is the whole look. A unit is a fact about the number
391/// beside it rather than a second thing to read, so it takes the muted content
392/// intent, the same reading `.figure-caption` and `.track-tick` take, and for
393/// the same reason.
394///
395/// And where it sits, which the first version left to the flow and got wrong. A
396/// control fills its group's width, so the span after it wrapped onto a line of
397/// its own and "min" hung under the box it measures. A group carrying a unit is
398/// two columns instead: the control and its unit share the first row's line,
399/// and the label, the hint, the note and the message span both. The control's
400/// track is `auto`, so it is as wide as the control and the unit reads straight
401/// after the number; the spare room goes to the unit's track, where it is
402/// empty. The other way round put "min" at the far edge of the pane.
403pub(crate) fn unit_rules(opts: &Emit) -> String {
404    let unit = class("form-unit", opts);
405    let group = class("form-group", opts);
406    let mut css = String::new();
407    let _ = writeln!(css, ".{unit} {{\n    color: var(--content-muted);\n}}");
408    let _ = writeln!(
409        css,
410        ".{group}:has(> .{unit}) {{\n    display: grid;\n    grid-template-columns: auto minmax(0, 1fr);\n    column-gap: var(--gap-bound);\n    align-items: center;\n}}"
411    );
412    let _ = writeln!(
413        css,
414        ".{group}:has(> .{unit}) > :not(.{unit}, input, select, textarea) {{\n    grid-column: 1 / -1;\n}}"
415    );
416    css
417}
418
419/// The rules an option's second line needs.
420///
421/// [`unit_rules`]' argument: rule what has no app counterpart to keep. An
422/// unruled second line renders identically to the label it sits under, which is
423/// a worse default than the hand-written markup it replaces.
424///
425/// Colour only, and muted, which is the same reading `.form-unit` and
426/// `.form-suggestion-detail` take: the line orients the label rather than
427/// competing with it. Nothing about placement or spacing, for `unit_rules`'
428/// reason, a magnitude asserted here belongs to `makeover-geometry`.
429pub(crate) fn option_detail_rules(opts: &Emit) -> String {
430    let detail = class("form-option-detail", opts);
431    let mut css = String::new();
432    let _ = writeln!(css, ".{detail} {{\n    color: var(--content-muted);\n}}");
433    css
434}
435
436/// The rules a field's suggestion list needs.
437///
438/// [`editor_rules`]' precedent and its argument: these three have no app
439/// counterpart to keep because the list did not exist before the member did.
440/// The markup is `quasi-webview`'s, a suggestion source is a route, which no
441/// description layer carries, and the look is
442/// still this crate's, because a renderer inventing how a list of candidates
443/// reads is the drift the vocabulary check exists to catch.
444///
445/// # In flow, and not floating
446///
447/// An absolutely positioned list needs a positioned ancestor, and the only
448/// candidate is `.form-group`, which is the app's class and deliberately
449/// unruled here. So the list stands under the control and moves what is below
450/// it. An app that wants it over the form positions the group itself, which is
451/// one declaration and is the app's call about its own layout.
452///
453/// `:empty` is what takes it away, so a route that answers with no candidates
454/// leaves no box behind. It is a content question rather than a whitespace one
455/// only because the emitter writes no whitespace inside the container, which is
456/// stated in `quasi-webview`'s own test.
457///
458/// # Placement, and nothing about size
459///
460/// One candidate to a line, its detail at the line's end. Without it the
461/// entries are bare anchors and run together as one paragraph: MNW's discover
462/// search drew "Blue HourProjectKickItem". Spacing comes from the geometry
463/// tokens every other form rule uses, so no magnitude is asserted here.
464///
465/// No height and no scroll ceiling. How tall a list of candidates gets to be
466/// before it scrolls is a magnitude, and magnitudes are `makeover-geometry`'s,
467/// exactly as the preview pane's height is.
468pub(crate) fn suggestion_rules(opts: &Emit) -> String {
469    let list = class("form-suggestions", opts);
470    let entry = class("form-suggestion", opts);
471    let detail = class("form-suggestion-detail", opts);
472    let mut css = String::new();
473
474    let _ = writeln!(css, ".{list}:empty {{\n    display: none;\n}}");
475    let _ = writeln!(
476        css,
477        ".{list} {{\n    display: flex;\n    flex-direction: column;\n}}"
478    );
479    let _ = writeln!(
480        css,
481        ".{entry} {{\n    display: flex;\n    align-items: baseline;\n    justify-content: space-between;\n    gap: var(--gap-group);\n    padding: var(--gap-bound) var(--gap-peer);\n    color: inherit;\n    text-decoration: none;\n}}"
482    );
483    // Over what it covers, which is what a list of candidates is even in flow:
484    // it is answering the box above it and goes away when the answer is taken.
485    css.push_str(&crate::depth_rule(&list, Depth::Overlay));
486    // An entry answers a click, so it gets every state one implies.
487    css.push_str(&crate::interactive_rules(&entry, Depth::Flat, opts));
488    // The keyboard's highlight and the pointer's are the same surface. They are
489    // the same fact told two ways, and a list where arrowing and hovering look
490    // different is a list that has two current entries.
491    //
492    // Keyed on `aria-selected` rather than on a class, for the reason
493    // `aria-invalid` carries the error state: it is what a screen reader hears,
494    // so a look keyed on it cannot drift from what is announced. A `.current`
495    // class would also be a name apps already spell for their own reasons:
496    // the MNW server has one, and unlayered app CSS beats this layer in
497    // silence.
498    let _ = writeln!(
499        css,
500        ".{entry}[aria-selected=\"true\"] {{\n    background: var(--hover-surface);\n}}"
501    );
502    // The second line, muted rather than disabled. A candidate carries no
503    // `unavailable`, and what sits beside the label now is what tells one row
504    // from another that reads the same. Disabled would say the row cannot be
505    // picked, which is the opposite of what the detail is for.
506    let _ = writeln!(css, ".{detail} {{\n    color: var(--content-muted);\n}}");
507
508    css
509}
510
511#[cfg(test)]
512mod tests;