makeover-layout 0.45.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.
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
use crate::{Choice, Curve, ThemeChoice, Tone};

// Names this module's prose links to, resolved for rustdoc.
#[allow(unused_imports)]
use crate::{Awaiting, Contrast, 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 [`min`](Field::min) and
    /// [`max`](Field::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
    ///
    /// [`min`](Field::min) and [`max`](Field::max) are `Option` for every other
    /// kind and are **required** here, in the sense the description can require
    /// anything: [`Field::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.
    ///
    /// [`Field::step`] is the third fact and is genuinely optional: absent, the
    /// host's own granularity 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 [`Field::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
    ///
    /// [`Field::name`] is the lower end and [`Field::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 an out-of-[`min`](Field::min) number 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`](Field::min), [`max`](Field::max), [`step`](Field::step) and
    /// [`unit`](Field::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.
    ///
    /// [`Field::min`] and [`Field::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,
    /// 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 | [`Field::accept`] |
    /// | one file or several | [`Field::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.
    ///
    /// [`Field::accept`] and [`Field::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. Max rejected that: `optgroup` appears at one live
    /// site in the tree and the non-theme grouping count is zero, so the thing
    /// that recurs is this picker rather than option lists that group.
    ///
    /// # What it carries that a select cannot
    ///
    /// [`Field::themes`] rather than [`Field::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. `Choice::new(id, format!("{name} ({variant})"))` is what
    /// the three apps had, and it flattens the group into prose and loses the
    /// tier entirely.
    ///
    /// [`Field::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
    /// [`Field::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,
    /// Carried through the form and never shown.
    Hidden,
}

/// 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 [`Field::options`].
    ///
    /// Two kinds do, so the pair is named once here rather than spelled out at
    /// each renderer and again in [`Field::options`]' own doc, where "every
    /// kind but `Select`" was true for exactly one release. A third
    /// option-taking kind should land here and nowhere else.
    #[must_use]
    pub const fn offers_options(self) -> bool {
        matches!(self, Self::Select | Self::Radio)
    }

    /// Whether the kind reads [`Field::themes`] and [`Field::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
    /// [`Choice`]es 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 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 [`Field::accept`] or [`Field::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 [`Field::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 [`kind`](Field::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.
/// [`Field::accept`] 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('/')?;
        if top.eq_ignore_ascii_case("image") {
            Some(Self::Image)
        } else if top.eq_ignore_ascii_case("audio") {
            Some(Self::Audio)
        } else if top.eq_ignore_ascii_case("video") {
            Some(Self::Video)
        } else {
            None
        }
    }
}

