makeover-webview 0.74.2

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.
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
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
//! Phase B, the forms half: [`makeover_layout::Field`] rendered to HTML.
//!
//! # Why this emits strings
//!
//! Both webview apps build their markup as strings and hand it to `innerHTML`:
//! goingson's `renderFormField` returns a template literal that fifteen call
//! sites interpolate into larger literals, and Balanced Breakfast's builds
//! nodes but appends them into the same string-built forms. Returning nodes
//! would rewrite the surrounding templates as well, which makes it a migration
//! rather than an adoption. So: strings, and the escaping comes with them.
//!
//! # Why one escaper is enough here
//!
//! 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. Nothing in this module hands
//! an unescaped value to the output except through [`Markup`], which a caller
//! has to name.
//!
//! # What the description does not carry
//!
//! One thing: the **current value**, which arrives in [`Filling`]. The
//! placeholder and a select's options are not renderer state: the first is
//! user-facing text sitting with `label` and `hint`, and the second is needed by
//! every renderer, so both are read off [`Field`].
//!
//! The value stays, and it is not a leftover. 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, push_class};
use makeover_layout::{Choice, Depth, Field, FieldKind, Intent as _, Selector, ThemeVariant, Tone};
use std::fmt::Write as _;

/// Every class this module can put in markup.
///
/// [`crate::facet::FACET_CLASSES`]' obligation, and the module where it was
/// missing longest. Most of these carry no rule and never will: `.form-group`,
/// `.form-label`, `.form-hint` and `.form-error` are the apps' own names, kept
/// so adoption deletes goingson's `renderFormField` rather than restyling
/// anything, and phase A emits only what it can generate from the description.
/// A class with no rule is invisible to [`crate::vocabulary::vocabulary`],
/// which reads the generated sheet, so the unruled half of a renderer's
/// vocabulary can only be written down.
///
/// 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-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, which is
/// [`makeover_layout::Field::invalid`]'s own reasoning: 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);

/// What the field currently holds.
///
/// An enum rather than a bag of optional fields, on the same reasoning
/// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable
/// here, where a struct would let it be said and then have to cope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Value<'a> {
    /// Nothing yet.
    #[default]
    Absent,
    /// The value of anything that takes typed text, a select included: what a
    /// select holds is the `value` of one of [`Field::options`]'s
    /// [`Choice`]s.
    ///
    /// The options are the field's and never this type's, which is what keeps
    /// a `Chosen { options, value }` variant from existing.
    /// `makeover-immediate` carries the same single-variant shape.
    Text(&'a str),
    /// A checkbox, on or off.
    On(bool),
    /// Both ends of a [`FieldKind::Interval`], lower first.
    ///
    /// Two values rather than one string with a separator, for
    /// [`makeover_layout::Field::upper_name`]'s reason one level down: an
    /// interval submits under two names, so it comes back as two values, and a
    /// delimiter this crate owned could appear inside either of them.
    ///
    /// Either end may be empty while the other stands. "Over 120 BPM" is a
    /// lower end and no upper one, and it is an answer rather than a
    /// half-filled form.
    Between {
        /// What the lower box holds now.
        lower: &'a str,
        /// What the upper box holds now.
        upper: &'a str,
    },
}

impl<'a> Value<'a> {
    /// The value as text, for the kinds that submit one.
    const fn as_text(&self) -> &'a str {
        match self {
            Self::Text(text) | Self::Between { lower: text, .. } => text,
            Self::Absent | Self::On(_) => "",
        }
    }
}

impl<'a> Value<'a> {
    /// The upper end, for the one variant that has one.
    const fn upper_text(&self) -> &'a str {
        match self {
            Self::Between { upper, .. } => upper,
            Self::Absent | Self::Text(_) | Self::On(_) => "",
        }
    }
}

/// 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, and until
    /// this existed the only way to attach one was to stop calling this emitter
    /// and write a second one. 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
    /// [`makeover_layout::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 [`FieldKind::Radio`] 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,
        }
    }

    /// The document-unique id for a field of this name.
    fn id_for(&self, name: &str) -> String {
        let mut id = String::new();
        if let Some(prefix) = self.id_prefix {
            escape_into(prefix, &mut id);
            id.push('-');
        }
        escape_into(name, &mut id);
        id
    }
}

/// Encode the five characters that let a value stop being a value, into a
/// buffer the caller already has.
///
/// The form the emitters use. [`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 this crate's own 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 `type` an input takes for a kind.
///
/// [`FieldKind::Secret`] is `password`, which both apps already map by hand.
const fn input_type(kind: FieldKind) -> &'static str {
    match kind {
        FieldKind::Secret => "password",
        FieldKind::Number => "number",
        FieldKind::Checkbox => "checkbox",
        FieldKind::File => "file",
        FieldKind::Hidden => "hidden",
        // Not decoration. Each of these changes the keyboard a touch device
        // offers and turns on the platform's own validation, which is why the
        // description names them apart from text rather than letting the app
        // pass an HTML type through.
        FieldKind::Email => "email",
        FieldKind::Url => "url",
        FieldKind::Tel => "tel",
        // The same argument, and it buys more here than anywhere else in this
        // list: a native picker as well as the keyboard and the validation.
        // Both submit the format `makeover-layout` names, `DATE_FORMAT` and
        // `DATETIME_FORMAT`, so honouring it costs this renderer nothing.
        FieldKind::Date => "date",
        FieldKind::DateTime => "datetime-local",
        FieldKind::Radio => "radio",
        // The clearest case in this list that a kind is not decoration: a
        // number and a range submit the same value and are different controls,
        // and the browser is the one drawing the difference.
        FieldKind::Range => "range",
        // Select and Textarea are not inputs at all; they never reach here.
        // Radio is one, but it is emitted once per option by `radio_html` and
        // so does not reach here either.
        FieldKind::Text | FieldKind::Select | FieldKind::Textarea | FieldKind::Rich => "text",
        // A kind added to the description since this renderer was built. Text
        // accepts any value the others would, so it degrades rather than
        // dropping the field.
        _ => "text",
    }
}

