Skip to main content

max / makeover-layout

0.8.0: Field carries its placeholder and its options Both were arriving from the renderer side. Writing makeover-webview's form emitter found three things Field could not say, and the emitter supplied all three itself; two of them were never renderer state and are here now. The placeholder is user-facing text sitting beside label and hint, and it was outside only because adding a field to a published struct is breaking. The options move because every renderer needs them and each was going to invent its own shape; Choice is makeover-webview's, taken unchanged rather than redesigned. The current value stays renderer-side and is not coming. That is the one of the three that is genuinely state: a webview reads it out of the DOM, an immediate-mode renderer writes through a &mut, and a description carrying it would have to carry a way to write it back. Field's own docs now record where the line falls, so the third renderer does not re-ask.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 18:49 UTC
Signed with PGP, not checked
Commit: f3cb7debc9711cce244f7176aebb5f1d699a6dd2
Parent: 93547ae
2 files changed, +160 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.7.0"
3 + version = "0.8.0"
4 4 edition = "2024"
5 5 description = "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."
6 6 license = "MIT"
M src/lib.rs +159
@@ -64,6 +64,23 @@
64 64 //! deliberately two members wide, because hover and pressed belong where they
65 65 //! already are. [`State`]'s own docs carry that argument.
66 66 //!
67 + //! 0.8.0 finishes [`Field`], which described a field well enough to label it and
68 + //! not well enough to draw it. Writing `makeover-webview`'s form emitter found
69 + //! three things missing and the renderer supplied all three from outside: the
70 + //! current value, a select's options, and the placeholder. Two of those move
71 + //! here and one does not.
72 + //!
73 + //! - [`Field::placeholder`] is user-facing text sitting beside `label` and
74 + //! `hint`. There was never a reading on which it was renderer state; it was
75 + //! outside only because adding a field to a published struct is breaking.
76 + //! - [`Field::options`] moves because every renderer needs them and each was
77 + //! going to invent its own shape. [`Choice`] is the shape `makeover-webview`
78 + //! already arrived at, taken as-is rather than redesigned.
79 + //! - The current value stays renderer-side and is not coming here. It is the
80 + //! one of the three that is genuinely state: a webview reads it out of the
81 + //! DOM, an immediate-mode renderer holds a `&mut` to the app's own field, and
82 + //! a description that carried it would be a form model.
83 + //!
67 84 //! # Where the description stops
68 85 //!
69 86 //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
@@ -858,10 +875,58 @@
858 875 }
859 876 }
860 877
878 + /// One option a [`FieldKind::Select`] offers.
879 + ///
880 + /// Two strings, because the submitted value and the read label are different
881 + /// facts and every renderer that has tried to collapse them has had to
882 + /// un-collapse them later. `makeover-webview` invented this shape writing its
883 + /// form emitter and it is taken here unchanged; moving it down rather than
884 + /// re-deriving it is the point, since the second and third renderers were each
885 + /// going to arrive at a near-miss of it.
886 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
887 + pub struct Choice<'a> {
888 + /// What is submitted.
889 + pub value: &'a str,
890 + /// What is read.
891 + pub label: &'a str,
892 + }
893 +
894 + impl<'a> Choice<'a> {
895 + /// An option whose submitted value is also its label.
896 + #[must_use]
897 + pub const fn plain(value: &'a str) -> Self {
898 + Self {
899 + value,
900 + label: value,
901 + }
902 + }
903 + }
904 +
861 905 /// One field of a form.
862 906 ///
863 907 /// Borrowed rather than owned: a description is built, read once by a renderer,
864 908 /// and dropped. Nothing here outlives the screen it describes.
909 + ///
910 + /// # What it carries, and what it does not
911 + ///
912 + /// Stated here so the next renderer does not re-ask, which is what the first
913 + /// two both did. It carries everything a renderer needs to *draw* the field:
914 + /// its kind, what it is called, what it is asked for, its standing help, what
915 + /// is wrong with it now, whether it is compulsory, whether it hides behind a
916 + /// disclosure, its ghost text, and the options it offers.
917 + ///
918 + /// It does not carry the **current value**, and it is not going to. That is the
919 + /// one thing here that is genuinely renderer state: a webview reads it back out
920 + /// of the DOM, an immediate-mode renderer holds a `&mut` to the app's own field
921 + /// and writes through it, and a terminal keeps an edit buffer. A description
922 + /// that carried the value would have to carry a way to write it back, at which
923 + /// point it is a form model and no longer a description.
924 + ///
925 + /// **Validation** is absent for the reason [`FieldKind`] records: [`error`] is
926 + /// the *result* of validating, supplied by whoever validated. Nothing here
927 + /// decides that a value is wrong.
928 + ///
929 + /// [`error`]: Field::error
865 930 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
866 931 pub struct Field<'a> {
867 932 /// What kind of value it takes.
@@ -874,6 +939,28 @@
874 939 pub hint: Option<&'a str>,
875 940 /// What is currently wrong with the value.
876 941 pub error: Option<&'a str>,
942 + /// Ghost text shown while the field is empty.
943 + ///
944 + /// User-facing text, and it sits with `label` and `hint` rather than with
945 + /// the value because it is a property of the *question* and not of the
946 + /// answer. It lived renderer-side in `makeover-webview` until 0.8.0 for one
947 + /// reason and it was not a reading on where it belonged: adding a field to
948 + /// a published struct is a breaking change.
949 + ///
950 + /// Not a substitute for a label. A field labelled only by its placeholder
951 + /// loses its label the moment anything is typed, and no renderer here can
952 + /// make that not happen, so the description keeps both.
953 + pub placeholder: Option<&'a str>,
954 + /// The options offered, in the order they are offered.
955 + ///
956 + /// Empty for every kind but [`FieldKind::Select`]. A select described with
957 + /// no options is sayable on purpose: it is what an app with an
958 + /// unfinished-loading option list actually has, and a renderer showing an
959 + /// empty select says so on screen rather than in a log.
960 + ///
961 + /// Which option is *current* is not here. That is the value, and the value
962 + /// is renderer state.
963 + pub options: &'a [Choice<'a>],
877 964 /// Whether the form refuses to submit without it.
878 965 pub required: bool,
879 966 /// Whether the field lives behind a "more options" disclosure.
@@ -890,6 +977,28 @@
890 977 label,
891 978 hint: None,
892 979 error: None,
980 + placeholder: None,
981 + options: &[],
982 + required: false,
983 + extended: false,
984 + }
985 + }
986 +
987 + /// A select offering the given options.
988 + ///
989 + /// The one kind that is under-described by [`Field::new`], so it gets a
990 + /// constructor rather than leaving every call site to remember that a
991 + /// select with an empty `options` renders as an empty select.
992 + #[must_use]
993 + pub const fn select(name: &'a str, label: &'a str, options: &'a [Choice<'a>]) -> Self {
994 + Self {
995 + kind: FieldKind::Select,
996 + name,
997 + label,
998 + hint: None,
999 + error: None,
1000 + placeholder: None,
1001 + options,
893 1002 required: false,
894 1003 extended: false,
895 1004 }
@@ -1278,6 +1387,56 @@
1278 1387 assert!(!FieldKind::Text.labels_itself());
1279 1388 }
1280 1389
1390 + #[test]
1391 + fn a_plain_field_offers_nothing_and_a_select_offers_its_options() {
1392 + let text = Field::new(FieldKind::Text, "title", "Title");
1393 + assert!(text.options.is_empty());
1394 + assert_eq!(text.placeholder, None);
1395 +
1396 + let sizes = [Choice::plain("small"), Choice::plain("large")];
1397 + let select = Field::select("size", "Size", &sizes);
1398 + assert_eq!(select.kind, FieldKind::Select);
1399 + assert_eq!(select.options.len(), 2);
1400 + }
1401 +
1402 + #[test]
1403 + fn a_choice_says_what_submits_and_what_is_read_apart() {
1404 + // The whole reason it is two strings. `plain` is the case where they
1405 + // coincide, and it is a shorthand rather than the general shape.
1406 + let plain = Choice::plain("7");
1407 + assert_eq!((plain.value, plain.label), ("7", "7"));
1408 +
1409 + let spelled = Choice {
1410 + value: "7",
1411 + label: "One week",
1412 + };
1413 + assert_ne!(spelled.value, spelled.label);
1414 + }
1415 +
1416 + #[test]
1417 + fn a_select_with_no_options_is_sayable() {
1418 + // An app whose option list has not loaded has exactly this. Making it
1419 + // unrepresentable would push the state somewhere less visible, and a
1420 + // renderer drawing an empty select reports it on screen.
1421 + let loading = Field::select("project", "Project", &[]);
1422 + assert!(loading.options.is_empty());
1423 + }
1424 +
1425 + #[test]
1426 + fn the_description_carries_the_question_and_never_the_answer() {
1427 + // The line 0.8.0 drew. Placeholder and options are properties of what
1428 + // is being asked; the current value is what came back, and no field
1429 + // here holds one.
1430 + let f = Field {
1431 + placeholder: Some("yyyy-mm-dd"),
1432 + ..Field::new(FieldKind::Text, "due", "Due")
1433 + };
1434 + assert_eq!(f.placeholder, Some("yyyy-mm-dd"));
1435 + // A placeholder is not a label, and having one does not excuse the
1436 + // field from carrying the other.
1437 + assert_eq!(f.label, "Due");
1438 + }
1439 +
1281 1440 #[test]
1282 1441 fn a_field_reports_its_own_error_state() {
1283 1442 let mut f = Field::new(FieldKind::Text, "title", "Title");