makeover-webview 0.114.0

The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser. With the build feature, writes a consumer's generated frontend files and checks the hand-written ones against them.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
//! Phase B, the forms half: the escaper, the field classes and the rules a
//! described field's markup is drawn with.
//!
//! The field markup itself is drawn by the quasi hosts (`quasi-webview` for
//! this renderer), which own the vocabulary member. What stays here is what
//! every caller writing HTML needs and what the generated stylesheet has to
//! agree with: [`escape`] and [`escape_into`], [`Markup`] and [`Filling`], the
//! class lists, and the rules behind them.
//!
//! # Why one escaper is enough
//!
//! goingson carries four escapers and 543 call sites that must pick between
//! them, because `escapeHtml` is built on `textContent` serialization and
//! **`textContent` refuses to encode `"`**. That is what makes it unsound in an
//! attribute, and it is the whole reason the choice exists. Its `escape.js`
//! records the finding as the CHRONIC-XSS seal, and its test suite has a gate
//! keeping the unsafe one off the namespace.
//!
//! [`escape`] here is not built on that, so it encodes the quote along with
//! everything else, which makes one function sound in both sinks. The four-way
//! choice does not move into Rust: it disappears. A caller hands an unescaped
//! value to the output only through [`Markup`], which it has to name.
//!
//! # What the description does not carry
//!
//! One thing: the **current value**, which arrives in [`Filling`]. A webview
//! reads it back out of the DOM, an immediate-mode renderer writes through a
//! `&mut`, and a terminal keeps an edit buffer; a description carrying it would
//! have to carry a way to write it back, at which point it is a form model.

use crate::{Emit, class};
use makeover_layout::{Depth, Intent as _, Tone, Value};
use std::fmt::Write as _;

/// Every class a field's markup can carry.
///
/// [`crate::facet::FACET_CLASSES`]' obligation. Every one of these is ruled by
/// the generated sheet, the group's four through `group_rules`; the list is
/// written down anyway, because it is what the hosts' markup is held against
/// and what the vocabulary test holds the sheet against.
///
/// What goes wrong without it: an app checking its stylesheet against
/// [`crate::vocabulary::names`] concludes that its live `.form-group` and
/// `.form-label` rules match nothing and are safe to delete.
pub const FIELD_CLASSES: &[&str] = &[
    "field",
    "form-checkbox-label",
    "form-checklist",
    "form-editor-modes",
    "form-editor-preview",
    "form-error",
    "form-group",
    "form-hint",
    "form-interval",
    "form-label",
    "form-note",
    "form-option-detail",
    "form-option-reason",
    "form-radio-group",
    "form-radio-label",
    "form-unit",
];

// `form-suggestions`, `form-suggestion` and `form-suggestion-detail` are
// deliberately absent: [`suggestion_rules`] writes their look and
// `quasi-webview` writes their markup, because a suggestion source is a route
// and no description layer carries one. They reach the vocabulary through the
// generated sheet, which is where a name this crate rules but does not emit
// belongs.

/// The state classes a field carries, which take no prefix.
///
/// `chosen` and `latched`'s convention, stated in
/// [`crate::vocabulary::vocabulary`]: a state qualifies a prefixed component
/// (`.mk-form-group.has-error`) rather than standing on its own, so a prefix
/// moves the thing and not its state.
///
/// `has-error` marks the group and `visible` marks the message: a renderer
/// with no descendant selectors cannot find the group from the message, so
/// both are told.
pub const FIELD_STATE_CLASSES: &[&str] = &["has-error", "visible"];

/// A string that is already markup, and is emitted without escaping.
///
/// The one hole in the escaping, and it has to be named to be used. goingson
/// has two live callers that need it, both passing a recurrence-config block
/// built elsewhere, and both would otherwise have their markup rendered as
/// visible angle brackets. A caller constructing this is stating that the
/// contents are trusted; nothing here can check that for them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Markup<'a>(pub &'a str);

