makeover-layout 0.54.0

The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition, plus which affordances exist at a given input class and window size.
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
// Names this module's prose links to, resolved for rustdoc.
#[allow(unused_imports)]
use crate::{Awaiting, Fill, ThemeVariant};

/// What kind of value a form field takes.
///
/// The union of the two vocabularies that diverged, which is what triggered
/// this crate. They have since converged on their own: both apps now have a
/// `renderFormField` emitting the same anatomy, and what is left differing is
/// the kind set, the error shape, and whether the return is a string or a node.
///
/// Validation is deliberately absent. Neither app has a shared story (goingson
/// validates after collecting the form data, with per-field transform hooks;
/// Balanced Breakfast has `required` and nothing else), and a schema that
/// describes fields but not constraints acquires a constraint layer per app,
/// which is exactly how the current divergence started. Naming it absent is a
/// decision; leaving it unmentioned would not be.
/// `#[non_exhaustive]` for the reason [`Fill`] is: renderers match on this and
/// the set keeps growing, so growth must not be a lockstep event.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FieldKind {
    /// A single line of text.
    Text,
    /// A single line of text that must never be echoed, logged or round-tripped
    /// through anything that might persist it.
    Secret,
    /// A number.
    Number,
    /// A number inside bounds the user drags across, where the range being
    /// visible is the point.
    ///
    /// Not [`Number`](Self::Number) with a field's `min` and `max`, which is
    /// the reading to resist and is the same resistance [`Radio`](Self::Radio)
    /// needed against `Select`. A bounded number and a validated number are
    /// different *questions*. A validated
    /// number is typed and can be wrong: the bounds are a rule the answer is
    /// checked against, and being told "must be at least 1" afterwards is the
    /// normal course of it. A range cannot be out of range at all, because the
    /// bounds are the control's extent rather than a rule, and the two ends are
    /// what the question means, audiofiles asks for a classifier threshold
    /// between 0 and 1, where 0 is never and 1 is only-on-certainty, and a typed
    /// 0.72 says nothing without both ends on screen beside it.
    ///
    /// A renderer cannot infer which one is meant from `min`/`max` alone, which
    /// is why this is a kind and not an inference: goingson's `min="1"` duration
    /// is a validated number and would become a slider.
    ///
    /// The membership test passes without stretching: a webview emits
    /// `<input type="range">`, egui has `Slider`, a terminal draws a bar and
    /// takes arrow keys, a CLI takes a bounded argument.
    ///
    /// # It owes its bounds
    ///
    /// A field's `min` and `max` are optional for every other kind and are
    /// **required** here, in the sense the description can require anything:
    /// the field's `bounded` is the check, and a range missing one has no
    /// extent for a renderer to draw. What a renderer does with an unbounded
    /// range is its own call and both answers are honest (fall back to a typed
    /// number, or pick a host default) so this is stated rather than enforced,
    /// the way every other constraint here is.
    ///
    /// The granularity is the third fact and is genuinely optional: absent, the
    /// host's own stands.
    Range,
    /// One question with two ends: a lower value and an upper one, submitted
    /// under two names.
    ///
    /// "Show me samples between 90 and 130 BPM" has a single answer with two
    /// ends, and the ends constrain each other: a minimum above the maximum is
    /// not a wrong value, it is an empty result nobody asked for. Described as
    /// two [`Number`](Self::Number) fields that is unsayable, nothing says they
    /// are one question, so a renderer draws two controls with two labels and no
    /// relationship, and a field's error can only be attached to one side of a
    /// fault that belongs to both.
    ///
    /// Not [`Range`](Self::Range), which was the reading to resist and the
    /// resistance is the same one `Range` itself needed against `Number`. A
    /// range describes *one* value inside an extent; this describes two, and the
    /// extent is a bound on each rather than the question's meaning. The two
    /// come apart in the answer: a range has a value, an interval has a pair,
    /// and either end may be absent while the other stands.
    ///
    /// # It states both names
    ///
    /// The field's `name` is the lower end and its `upper_name` is the upper
    /// one, stated rather than derived. One member instead of a naming
    /// convention this crate would then own forever.
    ///
    /// Direction is carried by which member the name sits in, so nothing
    /// separate says which end is which.
    ///
    /// # What it does not enforce
    ///
    /// The crossing rule. A lower end above the upper one is describable here
    /// and always was, exactly as a number below its `min` is: this
    /// crate carries constraints and never checks them, and deciding a value is
    /// wrong stays with whoever validated. What the description buys is that the
    /// fault now has one place to be reported rather than two.
    ///
    /// # Both ends take the same facts
    ///
    /// `min`, `max`, `step` and `unit` describe the axis rather than one end of
    /// it, so they are read once and applied to both. Six of audiofiles' filter axes
    /// are exactly this: one extent, one unit, one granularity, two ends.
    ///
    /// The bounds are optional here, unlike `Range`. They are a rule the answer
    /// is checked against rather than the control's extent, which is
    /// [`Number`](Self::Number)'s arrangement and not a slider's.
    Interval,
    /// An email address.
    ///
    /// Distinct from [`Text`](Self::Text) because the distinction is not
    /// decoration: a webview renderer emits `type="email"`, which on a touch
    /// device changes the keyboard that appears and turns on the platform's own
    /// validation. goingson ships to iOS, so collapsing this into text costs a
    /// keyboard with no `@` on it.
    Email,
    /// A URL. Same reasoning as [`Email`](Self::Email).
    Url,
    /// A telephone number. Same reasoning as [`Email`](Self::Email), and the
    /// clearest case of it: the keyboard is a numeric pad rather than letters.
    Tel,
    /// A calendar day, with no time of day in it.
    ///
    /// [`Email`](Self::Email)'s argument, and it carries further: a webview
    /// emits `type="date"`, which is a native picker, the platform's own
    /// validation, and on a touch device the date keyboard. Described as
    /// [`Text`](Self::Text) with a hint reading "YYYY-MM-DD", all three are
    /// lost and the hint is doing the platform's job in prose.
    ///
    /// The membership test passes on every host without stretching: a webview
    /// and a Tauri app emit the input, egui has a date picker, a terminal
    /// prompts for a day and can validate it, a CLI takes an argument.
    ///
    /// # The value is ISO 8601, `YYYY-MM-DD`
    ///
    /// Named here rather than left to each host, because a host that picks
    /// differently sends a server something it parses differently, and the
    /// failure is silent and per-host. It is `<input type="date">`'s own wire
    /// format, so the webview renderer owes nothing to honour it and the other
    /// hosts have one spelling to meet. [`DATE_FORMAT`] is the constant, and a
    /// test asserts this doc and that constant agree.
    Date,
    /// A calendar day and a time of day together.
    ///
    /// Apart from [`Date`](Self::Date) because the question is different rather
    /// than more precise: "which day does this expire" and "at what moment does
    /// this publish" are asked by different screens and answered by different
    /// controls. A webview emits `type="datetime-local"` for one and
    /// `type="date"` for the other, and a host that collapsed them would ask
    /// half the tree for a precision it does not want.
    ///
    /// Both arrived together on measurement rather than on symmetry: 13 sites
    /// of each across the MNW server and goingson, and **zero** of `time`,
    /// `month` or `week`, which is why those are not here. A member added for a
    /// case nobody has is a member designed against nothing, which is
    /// [`File`](Self::File)'s reasoning about `accept` applied to a whole
    /// member.
    ///
    /// # The value is `YYYY-MM-DDTHH:MM`, local, with no zone
    ///
    /// `<input type="datetime-local">`'s own format, and the "local" is the
    /// load-bearing half: the value carries no offset and no `Z`, so the moment
    /// it names is only fixed once something supplies a zone. That is the app's
    /// business and not the description's. Seconds are absent, which is the
    /// browser's own default and is left as the rule rather than restated as a
    /// constraint. [`DATETIME_FORMAT`] is the constant.
    ///
    /// A field's `min` and `max` already take "the host's own spelling of a
    /// bound", so a floor of *not in the past* needs nothing new here: it
    /// is a string in this same format.
    DateTime,
    /// Several lines of text.
    Textarea,
    /// Several lines of text the user writes markdown in.
    ///
    /// The editing counterpart of prose a description carries as markdown
    /// source, and the reason it can exist at all is the same one that lets the
    /// source be carried: editing markdown is editing text, so a terminal, an
    /// immediate-mode host and a webview all have an honest answer, and none of
    /// them has to refuse. A kind that meant "rich text" in the WYSIWYG sense
    /// would have been a document model, and two of the three hosts would have
    /// had to draw something they cannot.
    ///
    /// What the mark buys over [`Textarea`](Self::Textarea) is that a renderer
    /// may offer the affordances markdown has and plain text does not (a
    /// preview, a syntax pass, a monospaced face for the source) and that a
    /// host reading the value back knows what it is holding. A renderer with
    /// none of that draws a textarea, which is why this is additive rather than
    /// a second control.
    ///
    /// It says nothing about **when** the value is saved. Autosave is a clock,
    /// clocks are not described here, and the four MNW editors this was measured
    /// against each keep their own.
    ///
    /// Sanitising stays where it already is for markdown that is only displayed:
    /// with the renderer, at the point markup is produced. Being described is
    /// not a safety property, and a host with its own sanitiser and its own
    /// content-security posture still owns both.
    Rich,
    /// One of a fixed set, offered behind a control that shows one at a time.
    Select,
    /// One of a fixed set, with every option on screen at once.
    ///
    /// Not a presentation of [`Select`](Self::Select), which is the reading to
    /// resist: what differs is a property of the *question*. A choice that is
    /// consequential or irreversible has to be readable without opening
    /// anything, because a closed control shows one option and hides the rest,
    /// and the one it shows is whichever was current before the user had read
    /// the alternatives. audiofiles asks whether a library copies samples into
    /// its store or references them where they lie (which cannot be changed
    /// afterwards) and had already promoted that out of a checkbox by hand,
    /// with a comment giving this reason, before the description could say it.
    ///
    /// Everything here is an `<input type=...>`, a `<select>` or a
    /// `<textarea>`, and the way this enum grows is by a site being measured
    /// rather than by a list being completed. No member is ever "the last one".
    Radio,
    /// Any number of a fixed set, with every option on screen at once.
    ///
    /// [`Radio`](Self::Radio)'s shape with the answer widened from one option
    /// to a set, and not a row of [`Checkbox`](Self::Checkbox)es, which is the
    /// reading to resist. A row of checkboxes is several questions, each with
    /// its own name, label and error; this is one question whose answer has
    /// several parts. The difference shows the moment anything is wrong: "pick
    /// at least one" belongs to the set, and a row of checkboxes has nowhere to
    /// put it but under one box.
    ///
    /// Four measured sites, all in the MNW server and all written by hand. A
    /// project's features are asked three times, in its settings and in both
    /// project wizards. The fourth is the item wizard's bundle picker, which
    /// joins the ticked ids with commas into a hidden input: the delimiter this
    /// member exists so that no host has to invent.
    ///
    /// # The ticked set is marked on the options
    ///
    /// Each ticked option is marked `chosen`. A set is not one string,
    /// and a value holding one would need a separator that any option's value
    /// could contain. `chosen` is already how an option list marks itself, so
    /// a checklist marks as many options as are ticked and needs no second
    /// spelling.
    ///
    /// # It submits the name once per ticked option
    ///
    /// HTML's own wire format for a set of checkboxes sharing a name, so a
    /// webview owes nothing to honour it. An empty set submits nothing under
    /// the name, and a handler reads an absent name as the empty answer rather
    /// than as a missing one.
    Checklist,
    /// On or off.
    Checkbox,
    /// A file the user picks from wherever the host keeps files.
    ///
    /// It was filed as a router finding (a control whose destination is a
    /// host capability rather than an address) and splitting it is what made
    /// it two answers instead of one member satisfying neither. *Opening* a
    /// file is a one-way handoff and needs no new API. *Picking* one returns a
    /// value into a write, which is a form concern, which is this.
    ///
    /// The membership test passes on every host and not by a stretch: a Tauri
    /// app opens a native picker, a server renders `<input type="file">`, a
    /// terminal prompts for a path, a CLI takes an argument. That is closer to
    /// [`Email`](Self::Email), which exists because it changes the keyboard,
    /// than to anything bespoke.
    ///
    /// # The four things an upload says, and where each of them lives
    ///
    /// | axis | where |
    /// |---|---|
    /// | what it accepts | the field's accept list, [`Family`] for a media family |
    /// | one file or several | the field's `multiple` |
    /// | where the bytes go | the router's action, not here |
    /// | how far along it is | [`Awaiting`] on that action |
    ///
    /// Only the first two are this crate's, and that split is the answer to
    /// "describe an upload in full" rather than a gap in it. A destination is an
    /// address and this crate holds no addresses; progress is a live number and
    /// a description is built once, so the number is the renderer's to observe
    /// against the size [`Awaiting::amount`] carried before the transfer began.
    ///
    /// # How the file is handed over is the host's
    ///
    /// A drop area, a button opening a native picker, a path typed at a prompt:
    /// all three are the same field, and every measured site has the first. It
    /// is not described for the reason no gesture is, this crate owns no
    /// coordinates and no pointer, and a terminal that cannot be dropped on
    /// would be refusing a description it can otherwise honour completely.
    ///
    /// The accept list and `multiple` are measured rather than deferred. A
    /// member designed against nothing is the rule to keep: count the sites
    /// before adding one.
    File,
    /// Which theme the app wears.
    ///
    /// The one member here that names a *subject* rather than a shape of
    /// answer, and it is worth saying why that is not the door it looks like.
    /// Every other kind is a question a screen might ask about anything; this
    /// one is a specific question every app in the family asks, once, on its
    /// settings screen, and three of them wrote the same control by hand.
    ///
    /// # It is furniture, and the measurement is what says so
    ///
    /// The reading to resist is that this is [`Select`](Self::Select) with a
    /// grouped option list. Option lists that group barely occur; the thing
    /// that recurs is this picker.
    ///
    /// # What it carries that a select cannot
    ///
    /// A list of themes rather than a list of options, because a theme is four
    /// facts and an option is two. The two extra facts are the ones no
    /// app can supply without redoing work the theme layer has already done:
    /// which [`ThemeVariant`] group a theme is in, and how legible its muted
    /// text measured. An option labelled `format!("{name} ({variant})")` is
    /// what the three apps had, and it flattens the group into prose and loses the
    /// tier entirely.
    ///
    /// The field's `follows` carries the entry that is not a theme.
    ///
    /// # The cost, stated rather than discovered later
    ///
    /// This puts one screen's shape into a vocabulary that otherwise holds
    /// none, which was the objection raised against it and accepted going in.
    /// The mitigation is narrowness: this describes a theme picker, not a
    /// general "list the host resolved" mechanism. A second host-resolved list
    /// is when that generalisation gets measured, and not before.
    ///
    /// A renderer that has not heard of it draws a select over the themes'
    /// names and loses the grouping, which is the state
    /// every app was in before this member. Degrading to the status quo ante
    /// is the floor the member is designed against.
    Theme,
    /// Which density this device draws at: what it detects, or the reader's
    /// choice of [`Density::Pointer`] or [`Density::Touch`].
    ///
    /// # The device holds the answer, not the app
    ///
    /// Density is a claim about what is pointing at this screen, so the
    /// choice is the device's the way detection is. A laptop and a tablet
    /// signed into one account want different answers, and an app storing
    /// one value would hand the tablet the laptop's. So the field carries no
    /// value and submits none: the host reads its own store to show the
    /// current choice, writes it there on a pick, and applies it at once.
    /// The spelling it stores is [`Density::attribute_value`], so every host
    /// reading one store agrees, and absence is "follow detection".
    ///
    /// A webview keeps it in the browser and writes
    /// [`DENSITY_ATTRIBUTE`](makeover_geometry::DENSITY_ATTRIBUTE) on the
    /// root; an egui host keeps it with its own persisted state; a terminal
    /// has no contact patch to size for and does not draw the field.
    ///
    /// Furniture for [`Theme`](Self::Theme)'s reason and on its terms: it
    /// names one picker and is not a general "setting the host keeps"
    /// mechanism. A second device-held setting is when that gets measured.
    ///
    /// [`Density::Pointer`]: makeover_geometry::Density::Pointer
    /// [`Density::Touch`]: makeover_geometry::Density::Touch
    /// [`Density::attribute_value`]: makeover_geometry::Density::attribute_value
    Density,
    /// Carried through the form and never shown.
    Hidden,
}