/// The attributes every visible control carries, error state included.
///
/// `aria-invalid` is the whole reason the error state is readable at all: the
/// generated stylesheet keys the danger ring on `[aria-invalid="true"]` rather
/// than on a class, so a control rendered already-invalid without it is styled
/// as if nothing were wrong. goingson's runtime validation path sets the
/// attribute and its initial render does not, which is exactly the drift one
/// emitter removes.
/// `id` and `name` arrive separately because they are not the same fact. The
/// name is what submits and is fixed by the description; the id has to be
/// unique in the document and so carries [`Filling::id_prefix`] when a form
/// appears more than once.
/// The `accept` attribute, from the description's accept list.
///
/// The list is comma-joined because that is the
/// attribute's own format, and each entry writes itself: a family is its
/// wildcard media type, a media type is itself, a suffix is itself with its
/// leading dot. Nothing is normalised on the way through -- `.tar.gz` is two
/// dots and the browser is fine with it.
///
/// An empty list emits no attribute at all, which is the browser's own "any
/// file" and is what the description means by listing nothing. Emitting
/// `accept=""` instead would be a filter that matches nothing on some browsers
/// and everything on others.
///
/// It is a filter and not a guarantee, on the browser's side as much as here:
/// the picker keeps an "All Files" escape and the user may take it. Whoever
/// validated still validates.
fn push_accept(out: &mut String, field: &Field<'_>) {
    if field.accept.is_empty() {
        return;
    }
    out.push_str(" accept=\"");
    for (index, one) in field.accept.iter().enumerate() {
        if index > 0 {
            out.push(',');
        }
        escape_into(one.as_str(), out);
    }
    out.push('"');
}

/// The extent and the granularity, as the browser spells them.
///
/// Its own function because an interval writes them onto both of its ends: they
/// describe the axis rather than either end of it, which is what
/// [`FieldKind::Interval`] says and what the six audiofiles filter axes are.
fn push_bounds(out: &mut String, field: &Field<'_>) {
    if let Some(min) = field.min {
        out.push_str(" min=\"");
        escape_into(min, out);
        out.push('"');
    }
    if let Some(max) = field.max {
        out.push_str(" max=\"");
        escape_into(max, out);
        out.push('"');
    }
    // The browser's own default is `step="1"`, which turns a 0-to-1 threshold
    // into a two-position control. That is the granularity the description
    // means when it says nothing, so this is emitted only when an app has said
    // otherwise rather than defaulted here.
    //
    // A range takes its granularity from its curve as of makeover-layout
    // 0.32.0, and every other kind keeps `Field::step`. See the crate header on
    // what this renderer can and cannot do with a curve.
    let step = if field.kind == FieldKind::Range {
        field.curve.step()
    } else {
        field.step
    };
    if let Some(step) = step {
        out.push_str(" step=\"");
        escape_into(step, out);
        out.push('"');
    }
}

fn push_control_attributes(
    out: &mut String,
    field: &Field<'_>,
    filling: &Filling<'_>,
    id: &str,
    name: &str,
) {
    let _ = write!(out, " id=\"{id}\" name=\"");
    escape_into(name, out);
    out.push('"');
    if field.required {
        out.push_str(" required");
    }
    // makeover-layout 0.11.0's constraints. The description carries the rule and
    // this emits the browser's idiom for it, which is the model `required` has
    // been using since before the crate wrote down that it carried none.
    // Enforcement is still whoever validated's, and arrives back as `error`.
    if let Some(limit) = field.max_length {
        let _ = write!(out, " maxlength=\"{limit}\"");
    }
    push_bounds(out, field);
    // The description asks for the wall-clock value to be submitted as the
    // moment it names, and in a browser that conversion is script's: `<input
    // type="datetime-local">` submits what the user typed and nothing in HTML
    // turns it into an instant. So this emits the mark and quasi-webview's
    // `instant.js` does the converting -- the same division as `data-clock`,
    // where the markup says what to do and the shipped script is what a browser
    // knows that a description cannot.
    //
    // Only DateTime. A date and a time are each half a moment and cannot name
    // one on their own, so the flag is ignored there rather than emitting a
    // mark nothing can honour.
    if field.as_instant && matches!(field.kind, FieldKind::DateTime) {
        out.push_str(" data-instant=\"true\"");
    }
    if field.invalid() {
        out.push_str(" aria-invalid=\"true\"");
    }

    push_described_by(out, field, id);

    // Last, so that a host attaching a fact of its own can see everything this
    // emitter decided and cannot be overwritten by it. Duplicate attributes are
    // the caller's to avoid: HTML takes the first of a repeated pair, so an
    // attribute spelled here as well as there keeps this crate's answer.
    if let Some(Markup(attrs)) = filling.control_attrs {
        out.push(' ');
        out.push_str(attrs);
    }
}