/// Everything about the field that the description does not carry.
#[derive(Debug, Clone, Copy, Default)]
pub struct Filling<'a> {
    /// What the field holds now.
    pub value: Value<'a>,
    /// Markup appended inside the group, after the hint. Not escaped.
    pub trailing: Option<Markup<'a>>,
    /// Attributes written onto the control element itself. Not escaped.
    ///
    /// [`trailing`](Self::trailing)'s argument at attribute scale: a host knows
    /// facts about the control that no description layer carries. quasi's
    /// suggestion source is the first caller: a field that owns a list of
    /// candidates is a `role="combobox"` pointing at the list it owns, and
    /// neither half is anything the described field can say.
    ///
    /// Written verbatim, so a caller supplies `attr="value"` pairs with no
    /// leading space and does its own escaping. It is [`Markup`]'s hole in the
    /// same wall, named the same way so a caller has to state that the contents
    /// are trusted.
    ///
    /// A radio group drops them, and that is deliberate rather than an
    /// oversight: a radio group is a set of sibling inputs with no one control
    /// element, so there is nowhere honest to put an attribute meant for the
    /// control. The group carries the descriptions for the same reason.
    pub control_attrs: Option<Markup<'a>>,
    /// Scopes the `id` attributes to one instance of the form.
    ///
    /// The field's `name` is what the value submits under and is the same
    /// wherever the form appears; its `id` has to be unique in the document,
    /// and those two facts stop agreeing the moment a form appears twice.
    /// goingson hits this directly: its new-task and edit-task modals are the
    /// same field set, so it prefixes `form-modal-task-new` or `-edit` to keep
    /// `label for` and `aria-describedby` pointing at the right control.
    ///
    /// Applies to `id`, `for` and the `-hint` / `-error` associations. Never to
    /// `name`, which would change what the form submits.
    pub id_prefix: Option<&'a str>,
}

impl<'a> Filling<'a> {
    /// A filling that carries a value and nothing else.
    #[must_use]
    pub const fn of(value: Value<'a>) -> Self {
        Self {
            value,
            trailing: None,
            control_attrs: None,
            id_prefix: None,
        }
    }
}

/// Encode the five characters that let a value stop being a value, into a
/// buffer the caller already has.
///
/// The form an emitter uses. [`escape`] is this with a `String` allocated
/// around it, and the allocation is the whole difference: a described screen
/// escapes once per attribute and once per run of text, so a function that
/// returns a `String` allocates a few thousand times to produce one page,
/// where a template engine writes its escaped bytes straight into the output
/// buffer.
///
/// Sound in element text and in a double-quoted attribute alike, which is the
/// property `textContent`-based escaping cannot have. Both sinks are covered by
/// one function so that no call site has to choose, here or downstream.
///
/// Copies in runs rather than per character. All five encoded characters are
/// ASCII, so a byte scan cannot land inside a multi-byte character and the
/// slice between two of them is always a valid `&str`. Text with nothing to
/// encode, which is most text, is one `push_str` of the whole thing.
pub fn escape_into(text: &str, out: &mut String) {
    let mut start = 0;
    for (index, byte) in text.bytes().enumerate() {
        let encoded = match byte {
            b'&' => "&amp;",
            b'<' => "&lt;",
            b'>' => "&gt;",
            b'"' => "&quot;",
            b'\'' => "&#39;",
            _ => continue,
        };
        out.push_str(&text[start..index]);
        out.push_str(encoded);
        start = index + 1;
    }
    out.push_str(&text[start..]);
}

/// Encode the five characters that let a value stop being a value.
///
/// [`escape_into`] with a buffer of its own, for the callers that want a value
/// rather than an append: a caller assembling an attribute out of several
/// pieces, and everything outside this crate that took this function before the
/// buffer-writing form existed. Emitting into a buffer you already hold is the
/// cheaper path and the one the hosts' emitters take.
#[must_use]
pub fn escape(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    escape_into(text, &mut out);
    out
}