/// What a [`FieldKind::Density`] picker offers, in order, and what each entry
/// is called.
///
/// `None` is "follow detection", which is also what an empty store means.
/// Here rather than in each host because the words are the description's:
/// a webview's select and an egui combo box offering the same three things
/// under different names would be two pickers. "Mouse and trackpad" rather
/// than "Pointer" because the reader is choosing by what is in their hand,
/// not by the preset's name.
pub const DENSITY_CHOICES: [(Option<makeover_geometry::Density>, &str); 3] = [
    (None, "Automatic"),
    (
        Some(makeover_geometry::Density::Pointer),
        "Mouse and trackpad",
    ),
    (Some(makeover_geometry::Density::Touch), "Touch"),
];

/// The wire format a [`FieldKind::Date`] value takes: ISO 8601, `YYYY-MM-DD`.
///
/// A constant rather than a sentence in a doc comment, because the reason to
/// name the format at all is that a host picking its own would fail silently
/// against a server parsing another. A host that cannot emit the native control
/// still has one spelling to meet, and can say which one it meant.
pub const DATE_FORMAT: &str = "%Y-%m-%d";

/// The wire format a [`FieldKind::DateTime`] value takes: `YYYY-MM-DDTHH:MM`,
/// local, carrying no zone and no seconds.
///
/// [`DATE_FORMAT`]'s sibling and there for its reason. The absent zone is a
/// property of the value rather than an omission: the moment is not fixed until
/// something outside the description supplies one.
pub const DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M";