/// One entry in a file field's accept list.
///
/// Three shapes rather than a string, and all three are in the measured sites:
/// the MNW server writes `image/*`, `image/jpeg,image/png,image/webp`,
/// `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` and, in one place,
/// `.csv,text/csv`. A single string would carry all of them and answer nothing
/// about any of them.
///
/// # Why the list is not just a filter
///
/// It is read twice. Once to decide what the picker offers, which any of the
/// three shapes serves, and once to decide **which disclosure** the field gets:
/// a preview for a picture, a duration or a waveform for a sound. There is one
/// upload shape and a media upload is that shape with more of it shown, so the
/// accept list is what says which more. [`family`](Self::family) is that
/// question answered once here instead of a media-type parser in each renderer.
///
/// # A suffix names no family, on purpose
///
/// `.mp3` is audio in fact, and nothing here says so. A suffix-to-family table
/// in a published crate is a mapping that goes stale, disagrees with the host's
/// own idea of what a file is, and is wrong the first time somebody hands it a
/// container. A call site that wants a picture's preview writes
/// [`Family::Image`] or `image/jpeg`; a call site listing installer suffixes
/// wants no disclosure anyway, which is the measured case.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Accepted<'a> {
    /// Every file of a family: `image/*` and its siblings.
    Family(Family),
    /// One media type, written the way a media type is written:
    /// `image/jpeg`, `text/csv`.
    Type(&'a str),
    /// One file-name suffix, written with its leading dot: `.zip`, `.tar.gz`.
    ///
    /// A suffix and not an extension, because `.tar.gz` is a measured site and
    /// is two dots.
    Suffix(&'a str),
}

impl<'a> Accepted<'a> {
    /// The family this entry belongs to, when it names one.
    ///
    /// [`None`] for a [`Suffix`](Self::Suffix) and for any media type outside
    /// the three families, which is the honest answer rather than a missing
    /// one: the description did not say.
    #[must_use]
    pub fn family(self) -> Option<Family> {
        match self {
            Self::Family(family) => Some(family),
            Self::Type(media_type) => Family::of_type(media_type),
            Self::Suffix(_) => None,
        }
    }

    /// How a host that wants one string writes this entry.
    ///
    /// A webview's `accept` attribute takes exactly these spellings, and a
    /// terminal listing what it will take reads the same words.
    #[must_use]
    pub const fn as_str(self) -> &'a str {
        match self {
            Self::Family(family) => family.wildcard(),
            Self::Type(text) | Self::Suffix(text) => text,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Field<'a> {
    /// What kind of value it takes.
    pub kind: FieldKind,
    /// The name the value is submitted under.
    ///
    /// The *lower* end's name for a [`FieldKind::Interval`], whose upper end is
    /// [`upper_name`](Self::upper_name). Every other kind submits one value and
    /// this is the whole of it.
    pub name: &'a str,
    /// The name a [`FieldKind::Interval`]'s upper end is submitted under.
    ///
    /// [`None`] for every other kind, and sayable-and-ignored there the way
    /// [`options`](Self::options) is on a kind that offers none.
    ///
    /// Stated rather than derived from [`name`](Self::name), and
    /// [`FieldKind::Interval`] carries the measurement that decided it: the two
    /// sites in this tree disagree about affix order, so a derived rule would
    /// rename one of them. Which member a name sits in is also what says which
    /// end it is, so nothing separate carries the direction.
    ///
    /// An interval missing it is an interval with one end that can be submitted,
    /// which is a description a renderer may draw honestly and no better than
    /// that. [`Field::interval`] is what makes forgetting it unsayable, on the
    /// same footing as [`Field::range`] and its bounds.
    pub upper_name: Option<&'a str>,
    /// What the user is asked for.
    pub label: &'a str,
    /// Standing help, shown whether or not anything is wrong.
    pub hint: Option<&'a str>,
    /// What is currently wrong with the value.
    pub error: Option<&'a str>,
    /// A consequence of the answer the user has given, carrying its own tone.
    ///
    /// The third message channel, between [`hint`](Self::hint) and
    /// [`error`](Self::error) and overlapping neither. A hint is standing help
    /// that does not depend on the value; an error says the value is not
    /// acceptable. A note is the case in the middle: the value is perfectly
    /// acceptable and choosing it costs something the user should know about.
    ///
    /// The first consumer is audiofiles' export Format field, where choosing
    /// WAV or AIFF over Original re-encodes and silently drops embedded BWF,
    /// iXML, loop points, cue markers and ID3. That is not a validation
    /// failure and it is not standing help — it is true of one answer to one
    /// question — and it was hand-drawn in the app's own draw callback for
    /// want of anywhere to say it.
    ///
    /// The tone is carried rather than fixed at [`Tone::Warning`] because the
    /// channel is not only for warnings: the same slot says "this is the
    /// recommended one" ([`Tone::Success`]) and "this is what that setting
    /// implies" ([`Tone::Info`]). A renderer gets the announcement behaviour
    /// off the tone for free — makeover-webview emits `data-tone` and treats
    /// Warning and Danger as assertive for `aria-live`.
    ///
    /// It does **not** make the field invalid. [`invalid`](Self::invalid) stays
    /// `error.is_some()`, so a note never marks the group as a problem.
    ///
    /// # Precedence, for a renderer with room for one
    ///
    /// Error, then note, then hint. A renderer that shows every message shows
    /// them in that order too. makeover-tui is the one with room for exactly
    /// one line, and it is why the order is decided here rather than three
    /// times: what is wrong outranks what it costs, which outranks how it
    /// works.
    pub note: Option<(Tone, &'a str)>,
    /// Ghost text shown while the field is empty.
    ///
    /// User-facing text, and it sits with `label` and `hint` rather than with
    /// the value because it is a property of the *question* and not of the
    /// answer.
    ///
    /// Not a substitute for a label. A field labelled only by its placeholder
    /// loses its label the moment anything is typed, and no renderer here can
    /// make that not happen, so the description keeps both.
    pub placeholder: Option<&'a str>,
    /// The options offered, in the order they are offered.
    ///
    /// Empty for every kind [`FieldKind::offers_options`] rejects. A field
    /// described with no options is sayable on purpose: it is what an app with
    /// an unfinished-loading option list actually has, and a renderer showing
    /// an empty control says so on screen rather than in a log.
    ///
    /// Which option is *current* is not here. That is the value, and the value
    /// is renderer state.
    pub options: &'a [Choice<'a>],
    /// The themes offered, in the order they are offered.
    ///
    /// Empty for every kind [`FieldKind::offers_themes`] rejects, and sayable
    /// as empty for the one that accepts it: an app whose theme directories
    /// hold nothing has a picker offering only [`follows`](Self::follows),
    /// which is a true description of that machine.
    ///
    /// **The order is the grouping.** Entries arrive sorted by
    /// [`ThemeVariant`] and then by [`Contrast`] within each variant, so a
    /// renderer that draws headings walks the run of one variant and a renderer
    /// that cannot still gets the useful order. Handing back groups would force
    /// the second renderer to flatten what the first wanted.
    ///
    /// Nothing here sorts. The description carries the order it was given, and
    /// the sort belongs with whoever measured the tiers — `makeover::theme_options`
    /// is what produces it, and re-sorting here would be this crate deciding a
    /// question it cannot see the inputs to.
    ///
    /// Which theme is *current* is not here. That is the value, and the value
    /// is renderer state, exactly as it is for [`options`](Self::options).
    pub themes: &'a [ThemeChoice<'a>],
    /// The entry that follows the ambient mode instead of naming a theme.
    ///
    /// [`None`] for a picker that does not offer one, which is a real answer:
    /// an app whose host has no ambient mode to follow should not offer a row
    /// that does nothing.
    ///
    /// A [`Choice`] rather than a bare label, because the *value* is the app's.
    /// Every store in the family spells it `system` today and none of them is
    /// obliged to; a description that hardcoded the spelling would be this
    /// crate holding a fact about somebody else's config table.
    ///
    /// It is not a [`ThemeChoice`] with an absent variant. Following is a
    /// standing instruction that resolves differently as the desktop flips, and
    /// a theme id is an answer that does not — which is the distinction
    /// `makeover::ThemeSelection` exists to hold, carried here rather than
    /// blurred.
    pub follows: Option<Choice<'a>>,
    /// What a file field takes, in the order a host offering the list shows it.
    ///
    /// Empty for every kind [`FieldKind::takes_files`] rejects, and empty is
    /// also a real answer for one that accepts it: a field that takes any file
    /// says so by listing nothing, which is what an `<input type="file">` with
    /// no `accept` does and what most of the measured sites are.
    ///
    /// It is a filter and it is the disclosure cue, and [`Accepted`]'s doc
    /// carries which reading is which. Nothing here validates: a host may hand
    /// back a file the list does not cover, exactly as a browser does when the
    /// user switches the picker to "All Files", and deciding a value is wrong
    /// stays with whoever validated.
    pub accept: &'a [Accepted<'a>],
    /// Whether more than one file may be picked at once.
    ///
    /// Only [`FieldKind::takes_files`] reads it. A multi-valued answer to any
    /// other question is a different shape — a set of options, a repeated
    /// group — and neither is this flag with a different kind beside it.
    ///
    /// False is the common case: 4 of the MNW server's 16 file inputs carry it.
    pub multiple: bool,
    /// Whether the form refuses to submit without it.
    pub required: bool,
    /// The longest the value may be, in characters.
    pub max_length: Option<u32>,
    /// The lowest value accepted, as the host would write it.
    ///
    /// Text rather than a number, because the bound is only a number for some
    /// of the kinds that take one. goingson's own sites are `min="1"` on a
    /// duration and `min="2026-08-09T14:30"` on a datetime, and a numeric member
    /// could say the first and not the second. The [`kind`](Self::kind) already
    /// says how to read it, the same way it does for the value.
    pub min: Option<&'a str>,
    /// The highest value accepted, as the host would write it. See
    /// [`min`](Self::min).
    pub max: Option<&'a str>,
    /// The granularity the value moves in, as the host would write it.
    ///
    /// Text for [`min`](Self::min)'s reason, and it earns it twice over: the
    /// step of a date is a day and the step of a threshold is 0.01, and a
    /// numeric member could say one of them.
    ///
    /// Absent means the host's own granularity, which is the honest default
    /// rather than a missing value: a webview's `<input>` steps by 1 unless told
    /// otherwise, and that is the browser's rule and not this crate's to
    /// restate.
    ///
    /// # It is the granularity of a *typed* value
    ///
    /// [`FieldKind::Range`] reads its own from [`curve`](Self::curve) and
    /// ignores this. On a slider the granularity and the mapping are one
    /// decision, and on a typed number there is no mapping to decide with. See
    /// [`Curve`], "Why the step is here".
    pub step: Option<&'a str>,
    /// How a slider's position becomes its value, and how finely it moves.
    ///
    /// [`FieldKind::Range`]'s, and nothing else reads it: a typed number has a
    /// granularity but no mapping, and takes [`step`](Self::step) instead.
    ///
    /// Defaults to [`Curve::Linear`] with no step, which is what an
    /// undescribed range means.
    pub curve: Curve<'a>,
    /// What the number is measured in: `s`, `ms`, `dB`, `GiB`.
    ///
    /// A fact about the value, not part of the question's name, and that
    /// distinction is the whole reason it is a member. The two readings come
    /// apart the moment anything reads a field back rather than drawing it: a
    /// [`max`](Self::max) of `-96` and a bound of `-96 dBFS` are the same number
    /// and not the same answer, and under the convention this replaces the unit
    /// could only be recovered by parsing it back out of a label.
    ///
    /// # Where a renderer draws it
    ///
    /// Beside the value, wherever that host puts a value. Not in the label: a
    /// label is the sentence above the control, so unit-in-label reads the same
    /// on every host and is wrong on any host with somewhere better. egui puts
    /// it inside
    /// the slider where the readout already is, a terminal appends it to the
    /// value in the edit line, a webview sets it adjacent to the input.
    ///
    /// # Which kinds read it
    ///
    /// [`FieldKind::measurable`] answers, and it is
    /// [`takes_files`](FieldKind::takes_files)'s footing: three renderers ask
    /// before they can decide whether to draw this, and a `matches!` per
    /// renderer is where the next measurable kind goes missing. A unit on a kind
    /// that rejects it is sayable and ignored, the same way
    /// [`options`](Self::options) is on a kind that offers none.
    ///
    /// # Why a string
    ///
    /// The measured sites are `GiB`, `dBFS`, `s` and `ms`. An enum would have to
    /// grow a member for every unit any consumer ever wants, and this crate does
    /// not know them; it knows that a number has one.
    ///
    /// Written as the symbol alone, with no brackets and no leading space. The
    /// spacing is the renderer's, because a slider's readout and a sentence want
    /// different answers.
    pub unit: Option<&'a str>,
    /// Whether the field lives behind a "more options" disclosure.
    pub extended: bool,
    /// Whether this local wall-clock value is submitted as an absolute instant.
    ///
    /// [`FieldKind::DateTime`] asks for a time the way a person says one --
    /// "the 14th at half past two" -- and that names a different moment in
    /// Denver than it does in Berlin. A route that stores an instant needs the
    /// moment, so somebody has to convert. This member says the description
    /// wants that conversion; it does not say how.
    ///
    /// # The conversion belongs to the renderer
    ///
    /// Because the renderer is the only party that knows what "your computer's
    /// time zone" means for its host. A browser has one and the user is sitting
    /// in it; a TUI reads the host clock; an egui app reads the same clock a
    /// different way. Nothing above the renderer can answer it, and the
    /// alternatives all try: a hidden IANA-zone field needs a host capability
    /// for reading the zone that three hosts answer differently, plus a kind
    /// that does not exist, plus a wire-contract change; a timezone on the
    /// user's profile is a product decision wearing a bug's clothes. Say it
    /// here, and the next reader does not propose them again.
    ///
    /// # What a renderer does
    ///
    /// Draws the same control it always did -- the flag changes what is
    /// *submitted*, not what is shown -- and converts the local value to an
    /// absolute instant on the way out. A renderer that cannot convert submits
    /// the local value unchanged, which is what every renderer did before this
    /// existed.
    ///
    /// No wire contract moves when a site adopts it: the route was already
    /// receiving an instant. What changes is who computed it.
    ///
    /// # Which kinds read it
    ///
    /// [`FieldKind::DateTime`]'s. `Date` and `Time` are each half a moment and
    /// cannot name one on their own, so the flag is sayable and ignored there,
    /// the way [`options`](Self::options) is on a kind that offers none.
    pub as_instant: bool,
}