/// The rules the markdown editor's chrome needs.
///
/// The two names here have no app counterpart to keep, because the chrome did
/// not exist before the member did. The markup is a Write/Preview pair of
/// segments and an empty preview pane, drawn by the hosts; converting markdown
/// stays the host's, where its sanitiser already is.
///
/// Every rule is gated on `[data-format="markdown"]`, which is what keeps them
/// off a plain textarea, and every rule that hides content is gated on
/// `data-ready` as well, which is what keeps them out of a document with no
/// script.
pub(crate) fn editor_rules(opts: &Emit) -> String {
    let mut css = String::new();
    let modes = class("form-editor-modes", opts);
    let preview = class("form-editor-preview", opts);
    let field = class("field", opts);

    // Hidden until something binds the editor: a control rendered into a
    // document with no script looks live and answers nothing, so a reader with
    // no script gets the textarea alone.
    let _ = writeln!(
        css,
        "[data-format=\"markdown\"] > .{modes} {{\n    display: none;\n}}"
    );
    // Block, and nothing about how the two segments sit in it. A button is
    // inline already, so they make a row without this crate saying so, and
    // saying so is where a gap would follow, a magnitude, and
    // `makeover-geometry`'s.
    let _ = writeln!(
        css,
        "[data-format=\"markdown\"][data-ready] > .{modes} {{\n    display: block;\n}}"
    );

    // The pane is empty until the host fills it, so it is out of flow in every
    // state but the one where a bound editor is showing it. An empty box under
    // the control is chrome claiming a preview nobody rendered.
    let _ = writeln!(
        css,
        "[data-format=\"markdown\"] > .{preview} {{\n    display: none;\n}}"
    );
    let _ = writeln!(
        css,
        "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
         {{\n    display: block;\n}}"
    );
    // One at a time. The source and the preview are the same content read two
    // ways, and a field showing both answers its own question twice.
    let _ = writeln!(
        css,
        "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
         {{\n    display: none;\n}}"
    );

    // The pane stands where the control stood, so it reads as the surface the
    // control was: `.field` is a well, and this is the well it stands in for.
    // Nothing about size, how tall a preview is is the app's, the way the
    // height of a track is.
    let _ = write!(
        css,
        "[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
        crate::depth_declarations(Depth::Well)
    );

    css
}

/// The rules a field's group needs: its label, its hint, its message, and the
/// arrangement of the controls that answer one question.
///
/// These were the apps' names from phase A, left unruled so that adoption would
/// delete goingson's `renderFormField` rather than restyle anything. Adoption
/// happened and the argument went with it: a described form in an app that had
/// never written the rules drew its label as body text and its error as a plain
/// sentence, and MNW was that app. wiki `look-restoration`: the renderer owns
/// the look.
///
/// The values are the ones goingson shipped, less the group's own margin. A
/// form spaces its groups with a gap, so a margin here would space them twice;
/// an app laying groups out in normal flow keeps a margin of its own. The radio,
/// checkbox, reason and interval rules came from quasi-webview's arrangement
/// sheet unchanged, where they styled names this crate emits.
///
/// `visible` is the message's state rather than decoration. goingson's scripts
/// raise and lower it on a message that stays in the document, and this crate
/// writes it on every message it emits, so a lowered message is hidden and a
/// written one shows.
///
/// `has-error` draws a danger edge down the group's leading side. The control
/// already carries the danger edge through `aria-invalid`; the group's edge is
/// what a reader scanning a long form finds first, and it says which question
/// failed without colouring a word of it. The label and the message keep
/// content ink: a status colour is a fill and an edge, never the colour of
/// words a reader has to read (wiki `quasi-vocabulary-findings`).
pub(crate) fn group_rules(opts: &Emit) -> String {
    let group = class("form-group", opts);
    let label = class("form-label", opts);
    let hint = class("form-hint", opts);
    let error = class("form-error", opts);
    let radios = class("form-radio-group", opts);
    let checklist = class("form-checklist", opts);
    let radio = class("form-radio-label", opts);
    let checkbox = class("form-checkbox-label", opts);
    let reason = class("form-option-reason", opts);
    let interval = class("form-interval", opts);
    let danger = Tone::Danger.token();
    let mut css = String::new();

    let _ = writeln!(
        css,
        ".{label} {{\n    display: block;\n    margin-block-end: var(--gap-bound);\n    color: var(--content);\n    font-weight: bold;\n}}"
    );
    let _ = writeln!(
        css,
        ".{group}.has-error {{\n    border-inline-start: {} solid var(--{danger});\n    \
         padding-inline-start: var(--step-snug);\n}}",
        opts.focus_width
    );
    let _ = writeln!(
        css,
        ".{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}}"
    );
    let _ = writeln!(
        css,
        ".{error} {{\n    margin-block-start: var(--gap-bound);\n    color: var(--content);\n    font-weight: bold;\n}}"
    );
    let _ = writeln!(css, ".{error}:not(.visible) {{\n    display: none;\n}}");

    // A radio group is a stack of labelled choices, and a choice may say why:
    // the box beside its label rather than centred over it. A checklist is the
    // same stack with a box that stays ticked.
    let _ = writeln!(
        css,
        ".{radios},\n.{checklist} {{\n    display: flex;\n    flex-direction: column;\n    gap: var(--gap-peer);\n}}"
    );
    // Two tracks: the box, and everything it labels. A label that wraps
    // wraps in its own track beside the box, where a wrapping flex row left the
    // box alone on the line above its words, and the box sits on the label's
    // first line rather than above it.
    let _ = writeln!(
        css,
        ".{radio},\n.{checkbox} {{\n    display: grid;\n    grid-template-columns: auto 1fr;\n    \
         align-items: baseline;\n    column-gap: var(--gap-bound);\n}}\n\
         .{radio} > :not(input),\n.{checkbox} > :not(input),\n.{reason} {{\n    grid-column: 2;\n    min-width: 0;\n}}"
    );
    // An option's second line, and an unavailable option's reason, sit under
    // its label in the label's track: the second track above holds every child
    // but the box, each on a row of its own.
    let _ = writeln!(
        css,
        ".{interval} {{\n    display: flex;\n    align-items: center;\n    gap: var(--gap-bound);\n}}"
    );
    css
}