/// The `aria-describedby` naming whatever of the hint and the error exist.
///
/// Both associations, in the order they are useful: the standing help, then
/// what is currently wrong. goingson's runtime path points describedby at the
/// error alone and drops the hint association it never made in the first place;
/// naming both here means the hint survives an error appearing.
///
/// Its own function because a radio group carries it on the group rather than
/// on a control, and one reading of "what describes this field" is the point.
fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
    let unit = unit_of(field).is_some();
    if field.hint.is_none() && field.error.is_none() && field.note.is_none() && !unit {
        return;
    }
    let mut written = false;
    out.push_str(" aria-describedby=\"");
    if field.hint.is_some() {
        let _ = write!(out, "{id}-hint");
        written = true;
    }
    // The unit before the error and after the hint, which is the order they are
    // useful in: what the number is measured in is standing context like the
    // hint, and what is wrong with it now comes last.
    if unit {
        if written {
            out.push(' ');
        }
        let _ = write!(out, "{id}-unit");
        written = true;
    }
    // The note after the unit and before the error, matching the order the
    // three are drawn in and the order they are useful in: what the answer
    // costs is context, and what is wrong with it now still comes last.
    if field.note.is_some() {
        if written {
            out.push(' ');
        }
        let _ = write!(out, "{id}-note");
        written = true;
    }
    if field.error.is_some() {
        if written {
            out.push(' ');
        }
        let _ = write!(out, "{id}-error");
    }
    out.push('"');
}

/// The unit to draw beside this field's value, if there is one to draw.
///
/// Two conditions rather than one: the field has to carry a unit and its kind
/// has to be one that means anything by it. `FieldKind::measurable` is the
/// description answering the second, so this renderer keeps no list of its own
/// of which kinds are quantities.
fn unit_of<'a>(field: &Field<'a>) -> Option<&'a str> {
    field.unit.filter(|_| field.kind.measurable())
}

/// Whether the field's control is a set of elements rather than one.
///
/// A DOM concern rather than a description one, which is why it is decided here
/// and not in `makeover-layout`: `for` and `id` are an HTML association and
/// egui has no counterpart to get wrong. A `<label for>` aimed at a radio group
/// points at nothing, because no single element carries the group's id, so the
/// association has to invert — the label takes an id and the group names itself
/// with `aria-labelledby`.
const fn is_group_control(kind: FieldKind) -> bool {
    matches!(kind, FieldKind::Radio | FieldKind::Interval)
}

/// An interval: two number boxes inside one labelled group.
///
/// The markup MNW's discover sidebar writes by hand -- a `role="group"` with
/// `aria-labelledby` pointing at the question, holding `min_price` and
/// `max_price` -- which is HTML saying by hand exactly what
/// [`FieldKind::Interval`] now says in the description. So this emits what that
/// page already proved is right, rather than inventing a shape.
///
/// The group carries the error state and the descriptions, for
/// [`push_radio`]'s reason: what is wrong is the answer, and marking one box
/// invalid would name the wrong half of a fault that belongs to both ends.
///
/// # Both boxes take the same extent
///
/// [`Field::min`], [`Field::max`] and [`Field::step`] describe the axis rather
/// than either end, so [`push_bounds`] writes them onto both. The crossing rule
/// is not emitted, because the description does not carry it and the browser
/// has no attribute for it: an upper end below the lower one is a refusal
/// whoever validated hands back as [`Field::error`], which lands on the group.
///
/// # Which end is which, in words
///
/// `aria-label`, because the description states direction structurally -- the
/// lower end's name is [`Field::name`] and the upper one's is
/// [`Field::upper_name`] -- and never in words. Words for the ends are the
/// host's, the same way a slider's readout is, and a page with visible Min and
/// Max captions supplies them through [`Filling::trailing`] rather than having
/// this crate own two strings of English.
fn push_interval(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
    let id = filling.id_for(field.name);

    out.push_str("<div class=\"");
    push_class(out, "form-interval", opts);
    let _ = write!(out, "\" role=\"group\" aria-labelledby=\"{id}-label\"");
    if field.invalid() {
        out.push_str(" aria-invalid=\"true\"");
    }
    push_described_by(out, field, &id);
    out.push('>');

    // An interval with no upper name has one end that can be submitted, which
    // is what the description said and is drawn honestly rather than repaired:
    // `Field::interval` is what makes it unsayable, and inventing a name here
    // would submit a parameter no handler is reading.
    let ends: [(&str, &str, &str); 2] = [
        ("lower", field.name, filling.value.as_text()),
        (
            "upper",
            field.upper_name.unwrap_or(""),
            filling.value.upper_text(),
        ),
    ];
    for (end, name, value) in ends {
        if name.is_empty() {
            continue;
        }
        out.push_str("<input type=\"number\" class=\"");
        push_class(out, "field", opts);
        let _ = write!(out, "\" id=\"{id}-{end}\" name=\"");
        escape_into(name, out);
        let _ = write!(out, "\" aria-label=\"{end}\"");
        if field.required {
            out.push_str(" required");
        }
        push_bounds(out, field);
        if let Some(text) = field.placeholder {
            out.push_str(" placeholder=\"");
            escape_into(text, out);
            out.push('"');
        }
        out.push_str(" value=\"");
        escape_into(value, out);
        out.push_str("\">");
    }

    out.push_str("</div>");
}