impl FieldKind {
    /// Whether the value the kind takes is a moment rather than a string.
    ///
    /// Named once here for the reason [`offers_options`](Self::offers_options)
    /// is: two kinds answer yes, and a host that has to parse or format a value
    /// needs to ask without spelling the pair out at each renderer. A third
    /// temporal kind should land here and nowhere else.
    ///
    /// The format each one takes is [`DATE_FORMAT`] and [`DATETIME_FORMAT`].
    #[must_use]
    pub const fn temporal(self) -> bool {
        matches!(self, Self::Date | Self::DateTime)
    }

    /// Whether the field is drawn at all.
    #[must_use]
    pub const fn visible(self) -> bool {
        !matches!(self, Self::Hidden)
    }

    /// Whether the value must be kept out of logs and diagnostics.
    #[must_use]
    pub const fn confidential(self) -> bool {
        matches!(self, Self::Secret)
    }

    /// Where the field's own label sits.
    ///
    /// A checkbox labels itself on the right of the box; everything else takes
    /// a label above. Both webview apps already do this and both special-case
    /// it inline, which is the tell that it belongs in the description.
    ///
    /// A [`Radio`](Self::Radio) is not one of them, and the near-miss is worth
    /// naming: its *options* each label themselves, but the field still asks a
    /// question above them, so the group takes a label like everything else.
    #[must_use]
    pub const fn labels_itself(self) -> bool {
        matches!(self, Self::Checkbox)
    }

