Skip to main content

max / makeover-webview

0.15.0: the description carries the placeholder and the options now makeover-layout 0.8.0 took both down into Field, Choice included, so the copies here were the duplicates rather than the originals. Writing this emitter is what found the three gaps in the first place; two of them turned out not to be renderer state at all, and moving them down is what stops the second and third renderers each inventing a near-miss of the same struct. Gone: form::Choice, Filling::placeholder, and the options half of Value::Chosen. That last one collapses Chosen { options, value } into Text, since a select's value is the value of one of its options and nothing else was left in the variant. makeover-immediate reached the same single-variant shape independently, from the other direction. The header's "What the description does not carry" was wrong on two of three items and now names the one that remains: the current value, which stays because a webview reads it back out of the DOM and a description carrying it would have to carry a way to write it back. Two tests added while the seams were open. The placeholder was never covered here, and it is a value in an attribute like any other, so it gets the same escaping check the rest do. A select with no options emits an empty select, which is the description's own position on Field::options. Breaking. Three consumers repin: goingson, MNW server, balanced_breakfast.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 18:56 UTC
Signed with PGP, not checked
Commit: 456bf2fd6f7300fdb0bf4fdebb2cbe95a7b6a100
Parent: 1992536
2 files changed, +60 insertions, -61 deletions
M Cargo.toml +2 -2
@@ -1,13 +1,13 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.14.0"
3 + version = "0.15.0"
4 4 edition = "2024"
5 5 description = "The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser."
6 6 license = "MIT"
7 7 repository = "https://makenot.work/git/max/makeover-webview"
8 8
9 9 [dependencies]
10 - makeover-layout = "0.7"
10 + makeover-layout = "0.8"
11 11 # The capability axis. `makeover-touch` decides whether a hover rule should be
12 12 # gated at all; `makeover-geometry` spells the gate as a media condition. Both
13 13 # answers are owned elsewhere and neither is re-derived here.
M src/form.rs +58 -59
@@ -26,15 +26,22 @@
26 26 //!
27 27 //! # What the description does not carry
28 28 //!
29 - //! [`Field`] describes the field and not its contents, so three things arrive
30 - //! from the renderer side in [`Filling`]: the current value, the options of a
31 - //! select, and the placeholder. The first two are genuinely renderer state. The
32 - //! third is user-facing text and belongs with `label` and `hint` in
33 - //! makeover-layout; it lives here because that crate is published and adding a
34 - //! field to `Field` is a breaking change, not because this is its home.
29 + //! One thing: the **current value**, which arrives in [`Filling`].
30 + //!
31 + //! It used to be three. Writing this emitter is what found them, and the other
32 + //! two turned out not to be renderer state at all — the placeholder is
33 + //! user-facing text that sits with `label` and `hint`, and a select's options
34 + //! are needed by every renderer, which is how each of them ends up inventing a
35 + //! near-miss of the same struct. Both moved down into `makeover-layout` 0.8.0,
36 + //! `Choice` included, and this crate reads them off [`Field`] now.
37 + //!
38 + //! The value stays, and it is not a leftover. A webview reads it back out of
39 + //! the DOM, an immediate-mode renderer writes through a `&mut`, and a terminal
40 + //! keeps an edit buffer; a description carrying it would have to carry a way to
41 + //! write it back, at which point it is a form model.
35 42
36 43 use crate::{Emit, class};
37 - use makeover_layout::{Field, FieldKind};
44 + use makeover_layout::{Choice, Field, FieldKind};
38 45 use std::fmt::Write as _;
39 46
40 47 /// A string that is already markup, and is emitted without escaping.
@@ -47,46 +54,25 @@
47 54 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48 55 pub struct Markup<'a>(pub &'a str);
49 56
50 - /// One option of a select.
51 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 - pub struct Choice<'a> {
53 - /// What is submitted.
54 - pub value: &'a str,
55 - /// What is read.
56 - pub label: &'a str,
57 - }
58 -
59 - impl<'a> Choice<'a> {
60 - /// An option whose submitted value is also its label.
61 - #[must_use]
62 - pub const fn plain(value: &'a str) -> Self {
63 - Self {
64 - value,
65 - label: value,
66 - }
67 - }
68 - }
69 -
70 57 /// What the field currently holds.
71 58 ///
72 59 /// An enum rather than a bag of optional fields, on the same reasoning
73 - /// [`makeover_layout::Depth`] is one: a select with no options and a checkbox
74 - /// with a string value are both unsayable here, where a struct would let them
75 - /// be said and then have to cope.
60 + /// [`makeover_layout::Depth`] is one: a checkbox holding a string is unsayable
61 + /// here, where a struct would let it be said and then have to cope.
76 62 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
77 63 pub enum Value<'a> {
78 64 /// Nothing yet.
79 65 #[default]
80 66 Absent,
81 - /// The value of anything that takes typed text.
67 + /// The value of anything that takes typed text, a select included: what a
68 + /// select holds is the `value` of one of [`Field::options`]'s
69 + /// [`Choice`]s.
70 + ///
71 + /// It carried the options too until makeover-layout 0.8.0 moved them onto
72 + /// the field, which collapsed a `Chosen { options, value }` variant into
73 + /// this one. `makeover-immediate` arrived at the same single-variant shape
74 + /// on its own, from the other direction.
82 75 Text(&'a str),
83 - /// The options of a select, and which of them is current.
84 - Chosen {
85 - /// Every option, in the order they are offered.
86 - options: &'a [Choice<'a>],
87 - /// The current value. Matched against each option's `value`.
88 - value: &'a str,
89 - },
90 76 /// A checkbox, on or off.
91 77 On(bool),
92 78 }
@@ -95,7 +81,7 @@
95 81 /// The value as text, for the kinds that submit one.
96 82 const fn as_text(&self) -> &'a str {
97 83 match self {
98 - Self::Text(text) | Self::Chosen { value: text, .. } => text,
84 + Self::Text(text) => text,
99 85 Self::Absent | Self::On(_) => "",
100 86 }
101 87 }
@@ -106,8 +92,6 @@
106 92 pub struct Filling<'a> {
107 93 /// What the field holds now.
108 94 pub value: Value<'a>,
109 - /// Ghost text shown while the field is empty.
110 - pub placeholder: Option<&'a str>,
111 95 /// Markup appended inside the group, after the hint. Not escaped.
112 96 pub trailing: Option<Markup<'a>>,
113 97 /// Scopes the `id` attributes to one instance of the form.
@@ -130,7 +114,6 @@
130 114 pub const fn of(value: Value<'a>) -> Self {
131 115 Self {
132 116 value,
133 - placeholder: None,
134 117 trailing: None,
135 118 id_prefix: None,
136 119 }
@@ -262,7 +245,7 @@
262 245 let id = filling.id_for(field.name);
263 246 let attrs = control_attributes(field, &id, field.name);
264 247 let field_class = class("field", opts);
265 - let placeholder = filling.placeholder.map_or_else(String::new, |text| {
248 + let placeholder = field.placeholder.map_or_else(String::new, |text| {
266 249 format!(" placeholder=\"{}\"", escape(text))
267 250 });
268 251
@@ -272,12 +255,10 @@
272 255 escape(filling.value.as_text())
273 256 ),
274 257 FieldKind::Select => {
275 - let options = match filling.value {
276 - Value::Chosen { options, value } => options_html(options, value),
277 - // Described as a select and filled as something else. Emitting
278 - // an empty select says so on screen rather than in a log.
279 - _ => String::new(),
280 - };
258 + // A select described with no options emits an empty select, which
259 + // says so on screen rather than in a log. That is the description's
260 + // own position on `Field::options`, not a fallback invented here.
261 + let options = options_html(field.options, filling.value.as_text());
281 262 format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
282 263 }
283 264 FieldKind::Checkbox => {
@@ -519,25 +500,43 @@
519 500 #[test]
520 501 fn a_select_keeps_a_value_no_option_carries() {
521 502 let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
522 - let filling = Filling::of(Value::Chosen {
523 - options: &options,
524 - value: "10",
525 - });
526 - let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
503 + let f = Field::select("title", "Title", &options);
504 + let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
527 505 assert!(html.contains("data-unmatched=\"true\""), "{html}");
528 506 // Selected, so the next save round-trips it rather than writing the
529 507 // first option over the top of it.
530 508 assert!(html.contains("<option value=\"10\" selected"), "{html}");
531 509 }
532 510
511 + #[test]
512 + fn a_select_with_no_options_emits_an_empty_select() {
513 + // The description says a select with no options is sayable, because an
514 + // app whose option list has not loaded has exactly that. Emitting the
515 + // empty select reports it on screen rather than in a log.
516 + let f = Field::select("title", "Title", &[]);
517 + let html = field_html(&f, &Filling::default(), &Emit::default());
518 + assert!(html.contains("<select"), "{html}");
519 + assert!(!html.contains("<option"), "{html}");
520 + }
521 +
522 + #[test]
523 + fn a_placeholder_comes_off_the_description_and_is_escaped() {
524 + // It arrived in `Filling` until makeover-layout 0.8.0 and was never
525 + // covered here; it is a value in an attribute like any other.
526 + let f = Field {
527 + placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
528 + ..field(FieldKind::Text)
529 + };
530 + let html = field_html(&f, &Filling::default(), &Emit::default());
531 + assert!(html.contains("placeholder=\""), "{html}");
532 + assert!(!html.contains("\" onfocus"), "{html}");
533 + }
534 +
533 535 #[test]
534 536 fn a_select_marks_the_option_that_matches() {
535 537 let options = [Choice::plain("1"), Choice::plain("3")];
536 - let filling = Filling::of(Value::Chosen {
537 - options: &options,
538 - value: "3",
539 - });
540 - let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
538 + let f = Field::select("title", "Title", &options);
539 + let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
541 540 assert!(
542 541 html.contains("<option value=\"3\" selected>3</option>"),
543 542 "{html}"