/// A radio group: the options as sibling inputs sharing one `name`.
///
/// The group carries the error state and the descriptions, and the inputs carry
/// what submits. That split is [`Field::invalid`]'s reasoning applied one level
/// down: marking a single input invalid would say the wrong thing, since what
/// is wrong is the answer to the question and not one of the alternatives.
///
/// Ids are numbered rather than built from the option values, which can hold
/// anything a `&str` can — spaces and quotes included — and would otherwise
/// have to be slugged into something unique by a rule this crate would then own.
///
/// `required` lands on every input, which is how HTML says a group is
/// compulsory: the constraint is satisfied when any one of them is checked.
fn push_radio(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
    let id = filling.id_for(field.name);
    let value = filling.value.as_text();
    let name = escape(field.name);

    out.push_str("<div class=\"");
    push_class(out, "form-radio-group", opts);
    let _ = write!(out, "\" role=\"radiogroup\" aria-labelledby=\"{id}-label\"");
    if field.invalid() {
        out.push_str(" aria-invalid=\"true\"");
    }
    push_described_by(out, field, &id);
    out.push('>');

    // A group described with no options emits an empty group, for the reason
    // `Field::options` gives: an app whose option list has not loaded has
    // exactly that, and an empty group says so on screen rather than in a log.
    for (index, opt) in field.options.iter().enumerate() {
        out.push_str("<label class=\"");
        push_class(out, "form-radio-label", opts);
        let _ = write!(
            out,
            "\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" value=\""
        );
        escape_into(opt.value, out);
        out.push('"');
        if opt.value == value {
            out.push_str(" checked");
        }
        if field.required {
            out.push_str(" required");
        }
        // A radio group has room a `<select>` does not, so the reason gets its
        // own element beside the label rather than being run into it. The class
        // is what a stylesheet mutes; the text is there either way, which is
        // the half that matters — the finding was a greyed control with its
        // explanation behind a hover.
        if opt.unavailable.is_some() {
            out.push_str(" disabled");
        }
        out.push_str("><span>");
        escape_into(opt.label, out);
        out.push_str("</span>");
        // What picking it means, on the line under the label. `5e21dcfc`, and
        // the same treatment the reason gets one line down: a radio group has
        // room, so the sentence sits in its own element rather than being run
        // into the label the way a `<select>`'s has to be.
        //
        // Before the reason, which is the order the two read in: what this
        // option *is* comes ahead of why it cannot be picked, and an option
        // carrying both has said two things rather than one long one.
        if let Some(detail) = opt.detail {
            out.push_str("<span class=\"");
            push_class(out, "form-option-detail", opts);
            out.push_str("\">");
            escape_into(detail, out);
            out.push_str("</span>");
        }
        if let Some(reason) = opt.unavailable {
            out.push_str("<span class=\"");
            push_class(out, "form-option-reason", opts);
            out.push_str("\">");
            escape_into(reason, out);
            out.push_str("</span>");
        }
        out.push_str("</label>");
    }

    out.push_str("</div>");
}

/// The options of a select: the unanswered instruction, an unmatched current
/// value carried as its own, then the options themselves.
///
/// An option is marked either by [`Choice::chosen`] or by carrying the field's
/// current value; the stray-option and placeholder paths below key on the value
/// alone, so a list that marks itself has an empty value and reaches neither.
///
/// A select handed a value no option carries renders with nothing selected, the
/// browser falls back to the first option, and the next save writes a value
/// nobody chose. goingson hit exactly that with a backup-retention default of
/// 10 against a 1/3/7/14/0 list, and grew this stray-option fix locally; it is
/// here so the second app gets it without hitting the bug first.
fn push_options(out: &mut String, field: &Field<'_>, options: &[Choice<'_>], value: &str) {
    // The unanswered state, which HTML has no attribute for: `placeholder` is
    // not a `<select>` attribute, and the idiom is an empty option that cannot
    // be chosen back. `disabled` is what stops it being re-selected once the
    // user has answered, and `selected` is what puts it in the closed control
    // while the value is empty; together they read as an instruction rather
    // than as an option.
    //
    // `required` keeps working through it rather than around it: the option's
    // value is empty, so a required select with this showing is invalid, which
    // is the true report on a question nobody has answered.
    //
    // Emitted only while the value is empty, so it does not sit in the open
    // list once the field is answered. A non-empty value no option carries is a
    // wrong answer rather than an absent one and takes the stray-option path
    // below.
    if value.is_empty()
        && let Some(text) = field.placeholder
    {
        out.push_str("<option value=\"\" disabled selected>");
        escape_into(text, out);
        out.push_str("</option>");
    }
    if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
        // The one place an escaped value is worth keeping: it is written twice,
        // as the option's value and as its text.
        let escaped = escape(value);
        let _ = write!(
            out,
            "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
        );
    }
    for opt in options {
        out.push_str("<option value=\"");
        escape_into(opt.value, out);
        out.push('"');
        // Two ways an option is the marked one, and a description uses one of
        // them: the option says so itself, or the field's value names it. See
        // [`makeover_layout::Choice::chosen`] for why both exist and why this
        // crate cannot refuse the pair -- a caller that sets both gets both
        // marked, and quasi-declare is where that is caught.
        //
        // A `placeholder` is unaffected and still rides on an empty value: it
        // is emitted `selected` to show the unanswered state, and a list whose
        // own option is chosen leaves two options selected, which HTML resolves
        // to the last one in tree order. That is the chosen option, since the
        // placeholder is emitted first.
        if opt.chosen || opt.value == value {
            out.push_str(" selected");
        }
        // `disabled` is what the browser reads, and it says nothing about why.
        // The reason goes in the option's own text, because a `<select>` gives
        // its options no room for anything else: no title attribute the
        // keyboard reaches, no second line, no element inside. So the row reads
        // "Multi-sample: Drop a second sample onto the keyboard." and is the
        // one place the precondition can be both attached to its option and
        // read without a pointer.
        if opt.unavailable.is_some() {
            out.push_str(" disabled");
        }
        out.push('>');
        escape_into(opt.label, out);
        // Both extra strings run into the row's text, for the reason above:
        // this is the one control with nowhere else to put either of them.
        // `5e21dcfc` did not invent that rule, it met it.
        if let Some(detail) = opt.detail {
            out.push_str(": ");
            escape_into(detail, out);
        }
        if let Some(reason) = opt.unavailable {
            out.push_str(": ");
            escape_into(reason, out);
        }
        out.push_str("</option>");
    }
}