    /// Whether the kind reads a field's options.
    ///
    /// Three kinds do, so the set is named once here rather than spelled out at
    /// each renderer and again in the options' own doc, where "every
    /// kind but `Select`" was true for exactly one release.
    /// [`Checklist`](Self::Checklist) is the third and landed here and nowhere
    /// else, which is what this predicate is for.
    #[must_use]
    pub const fn offers_options(self) -> bool {
        matches!(self, Self::Select | Self::Radio | Self::Checklist)
    }

    /// Whether the answer is a set of the options rather than one of them.
    ///
    /// One kind answers yes, and it gets a name for
    /// [`takes_files`](Self::takes_files)'s reason: every renderer that walks
    /// a field's options has to ask it before it decides whether an option is
    /// marked by the field's value or by its own `chosen` alone, and whether
    /// picking one clears the others.
    #[must_use]
    pub const fn takes_several(self) -> bool {
        matches!(self, Self::Checklist)
    }

    /// Whether the kind reads a field's `themes` and `follows`.
    ///
    /// One member answers yes, and it gets a name for
    /// [`takes_files`](Self::takes_files)'s reason rather than in spite of
    /// being alone: four renderers ask it before they read either member, and
    /// a `matches!` per renderer is where the next one goes missing.
    ///
    /// Deliberately not folded into
    /// [`offers_options`](Self::offers_options). A theme picker offers no
    /// options at all, so a renderer walking `options` for it walks an
    /// empty slice and draws an empty control.
    #[must_use]
    pub const fn offers_themes(self) -> bool {
        matches!(self, Self::Theme)
    }