impl<'a> Field<'a> {
    /// A plain required-nothing field of the given kind.
    #[must_use]
    pub const fn new(kind: FieldKind, name: &'a str, label: &'a str) -> Self {
        Self {
            kind,
            name,
            upper_name: None,
            label,
            hint: None,
            error: None,
            note: None,
            placeholder: None,
            options: &[],
            themes: &[],
            follows: None,
            accept: &[],
            multiple: false,
            required: false,
            max_length: None,
            min: None,
            max: None,
            step: None,
            curve: Curve::Linear { step: None },
            unit: None,
            extended: false,
            as_instant: false,
        }
    }

    /// A bounded number the user drags across its whole extent.
    ///
    /// The third under-described kind, and it gets a constructor for
    /// [`select`](Self::select)'s reason: a range is the one kind whose bounds
    /// are not a rule but the control itself, so a call site that forgot them
    /// has a slider with nothing to slide across. Taking them as arguments is
    /// what makes that unsayable.
    ///
    /// The granularity stays a field rather than a fourth argument, and it is
    /// [`curve`](Self::curve)'s: it is genuinely optional, since the host's own
    /// is a real answer, and the two bounds are not.
    #[must_use]
    pub const fn range(name: &'a str, label: &'a str, min: &'a str, max: &'a str) -> Self {
        Self {
            min: Some(min),
            max: Some(max),
            ..Self::new(FieldKind::Range, name, label)
        }
    }