/// The rules a field's note needs.
///
/// Nothing emitted a note before the described field could carry one, so
/// there was no app rule to keep.
///
/// The tones are the four a badge carries, drawn as a leading edge the way a
/// toned sentence draws one; the words keep content ink. The bare class is
/// `content` rather than `content-muted`: a note is a consequence the user is
/// meant to read before answering, so muting it by default would be this crate
/// deciding it does not matter.
pub(crate) fn note_rules(opts: &Emit) -> String {
    let note = class("form-note", opts);
    let mut css = String::new();
    let _ = writeln!(css, ".{note} {{\n    color: var(--content);\n}}");
    for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
        let _ = writeln!(
            css,
            ".{note}[data-tone=\"{0}\"] {{\n    border-inline-start: {1} solid var(--{0});\n    \
             padding-inline-start: var(--step-snug);\n}}",
            tone.token(),
            opts.focus_width
        );
    }
    css
}

/// The rule a field's unit needs.
///
/// Nothing emitted a unit before the described field could carry one, so
/// there was no app rule to keep.
///
/// One declaration, and it is the whole look. A unit is a fact about the number
/// beside it rather than a second thing to read, so it takes the muted content
/// intent, the same reading `.figure-caption` and `.track-tick` take, and for
/// the same reason.
///
/// And where it sits, which the first version left to the flow and got wrong. A
/// control fills its group's width, so the span after it wrapped onto a line of
/// its own and "min" hung under the box it measures. A group carrying a unit is
/// two columns instead: the control and its unit share the first row's line,
/// and the label, the hint, the note and the message span both. The control's
/// track is `auto`, so it is as wide as the control and the unit reads straight
/// after the number; the spare room goes to the unit's track, where it is
/// empty. The other way round put "min" at the far edge of the pane.
pub(crate) fn unit_rules(opts: &Emit) -> String {
    let unit = class("form-unit", opts);
    let group = class("form-group", opts);
    let mut css = String::new();
    let _ = writeln!(css, ".{unit} {{\n    color: var(--content-muted);\n}}");
    let _ = writeln!(
        css,
        ".{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}}"
    );
    let _ = writeln!(
        css,
        ".{group}:has(> .{unit}) > :not(.{unit}, input, select, textarea) {{\n    grid-column: 1 / -1;\n}}"
    );
    css
}