/// The themes, as one `<optgroup>` per variant with a contrast mark per row.
///
/// # The grouping comes out of the order, not out of a group list
///
/// [`makeover_layout::Field::themes`] arrives sorted by variant and then by
/// measured contrast, and the run of one variant is the group. So this walks
/// the list once and opens a new `<optgroup>` whenever the variant changes,
/// which is the whole of the grouping logic and cannot disagree with the order
/// the way a separately-carried group list could.
///
/// A theme whose variant equals its predecessor's never opens a group, so a
/// list that arrived unsorted would emit repeated groups rather than silently
/// merging distant rows. That is the honest report on a description that broke
/// its own contract, and it is visible on screen rather than in a log.
///
/// # The follow row is not in a group
///
/// It names no theme and sits in no variant, so it is emitted first and bare.
/// Grouping it under a heading would be inventing a fourth variant for one row.
///
/// # The badge is text, because a `<select>` has nowhere else to put it
///
/// A `<select>`'s options take no elements, no second line and no title the
/// keyboard reaches, which is [`push_options`]' finding about
/// [`Choice::unavailable`] met a second time. So the tier rides in the option's
/// own text, in brackets after the name, and it is
/// [`makeover_layout::Contrast::badge`]'s spelling rather than one invented
/// here — three renderers picking their own is one picker reading three ways.
fn push_theme_options(out: &mut String, field: &Field<'_>, value: &str) {
    if let Some(follow) = field.follows {
        out.push_str("<option value=\"");
        escape_into(follow.value, out);
        out.push('"');
        if follow.value == value {
            out.push_str(" selected");
        }
        out.push('>');
        escape_into(follow.label, out);
        out.push_str("</option>");
    }

    // A stored id naming a theme that is no longer installed. `push_options`'
    // reasoning applies unchanged: a value no row carries is a wrong answer
    // rather than an absent one, and dropping it would silently show the user
    // a different theme than the one their config names.
    let known = field.themes.iter().any(|theme| theme.id == value)
        || field.follows.is_some_and(|follow| follow.value == value);
    if !value.is_empty() && !known {
        let escaped = escape(value);
        let _ = write!(
            out,
            "<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
        );
    }

    let mut open: Option<ThemeVariant> = None;
    for theme in field.themes {
        if open != Some(theme.variant) {
            if open.is_some() {
                out.push_str("</optgroup>");
            }
            out.push_str("<optgroup label=\"");
            escape_into(theme.variant.heading(), out);
            out.push_str("\" data-variant=\"");
            out.push_str(theme.variant.as_str());
            out.push_str("\">");
            open = Some(theme.variant);
        }

        out.push_str("<option value=\"");
        escape_into(theme.id, out);
        out.push_str("\" data-contrast=\"");
        out.push_str(theme.contrast.as_str());
        out.push('"');
        if theme.id == value {
            out.push_str(" selected");
        }
        out.push('>');
        escape_into(theme.name, out);
        out.push_str(" (");
        out.push_str(theme.contrast.badge());
        out.push(')');
        out.push_str("</option>");
    }
    if open.is_some() {
        out.push_str("</optgroup>");
    }
}