    /// One question with two ends, taking the name each end submits under.
    ///
    /// A constructor for [`range`](Self::range)'s reason inverted: a range's
    /// bounds are what a call site cannot forget, and an interval's second name
    /// is. An interval built through [`new`](Self::new) has an upper end with
    /// nowhere to be submitted, and nothing downstream can invent one, so taking
    /// it as an argument is what makes that unsayable.
    ///
    /// The extent, the granularity and the unit stay members. They describe the
    /// axis rather than either end and they are genuinely optional, which is
    /// [`FieldKind::Number`]'s arrangement and the one an interval takes.
    #[must_use]
    pub const fn interval(name: &'a str, upper_name: &'a str, label: &'a str) -> Self {
        Self {
            upper_name: Some(upper_name),
            ..Self::new(FieldKind::Interval, name, label)
        }
    }

    /// A file field, taking the given accept list.
    ///
    /// The fourth under-described kind and it gets a constructor for
    /// [`range`](Self::range)'s reason rather than [`select`](Self::select)'s:
    /// a file field with no accept list is not broken, it is a field that takes
    /// anything, and the hazard is the opposite one. A call site that meant to
    /// restrict and forgot has a picker offering every file on the machine and
    /// a server refusing the upload afterwards, which is the failure the list
    /// exists to move forward. Taking it as an argument is what makes an
    /// accidental omission a deliberate `&[]`.
    ///
    /// [`multiple`](Self::multiple) stays a field. One file is the common case
    /// and the honest default; several is the thing worth saying.
    #[must_use]
    pub const fn upload(name: &'a str, label: &'a str, accept: &'a [Accepted<'a>]) -> Self {
        Self {
            accept,
            ..Self::new(FieldKind::File, name, label)
        }
    }

    /// A select offering the given options.
    ///
    /// One of the two kinds under-described by [`Field::new`], so it gets a
    /// constructor rather than leaving every call site to remember that a
    /// select with an empty `options` renders as an empty select.
    #[must_use]
    pub const fn select(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
        Self::offering(FieldKind::Select, name, label, options)
    }

    /// A radio group offering the given options.
    ///
    /// The other. Same hazard as [`select`](Self::select) and a worse one: a
    /// radio group with no options draws nothing at all, so a call site that
    /// forgot them has an empty rectangle rather than a visibly empty control.
    #[must_use]
    pub const fn radio(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
        Self::offering(FieldKind::Radio, name, label, options)
    }

    /// A theme picker over the themes the host resolved.
    ///
    /// A constructor for [`select`](Self::select)'s reason and one of its own.
    /// The shared reason: a theme picker built through [`new`](Self::new) has
    /// an empty [`themes`](Self::themes) list and draws an empty control. Its
    /// own: the list is the *only* thing this kind takes that a call site
    /// cannot get wrong by omission and can get wrong by substitution, since
    /// [`options`](Self::options) is right there and reads as if it would work.
    ///
    /// [`following`](Self::following) is the builder rather than a fourth
    /// argument, because a picker with no follow-the-system row is a real
    /// picker and every renderer draws it honestly.
    #[must_use]
    pub const fn theme(name: &'a str, label: &'a str, themes: &'a [ThemeChoice<'a>]) -> Self {
        Self {
            themes,
            ..Self::new(FieldKind::Theme, name, label)
        }
    }

    /// The same picker, offering a row that tracks the ambient mode.
    ///
    /// The [`Choice`] carries the value the app's own store spells it with.
    #[must_use]
    pub const fn following(mut self, follow: Choice<'a>) -> Self {
        self.follows = Some(follow);
        self
    }

    /// The shared body of the two constructors that take options.
    ///
    /// Private, and keyed on the kind rather than exposed, because the two
    /// public names are the point: a call site says which question it is
    /// asking, not which flag it is setting.
    const fn offering(
        kind: FieldKind,
        name: &'a str,
        label: &'a str,
        options: &'a [Choice<'a>],
    ) -> Self {
        Self {
            options,
            ..Self::new(kind, name, label)
        }
    }

    /// Whether the field is currently reporting a problem.
    ///
    /// Read this rather than testing `error.is_some()` at each renderer: the
    /// error state has to mark the field's whole group and not only the
    /// message, because a renderer with no descendant selectors (egui, a
    /// terminal) cannot find the group from the message. goingson already marks
    /// the group and Balanced Breakfast does not, so goingson's shape is the
    /// one taken here.
    ///
    /// [`note`](Self::note) is deliberately not consulted. A note says the
    /// answer costs something, not that it is unacceptable, and a field the
    /// user may submit as it stands is not invalid.
    #[must_use]
    pub const fn invalid(&self) -> bool {
        self.error.is_some()
    }

    /// Whether the field carries both ends of its extent.
    ///
    /// Only [`FieldKind::Range`] owes them, and it owes them absolutely: a
    /// slider with one end missing has no extent to draw. Named here rather
    /// than left to each renderer to test `min.is_some() && max.is_some()`,
    /// which is three renderers arriving at the same condition and one of them
    /// getting it wrong, and named as a question about the *field* rather than
    /// about the kind because the kind cannot see the bounds.
    ///
    /// It is a check and not a guarantee. Nothing here refuses to build an
    /// unbounded range — [`Field::range`] is what makes the bounded one easy —
    /// so a renderer asks this and falls back to whatever its host does
    /// honestly with a number.
    #[must_use]
    pub const fn bounded(&self) -> bool {
        self.min.is_some() && self.max.is_some()
    }

    /// Whether anything in [`accept`](Self::accept) names a media family.
    ///
    /// The question a renderer asks before it decides to keep room for a
    /// preview, and it is deliberately the *whole list* rather than one entry:
    /// the media dropzone this was measured against takes `image/*,video/*`, so
    /// there is no single family to return and there is still a disclosure to
    /// offer. Which one it turns out to be is known once a file is picked, which
    /// is renderer-side and after the description is gone.
    ///
    /// False for an empty list, for a list of suffixes, and for `text/csv`. A
    /// renderer that wants the family of a particular entry reads
    /// [`Accepted::family`].
    #[must_use]
    pub fn accepts_media(&self) -> bool {
        self.accept.iter().any(|one| one.family().is_some())
    }
}