    /// Whether the host keeps the value rather than the app.
    ///
    /// One member answers yes, named for [`takes_files`](Self::takes_files)'s
    /// reason: a renderer has to ask before it writes a `name`, fills a value
    /// or submits one, and a field the device holds does none of the three.
    /// See [`Density`](Self::Density).
    #[must_use]
    pub const fn held_by_device(self) -> bool {
        matches!(self, Self::Density)
    }

    /// Whether the value runs to more than one line.
    ///
    /// Named once here for [`temporal`](Self::temporal)'s reason: two kinds
    /// answer yes, every renderer has to ask it before it can size anything,
    /// and a `matches!` per renderer is the pair drifting apart one member at a
    /// time. What a host does with the markdown, if anything, it reads from the
    /// kind itself; this is only whether one line is enough.
    #[must_use]
    pub const fn multiline(self) -> bool {
        matches!(self, Self::Textarea | Self::Rich)
    }

    /// Whether the value is a file the host picks rather than a string typed
    /// into a box.
    ///
    /// One member answers yes, which is [`visible`](Self::visible)'s and
    /// [`confidential`](Self::confidential)'s footing rather than a departure
    /// from it: the question gets a name because three renderers ask it before
    /// they can read a field's accept list or `multiple`, and a `matches!`
    /// per renderer is where a second file-taking kind would go missing.
    #[must_use]
    pub const fn takes_files(self) -> bool {
        matches!(self, Self::File)
    }