/// The control itself, without its label, hint or error.
fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
    // Emitted before anything else is computed: a radio group carries its
    // descriptions on the group rather than on a control, so none of the
    // attributes below belong to it.
    if matches!(field.kind, FieldKind::Radio) {
        push_radio(out, field, filling, opts);
        return;
    }
    // The same split one kind along: an interval is two inputs and one
    // question, so the group carries the error and the descriptions and the
    // boxes carry what submits.
    if matches!(field.kind, FieldKind::Interval) {
        push_interval(out, field, filling, opts);
        return;
    }

    let id = filling.id_for(field.name);
    let placeholder = |out: &mut String| {
        if let Some(text) = field.placeholder {
            out.push_str(" placeholder=\"");
            escape_into(text, out);
            out.push('"');
        }
    };

    match field.kind {
        // Both multi-line kinds are a `<textarea>`, and the markdown one says so
        // in an attribute rather than in a class: what the value *is* is not a
        // styling hook, and a progressive enhancement looking for editors to
        // upgrade needs a selector that survives `Emit`'s class prefixing.
        // Without the mark, a described editor is a plain box and the four
        // hand-written MNW editors have nothing to convert onto.
        //
        // `data-format` and not `data-value`: this names the shape of the
        // value, and `facet` already spends `data-facet-value` on carrying an
        // actual one. Two attributes a letter apart meaning opposite things is
        // how a renderer's own vocabulary starts drifting.
        kind if kind.multiline() => {
            let rich = matches!(kind, FieldKind::Rich);
            if rich {
                push_editor_open(out, opts);
            }
            out.push_str("<textarea class=\"");
            push_class(out, "field", opts);
            out.push('"');
            if rich {
                out.push_str(" data-format=\"markdown\"");
            }
            push_control_attributes(out, field, filling, &id, field.name);
            placeholder(out);
            out.push('>');
            escape_into(filling.value.as_text(), out);
            out.push_str("</textarea>");
            if rich {
                push_editor_close(out, opts);
            }
        }
        FieldKind::Select => {
            out.push_str("<select class=\"");
            push_class(out, "field", opts);
            out.push('"');
            push_control_attributes(out, field, filling, &id, field.name);
            out.push('>');
            // A select described with no options emits an empty select, which
            // says so on screen rather than in a log. That is the description's
            // own position on `Field::options`, not a fallback invented here.
            push_options(out, field, field.options, filling.value.as_text());
            out.push_str("</select>");
        }
        // The one place this renderer emits `<optgroup>`, and it emits it
        // because the description finally says there is a group. The measured
        // history is the argument: `optgroup` appears at one live site in the
        // whole tree, and the two apps that had grouped theme pickers lost the
        // grouping the moment they were described, because `Choice` is a value
        // and a label and a group is neither.
        FieldKind::Theme => {
            out.push_str("<select class=\"");
            push_class(out, "field", opts);
            out.push('"');
            push_control_attributes(out, field, filling, &id, field.name);
            out.push('>');
            push_theme_options(out, field, filling.value.as_text());
            out.push_str("</select>");
        }
        FieldKind::Checkbox => {
            out.push_str("<label class=\"");
            push_class(out, "form-checkbox-label", opts);
            out.push_str("\"><input type=\"checkbox\"");
            push_control_attributes(out, field, filling, &id, field.name);
            if matches!(filling.value, Value::On(true)) {
                out.push_str(" checked");
            }
            out.push_str("><span>");
            escape_into(field.label, out);
            out.push_str("</span></label>");
        }
        // A secret never carries its value into the markup. `FieldKind::secret`
        // is documented as a value that must not be round-tripped through
        // anything that might persist it, and the DOM is such a thing: it is
        // read by every extension on the page and is the first thing a crash
        // reporter serialises. Neither app pre-fills one today, so this costs
        // nothing and closes the door before something does.
        FieldKind::Secret => {
            out.push_str("<input type=\"password\" class=\"");
            push_class(out, "field", opts);
            out.push('"');
            push_control_attributes(out, field, filling, &id, field.name);
            placeholder(out);
            out.push('>');
        }
        // A file input carries no value, and this is the browser's rule rather
        // than a preference: setting one from markup is refused, because a page
        // that could preselect a path could read a file the user never offered.
        // Nothing upstream needs to know, which is why the exception is here.
        FieldKind::File => {
            out.push_str("<input type=\"file\" class=\"");
            push_class(out, "field", opts);
            out.push('"');
            push_control_attributes(out, field, filling, &id, field.name);
            push_accept(out, field);
            if field.multiple {
                out.push_str(" multiple");
            }
            out.push('>');
        }
        kind => {
            let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
            push_class(out, "field", opts);
            out.push('"');
            push_control_attributes(out, field, filling, &id, field.name);
            placeholder(out);
            out.push_str(" value=\"");
            escape_into(filling.value.as_text(), out);
            out.push_str("\">");
        }
    }
}