/// The rules an option's second line needs.
///
/// [`unit_rules`]' argument: rule what has no app counterpart to keep. An
/// unruled second line renders identically to the label it sits under, which is
/// a worse default than the hand-written markup it replaces.
///
/// Colour only, and muted, which is the same reading `.form-unit` and
/// `.form-suggestion-detail` take: the line orients the label rather than
/// competing with it. Nothing about placement or spacing, for `unit_rules`'
/// reason, a magnitude asserted here belongs to `makeover-geometry`.
pub(crate) fn option_detail_rules(opts: &Emit) -> String {
    let detail = class("form-option-detail", opts);
    let mut css = String::new();
    let _ = writeln!(css, ".{detail} {{\n    color: var(--content-muted);\n}}");
    css
}

/// The rules a field's suggestion list needs.
///
/// [`editor_rules`]' precedent and its argument: these three have no app
/// counterpart to keep because the list did not exist before the member did.
/// The markup is `quasi-webview`'s, a suggestion source is a route, which no
/// description layer carries, and the look is
/// still this crate's, because a renderer inventing how a list of candidates
/// reads is the drift the vocabulary check exists to catch.
///
/// # In flow, and not floating
///
/// An absolutely positioned list needs a positioned ancestor, and the only
/// candidate is `.form-group`, which is the app's class and deliberately
/// unruled here. So the list stands under the control and moves what is below
/// it. An app that wants it over the form positions the group itself, which is
/// one declaration and is the app's call about its own layout.
///
/// `:empty` is what takes it away, so a route that answers with no candidates
/// leaves no box behind. It is a content question rather than a whitespace one
/// only because the emitter writes no whitespace inside the container, which is
/// stated in `quasi-webview`'s own test.
///
/// # Placement, and nothing about size
///
/// One candidate to a line, its detail at the line's end. Without it the
/// entries are bare anchors and run together as one paragraph: MNW's discover
/// search drew "Blue HourProjectKickItem". Spacing comes from the geometry
/// tokens every other form rule uses, so no magnitude is asserted here.
///
/// No height and no scroll ceiling. How tall a list of candidates gets to be
/// before it scrolls is a magnitude, and magnitudes are `makeover-geometry`'s,
/// exactly as the preview pane's height is.
pub(crate) fn suggestion_rules(opts: &Emit) -> String {
    let list = class("form-suggestions", opts);
    let entry = class("form-suggestion", opts);
    let detail = class("form-suggestion-detail", opts);
    let mut css = String::new();

    let _ = writeln!(css, ".{list}:empty {{\n    display: none;\n}}");
    let _ = writeln!(
        css,
        ".{list} {{\n    display: flex;\n    flex-direction: column;\n}}"
    );
    let _ = writeln!(
        css,
        ".{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}}"
    );
    // Over what it covers, which is what a list of candidates is even in flow:
    // it is answering the box above it and goes away when the answer is taken.
    css.push_str(&crate::depth_rule(&list, Depth::Overlay));
    // An entry answers a click, so it gets every state one implies.
    css.push_str(&crate::interactive_rules(&entry, Depth::Flat, opts));
    // The keyboard's highlight and the pointer's are the same surface. They are
    // the same fact told two ways, and a list where arrowing and hovering look
    // different is a list that has two current entries.
    //
    // Keyed on `aria-selected` rather than on a class, for the reason
    // `aria-invalid` carries the error state: it is what a screen reader hears,
    // so a look keyed on it cannot drift from what is announced. A `.current`
    // class would also be a name apps already spell for their own reasons:
    // the MNW server has one, and unlayered app CSS beats this layer in
    // silence.
    let _ = writeln!(
        css,
        ".{entry}[aria-selected=\"true\"] {{\n    background: var(--hover-surface);\n}}"
    );
    // The second line, muted rather than disabled. A candidate carries no
    // `unavailable`, and what sits beside the label now is what tells one row
    // from another that reads the same. Disabled would say the row cannot be
    // picked, which is the opposite of what the detail is for.
    let _ = writeln!(css, ".{detail} {{\n    color: var(--content-muted);\n}}");

    css
}

#[cfg(test)]
mod tests;