    /// Whether the value is a quantity, so a field's `unit` means something.
    ///
    /// The numeric kinds and nothing else. A date is a quantity in the sense
    /// that it is ordered, and it is not one in the sense that matters here:
    /// its unit is fixed by the kind, so `Date` carrying `days` would be the
    /// description restating what the kind already said.
    ///
    /// [`takes_files`](Self::takes_files)'s footing, and for its reason: the
    /// renderers ask this before they decide where a unit goes, and a
    /// `matches!` per renderer is where the next measurable kind goes missing.
    ///
    /// [`Interval`](Self::Interval) is measurable too: an axis is measured in
    /// something and both its ends are in it.
    #[must_use]
    pub const fn measurable(self) -> bool {
        matches!(self, Self::Number | Self::Range | Self::Interval)
    }
}

/// A family of media a file can belong to.
///
/// Three members, because three is what a media type's own first segment offers
/// that a renderer can do anything with. `text` and `application` are families
/// too and neither buys a disclosure (there is no preview of an
/// `application/octet-stream`) so naming them would be a member added for a
/// case nobody has.
///
/// It is the answer to "which disclosure", not a validation rule.
/// A field's accept list is what a host filters on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Family {
    /// A still picture.
    Image,
    /// Sound.
    Audio,
    /// Moving pictures, with or without sound.
    Video,
}

impl Family {
    /// The wildcard media type that means the whole family.
    ///
    /// `image/*` and its two siblings, which is what the measured sites write
    /// and what a webview puts in an `accept` attribute. Named here so the three
    /// renderers do not each spell the star.
    #[must_use]
    pub const fn wildcard(self) -> &'static str {
        match self {
            Self::Image => "image/*",
            Self::Audio => "audio/*",
            Self::Video => "video/*",
        }
    }

    /// The family a media type's first segment names, if it is one of these.
    ///
    /// Case-insensitive on the segment, because a media type is
    /// case-insensitive and half the tree writes them lowercase by habit rather
    /// than by rule.
    #[must_use]
    pub fn of_type(media_type: &str) -> Option<Self> {
        let (top, _) = media_type.split_once('/')?;
        [Self::Image, Self::Audio, Self::Video]
            .into_iter()
            .find(|family| {
                family
                    .wildcard()
                    .strip_suffix("/*")
                    .is_some_and(|t| t.eq_ignore_ascii_case(top))
            })
    }
}

/// What a field holds now, as a renderer reads it.
///
/// The one value type every renderer draws from. Absent from a field's
/// description on purpose: the value is renderer state, and a description that
/// carried it would be a second source of truth for what the user typed.
///
/// An enum rather than a bag of optional fields, for the reason [`Depth`] is
/// one: a checkbox holding a string is unsayable here.
///
/// [`Depth`]: crate::Depth
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
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 the field's options. The options
    /// stay the field's, which is what keeps a `Chosen { options, value }`
    /// variant from existing.
    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: an interval submits
    /// under two names, so it comes back as two values, and a delimiter chosen
    /// here could appear inside either. Either end may be empty while the
    /// other stands: "over 120 BPM" is an answer, not 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: the lower end of an
    /// interval, and empty for a checkbox or nothing.
    #[must_use]
    pub const fn as_text(&self) -> &'a str {
        match self {
            Self::Text(text) | Self::Between { lower: text, .. } => text,
            Self::Absent | Self::On(_) => "",
        }
    }

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