/// The chrome a markdown field gets and a plain textarea does not: the two
/// modes, and the pane a preview lands in.
///
/// # Why this is the one field with markup around it
///
/// [`FieldKind::Rich`]'s own doc says the mark buys a renderer permission to
/// offer a preview or a syntax pass, and that a renderer with neither draws a
/// textarea. A renderer taking the permission and emitting the same box as
/// [`FieldKind::Textarea`] leaves an app converting onto the member with less
/// than it had written by hand: MNW's `partial-item-text-editor.js` has a
/// Write/Preview pair and a pane behind it, and describing the field without
/// this would delete both. So the pair is here, on `facet`'s argument one
/// field down -- the markup it replaces is not markup an app is keeping.
///
/// # Nothing here renders markdown, and that is where the sanitising stays
///
/// The pane arrives empty and this crate never turns a value into markup.
/// Converting markdown is the host's, which is where the sanitiser already is:
/// MNW renders through `docengine` over ammonia and holds an allowlist beside
/// it. A converter here would move that guarantee into a crate with no view of
/// the host's content-security posture, and `Rich`'s doc is explicit that a
/// host with its own sanitiser still owns it. What this emits is a hook, and
/// whatever fills it fills it with markup it has already made safe.
///
/// # The direction the enhancement runs
///
/// [`crate::stylesheet`]'s rule for a showing region, and for its reason: a
/// control rendered into a document with no script is a control that looks live
/// and answers nothing. Nothing is hidden here and no control is shown until
/// whatever binds the editor sets `data-ready` on the wrapper, so a reader with
/// no script gets the textarea alone and a reader with script gets the modes. A bound editor says which mode it is in with
/// `data-mode`, and [`editor_rules`] reads that.
fn push_editor_open(out: &mut String, opts: &Emit) {
    // The mark sits on the wrapper as well as on the control, saying one thing
    // about two: this control's value is markdown, and this editor edits
    // markdown. The rules gate on the wrapper and they are attribute rules
    // rather than class rules for `data-format`'s own reason -- the gate has to
    // survive `Emit`'s class prefixing, because the enhancement selects on it
    // too.
    out.push_str("<div data-format=\"markdown\"><div class=\"");
    push_class(out, "form-editor-modes", opts);
    out.push_str("\">");
    push_mode(out, "write", "Write", true, opts);
    push_mode(out, "preview", "Preview", false, opts);
    out.push_str("</div>");
}

/// One of the two modes, as a segment of the pair.
///
/// [`crate::option_class`] for [`Selector::Segmented`] rather than a name of
/// its own: a Write/Preview pair is a segmented control, and spelling it as one
/// gets it the depth, the focus ring and the chosen state every described
/// selector gets, from rules that already exist. The words are written here for
/// the reason `facet`'s exclude button writes its own: a description carrying
/// them would be choosing them for the terminal as well.
fn push_mode(out: &mut String, mode: &str, label: &str, chosen: bool, opts: &Emit) {
    out.push_str("<button type=\"button\" class=\"");
    push_class(out, crate::option_class(Selector::Segmented), opts);
    if chosen {
        // The sheet keys the held-in segment on the class and a screen reader
        // reads the attribute. Both, because they are two readings of one fact,
        // which is the arrangement a facet value already has.
        out.push_str(" chosen");
    }
    let _ = write!(
        out,
        "\" data-editor-mode=\"{mode}\" aria-pressed=\"{chosen}\">{label}</button>"
    );
}

/// The preview pane, and the wrapper closing over both halves.
fn push_editor_close(out: &mut String, opts: &Emit) {
    out.push_str("<div class=\"");
    push_class(out, "form-editor-preview", opts);
    // `data-editor-preview` and not an id: a form appears twice in a document
    // often enough that `Filling::id_prefix` exists for it, and a binder holding
    // the control can reach this without either of them being unique.
    out.push_str("\" data-editor-preview></div></div>");
}

/// The rules the markdown editor's chrome needs.
///
/// The one place this module writes CSS. The class names [`field_html`] emits
/// are goingson's and are deliberately unruled -- `.form-group`, `.form-label`,
/// `.form-hint` and `.form-error` are the app's own, and phase A emits only what
/// it can generate from the description -- but the two names here have no app
/// counterpart to keep, because the chrome did not exist before the member did.
///
/// 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, which is the whole argument in
    // `push_editor_open`.
    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 rule a field's unit needs.
///
/// [`suggestion_rules`]' precedent and its argument: `.form-group`,
/// `.form-label`, `.form-hint` and `.form-error` are the apps' own names and
/// stay unruled here, and this one has no app counterpart to keep because
/// nothing emitted it before `Field::unit` existed.
///
/// 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.
///
/// Nothing about placement or spacing. Where the span sits relative to the
/// control is the app's layout, exactly as `.form-hint`'s is, and a margin
/// asserted here would be this crate deciding a magnitude that belongs to
/// `makeover-geometry`.
/// The rules a field's note needs.
///
/// [`unit_rules`]' precedent and its argument: `.form-hint` and `.form-error`
/// are the apps' own names and stay unruled here, and this one has no app
/// counterpart to keep because nothing emitted it before [`Field::note`]
/// existed.
///
/// Colour only, and the tones are the four a badge carries. 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    color: var(--{0});\n}}",
            tone.token()
        );
    }
    css
}

