Skip to main content

max / makeover-layout

0.35.0: a suggestion carries a second line `Candidate { value, label, detail }`, the entry in a field's suggestion list. Ruled by Max 2026-08-21 (`1fcf2e9b`): a suggestion-only type rather than a fourth member on `Choice`. The two types are near-identical and the ruling accepted that drift risk, so the mitigation is written on the type: an option and a candidate are submitted the same way and read differently. An option is picked out of a set the user can see whole; a candidate is offered out of a set nobody can see, which is why it carries the line that orients it and an option does not. Both measured sites draw that second string today by hand, in a second span, and one of them is load-bearing rather than decoration: the MNW tag box computes its context as the parent path, because "Format" is a leaf under audio, software, writing and video and four identical rows are not a list. `#[non_exhaustive]` from birth. `Choice` took it at 0.28.0 only after `unavailable` broke 40 literal sites in six repos, and a new type repeating that would be the third time the tree learned it. No `unavailable`: a suggestion that cannot be picked is a row a route should not have offered. Add it if a measured site ever wants one. What is not here: `picks`. What happens when a candidate is picked is an `Action`, and `Action` is not a word this crate has, exactly as `Field` here has no `suggests`. Both live on the router's owned mirror, per the same-day ruling on `ed1fa86f`.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 02:54 UTC
Signed with PGP, not checked
Commit: 3ca81286b3fef72caf9d431d6c241e4bb3a929f7
Parent: c52d33c
2 files changed, +159 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.34.0"
3 + version = "0.35.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +158
@@ -382,6 +382,29 @@
382 382 //! that one page: audiofiles' library browser and goingson's filters are the
383 383 //! same shape.
384 384 //!
385 + //! 0.35.0 gives a suggestion its second line. [`Candidate`] is the entry in a
386 + //! field's suggestion list: a value, a label, and the [`detail`] that orients
387 + //! it. Ruled by Max 2026-08-21 (`1fcf2e9b`) after the combobox member shipped
388 + //! and was then held against the two sites it was designed from, which is an
389 + //! order worth not repeating.
390 + //!
391 + //! - **A candidate is not a [`Choice`], and the difference is in the reading
392 + //! rather than the writing.** Both submit one string and read as another. An
393 + //! option is picked out of a set the user can see whole; a candidate is
394 + //! offered out of a set nobody can see, so it has to say what tells it from
395 + //! its neighbours. Both measured sites draw that second string today, by
396 + //! hand, in a second span.
397 + //! - **It is its own type rather than a member on [`Choice`].** `Choice` is the
398 + //! most-consumed struct in the vocabulary and the member would have landed on
399 + //! every option list in the tree the day it shipped, including the ones with
400 + //! nowhere to draw it.
401 + //! - **[`Candidate`] is `#[non_exhaustive]` from birth**, which is the whole of
402 + //! what 0.28.0 cost and is not being paid twice.
403 + //! - No `unavailable`. A suggestion that cannot be picked is a row a route
404 + //! should not have offered.
405 + //!
406 + //! [`detail`]: Candidate::detail
407 + //!
385 408 //! 0.34.0 gives an interval a description. [`FieldKind::Interval`] and
386 409 //! [`Field::upper_name`] say that two values are one question with two ends.
387 410 //! Ruled by Max 2026-08-21 against audiofiles' six filter axes and the MNW
@@ -3844,6 +3867,104 @@
3844 3867 }
3845 3868 }
3846 3869
3870 + /// One entry in a field's suggestion list.
3871 + ///
3872 + /// A suggestion-only type rather than a fourth member on [`Choice`], ruled by
3873 + /// Max 2026-08-21 (`1fcf2e9b`). The two are near-identical and that is the
3874 + /// drift risk the ruling accepted, so the mitigation is written here: **an
3875 + /// option and a candidate are submitted the same way and read differently.**
3876 + /// An option is a thing you pick from a known set, and the set is the whole of
3877 + /// what there is. A candidate is a thing you are being *oriented* toward out of
3878 + /// a set nobody can see, which is why it carries [`detail`](Self::detail) and
3879 + /// an option does not.
3880 + ///
3881 + /// This reverses a position quasi-router stated in its own doc, that a
3882 + /// candidate is [`Choice`] "because a candidate is submitted under one string
3883 + /// and read under another, which is what an option is". True and not
3884 + /// sufficient: how a thing is submitted was never the half that differed.
3885 + ///
3886 + /// # Why the second string is not folded into the label
3887 + ///
3888 + /// Because every renderer wants it separately, and the two measured sites both
3889 + /// draw it by hand today. The MNW server's tag box computes its context as the
3890 + /// parent path -- "the parent path orients an otherwise ambiguous leaf:
3891 + /// 'Format' appears under audio, software, writing, and video" -- and a list of
3892 + /// four identical rows reading "Format" is not a usable list. In a webview the
3893 + /// second string is styled differently, in a terminal it wants the remaining
3894 + /// columns rather than a dash, and in neither is it part of what the typed
3895 + /// value matches against. `Choice::new(slug, format!("{label} - {context}"))`
3896 + /// loses all three of those facts, which is the condition this type exists to
3897 + /// end.
3898 + ///
3899 + /// # No `unavailable`
3900 + ///
3901 + /// [`Choice::unavailable`] has no counterpart here, and the omission is the
3902 + /// implementer's call recorded rather than an oversight. A suggestion that
3903 + /// cannot be picked is arguably not a suggestion: an option list is a fixed set
3904 + /// a user is owed an explanation about, and a candidate list is whatever a
3905 + /// route decided to offer, so a route with nothing to say simply does not offer
3906 + /// the row. Add it if a measured site ever wants it.
3907 + ///
3908 + /// # What it does not carry, and where that lives
3909 + ///
3910 + /// What *happens* when a candidate is picked. Picking is local by default -- it
3911 + /// writes [`value`](Self::value) into the field that owns the list -- and a
3912 + /// candidate that does something else says so with an action. An action is not
3913 + /// a word this crate has, exactly as [`Field`] here has no `suggests` member,
3914 + /// so both live on the router's owned mirror of this type. Ruled the same day
3915 + /// (`ed1fa86f`).
3916 + ///
3917 + /// `#[non_exhaustive]` from birth. Non-negotiable and the reason is on the
3918 + /// sibling: [`Choice`] took it at 0.28.0 only after `unavailable` broke 40
3919 + /// literal sites in six repos, and a new type repeating that would be the third
3920 + /// time the tree learned it.
3921 + ///
3922 + /// Added 0.35.0.
3923 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3924 + #[non_exhaustive]
3925 + pub struct Candidate<'a> {
3926 + /// What is submitted, and what picking writes into the field.
3927 + pub value: &'a str,
3928 + /// What is read.
3929 + pub label: &'a str,
3930 + /// The second line: what orients this candidate among rows that read alike.
3931 + ///
3932 + /// Optional because a candidate list whose labels are already distinct
3933 + /// wants nothing here, and a renderer given [`None`] draws one line rather
3934 + /// than an empty second one.
3935 + pub detail: Option<&'a str>,
3936 + }
3937 +
3938 + impl<'a> Candidate<'a> {
3939 + /// A candidate whose submitted value is also its label.
3940 + #[must_use]
3941 + pub const fn plain(value: &'a str) -> Self {
3942 + Self::new(value, value)
3943 + }
3944 +
3945 + /// A candidate that submits one string and reads as another.
3946 + ///
3947 + /// A constructor rather than a literal, which is what `#[non_exhaustive]`
3948 + /// costs and buys: outside this crate the struct cannot be built by naming
3949 + /// its members, so every call site goes through here and the next member
3950 + /// added breaks none of them.
3951 + #[must_use]
3952 + pub const fn new(value: &'a str, label: &'a str) -> Self {
3953 + Self {
3954 + value,
3955 + label,
3956 + detail: None,
3957 + }
3958 + }
3959 +
3960 + /// The same candidate, with the line that tells it from its neighbours.
3961 + #[must_use]
3962 + pub const fn detailed(mut self, detail: &'a str) -> Self {
3963 + self.detail = Some(detail);
3964 + self
3965 + }
3966 + }
3967 +
3847 3968 /// One field of a form.
3848 3969 ///
3849 3970 /// Borrowed rather than owned: a description is built, read once by a renderer,
@@ -6230,6 +6351,43 @@
6230 6351 );
6231 6352 }
6232 6353
6354 + #[test]
6355 + fn a_candidate_carries_the_line_that_tells_it_from_its_neighbours() {
6356 + // The gap this type was born for: two candidates whose labels read
6357 + // alike, told apart by the second string and by nothing else. The
6358 + // measured site is the MNW tag box, where "Format" is a leaf under
6359 + // audio, software, writing and video.
6360 + let audio = Candidate::new("audio/format", "Format").detailed("Audio");
6361 + let writing = Candidate::new("writing/format", "Format").detailed("Writing");
6362 +
6363 + assert_eq!(audio.label, writing.label);
6364 + assert_ne!(audio.detail, writing.detail);
6365 + assert_ne!(
6366 + audio, writing,
6367 + "two rows a user cannot tell apart are two rows the type can"
6368 + );
6369 + }
6370 +
6371 + #[test]
6372 + fn a_candidate_is_read_differently_from_an_option_and_written_the_same() {
6373 + // The ruling's own distinction, held as a test so the two types do not
6374 + // drift back together. Submitting is identical; the second line is the
6375 + // whole of what differs, and it is absent by default because a list of
6376 + // distinct labels wants nothing there.
6377 + let candidate = Candidate::plain("rust");
6378 + assert_eq!((candidate.value, candidate.label), ("rust", "rust"));
6379 + assert_eq!(
6380 + candidate.detail, None,
6381 + "one line unless the route says otherwise"
6382 + );
6383 +
6384 + let option = Choice::plain("rust");
6385 + assert_eq!(
6386 + (candidate.value, candidate.label),
6387 + (option.value, option.label)
6388 + );
6389 + }
6390 +
6233 6391 #[test]
6234 6392 fn a_radio_asks_the_same_question_as_a_select_and_is_not_the_same_kind() {
6235 6393 // Both offer a fixed set and both read `options`, so the two