pub(crate) fn unit_rules(opts: &Emit) -> String {
    let unit = class("form-unit", opts);
    let mut css = String::new();
    let _ = writeln!(css, ".{unit} {{\n    color: var(--content-muted);\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: the class names this module's
/// markup emits are the apps' own and stay unruled, and these three have no app
/// counterpart to keep because the list did not exist before the member did.
/// The markup is `quasi-webview`'s rather than this crate'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.
///
/// # Nothing about size
///
/// No height, no scroll ceiling, no padding. 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}}");
    // 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. `1fcf2e9b` replaced the
    // unavailable reason this rule used to draw: 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
}

/// One field, as the group the app drops into its form.
///
/// The shape is goingson's, down to the class names, so adoption there deletes
/// `renderFormField` rather than restyling anything. That is also why the class
/// names are not emitted by [`crate::stylesheet`]: `.form-group`, `.form-label`,
/// `.form-hint` and `.form-error` are the apps' own, and phase A deliberately
/// emits only what it can generate from the description. Whether they should
/// move into the description is the next question this raises, not one it
/// answers.
///
/// A [`FieldKind::Hidden`] field is the input alone: no group, no label, and
/// nothing drawn, which is what [`FieldKind::visible`] means.
///
/// The error marks the group as well as the control. That is
/// [`Field::invalid`]'s own reasoning: a renderer with no descendant selectors
/// cannot find the group from the message, so the group has to be told.
///
/// ```
/// use makeover_layout::{Field, FieldKind};
/// use makeover_webview::{Emit, form::{Filling, Value, field_html}};
///
/// let field = Field::new(FieldKind::Text, "title", "Title");
/// let html = field_html(&field, &Filling::of(Value::Text("Ship it")), &Emit::default());
///
/// assert!(html.contains(r#"<label class="form-label" for="title">Title</label>"#));
/// assert!(html.contains(r#"value="Ship it""#));
/// ```
#[must_use]
pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
    let mut html = String::new();
    field_html_into(field, filling, opts, &mut html);
    html
}

/// One field, written into a buffer the caller already has.
///
/// [`field_html`]'s streaming form, byte-identical to it. A form is a run of
/// these, so a host building one should hold a single buffer and append each
/// field into it rather than take a `String` per field and concatenate.
pub fn field_html_into(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit, out: &mut String) {
    let id = filling.id_for(field.name);

    if !field.kind.visible() {
        // Name only, no id: a hidden field is never pointed at by a label or a
        // description, so the one attribute it needs is the one that submits.
        out.push_str("<input type=\"hidden\" name=\"");
        escape_into(field.name, out);
        out.push_str("\" value=\"");
        escape_into(filling.value.as_text(), out);
        out.push_str("\">");
        return;
    }

    out.push_str("<div class=\"");
    push_class(out, "form-group", opts);
    if field.invalid() {
        out.push_str(" has-error");
    }
    if field.extended {
        // The disclosure that hides these is a property of the form, not of the
        // field, so the field is marked and the app opens or closes the group.
        out.push_str("\" data-extended=\"true");
    }
    out.push_str("\">");

    // A checkbox labels itself, on the right of the box. Both apps special-case
    // this inline today, which is the tell that it belongs in the description;
    // `FieldKind::labels_itself` is where it went.
    if !field.kind.labels_itself() {
        out.push_str("<label class=\"");
        push_class(out, "form-label", opts);
        // A group control is named *by* its label rather than pointing at it,
        // so the two carry opposite halves of the association. See
        // `is_group_control`.
        if is_group_control(field.kind) {
            let _ = write!(out, "\" id=\"{id}-label\">");
        } else {
            let _ = write!(out, "\" for=\"{id}\">");
        }
        escape_into(field.label, out);
        out.push_str("</label>");
    }

    push_control(out, field, filling, opts);

    // Adjacent text, because HTML has no unit attribute and inventing one would
    // be markup nothing reads. Pointed at by `aria-describedby` so it is not
    // decoration a screen reader skips: the number and what it is measured in
    // are one fact, and reading the first without the second is reading it
    // wrong.
    if let Some(unit) = unit_of(field) {
        out.push_str("<span class=\"");
        push_class(out, "form-unit", opts);
        let _ = write!(out, "\" id=\"{id}-unit\">");
        escape_into(unit, out);
        out.push_str("</span>");
    }

    if let Some(hint) = field.hint {
        out.push_str("<div class=\"");
        push_class(out, "form-hint", opts);
        let _ = write!(out, "\" id=\"{id}-hint\">");
        escape_into(hint, out);
        out.push_str("</div>");
    }
    // A consequence of the answer, between the standing help and the failure.
    // The tone rides on `data-tone` -- the same attribute every other toned
    // thing in this crate takes -- and it also picks the live region: Warning
    // and Danger are assertive, which is quasi-webview's own reading at
    // `node.rs:1403` and is honoured here rather than restated differently.
    if let Some((tone, note)) = field.note {
        out.push_str("<div class=\"");
        push_class(out, "form-note", opts);
        let assertive = matches!(tone, Tone::Warning | Tone::Danger);
        let _ = write!(
            out,
            "\" id=\"{id}-note\" role=\"{}\"",
            if assertive { "alert" } else { "status" }
        );
        // Neutral is the bare class rather than a variant, matching every
        // other toned component here: it is the absence of a status.
        if tone != Tone::Neutral {
            let _ = write!(out, " data-tone=\"{}\"", tone.token());
        }
        out.push('>');
        escape_into(note, out);
        out.push_str("</div>");
    }
    if let Some(Markup(markup)) = filling.trailing {
        out.push_str(markup);
    }
    if let Some(error) = field.error {
        out.push_str("<div class=\"");
        push_class(out, "form-error", opts);
        let _ = write!(out, " visible\" id=\"{id}-error\" role=\"alert\">");
        escape_into(error, out);
        out.push_str("</div>");
    }

    out.push_str("</div>");
}

#[cfg(test)]
mod tests;