Skip to main content

max / makeover-webview

0.52.0: a markdown field gets the preview it was permitted Task 643fd96f. data-format="markdown" had one writer and no readers: no rule, no markup, no selector anywhere in Libraries/, quasi/ or MNW/. So a described Rich field was a plain textarea carrying an attribute, and MNW converting its four hand-written section editors onto the member would have LOST the Write/Preview pair partial-item-text-editor.html already draws. A permission taken and not spent turns every conversion into a regression, which is why this blocked the conversion task rather than sitting beside it. The chrome is a wrapper carrying the mark, a two-segment mode row and a preview pane. The pair is spelled as Selector::Segmented rather than as a name of its own, so it takes the depth, the focus ring and the chosen state from rules that already exist; the pane is a well, because it stands where the control stood. Every rule gates on [data-format="markdown"], which is what the attribute was for -- a class-keyed gate would be prefixed away from the enhancement that selects on it. Nothing here renders markdown. The pane arrives empty and is filled by whoever binds the editor, so the sanitising stays exactly where it is: MNW renders through docengine over ammonia and holds an allowlist beside it, and a converter in this crate would move that guarantee into a place with no view of the host's content-security posture. That is the inherited question answered rather than carried. The enhancement runs in showing_rules' direction: nothing is hidden and no control is shown until something sets data-ready on the wrapper. A reader with no script gets the textarea alone, which is what 0.50.0 emitted, so this adds an affordance and takes none away.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-20 15:03 UTC
Signed with PGP, not checked
Commit: 4bc15ffb20e23f857691119a663d6c7fde55a878
Parent: 55f7b37
3 files changed, +293 insertions, -4 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.51.0"
3 + version = "0.52.0"
4 4 edition = "2024"
5 5 # One copy of this renderer per dependency graph, enforced by cargo rather than
6 6 # by remembering. Two versions means the generated stylesheet and the emitted
M src/form.rs +270 -3
@@ -40,8 +40,8 @@
40 40 //! keeps an edit buffer; a description carrying it would have to carry a way to
41 41 //! write it back, at which point it is a form model.
42 42
43 - use crate::{Emit, push_class};
44 - use makeover_layout::{Choice, Field, FieldKind};
43 + use crate::{Emit, class, push_class};
44 + use makeover_layout::{Choice, Depth, Field, FieldKind, Selector};
45 45 use std::fmt::Write as _;
46 46
47 47 /// A string that is already markup, and is emitted without escaping.
@@ -511,10 +511,14 @@
511 511 // actual one. Two attributes a letter apart meaning opposite things is
512 512 // how a renderer's own vocabulary starts drifting.
513 513 kind if kind.multiline() => {
514 + let rich = matches!(kind, FieldKind::Rich);
515 + if rich {
516 + push_editor_open(out, opts);
517 + }
514 518 out.push_str("<textarea class=\"");
515 519 push_class(out, "field", opts);
516 520 out.push('"');
517 - if matches!(kind, FieldKind::Rich) {
521 + if rich {
518 522 out.push_str(" data-format=\"markdown\"");
519 523 }
520 524 push_control_attributes(out, field, &id, field.name);
@@ -522,6 +526,9 @@
522 526 out.push('>');
523 527 escape_into(filling.value.as_text(), out);
524 528 out.push_str("</textarea>");
529 + if rich {
530 + push_editor_close(out, opts);
531 + }
525 532 }
526 533 FieldKind::Select => {
527 534 out.push_str("<select class=\"");
@@ -589,6 +596,153 @@
589 596 }
590 597 }
591 598
599 + /// The chrome a markdown field gets and a plain textarea does not: the two
600 + /// modes, and the pane a preview lands in.
601 + ///
602 + /// # Why this is the one field with markup around it
603 + ///
604 + /// [`FieldKind::Rich`]'s own doc says the mark buys a renderer permission to
605 + /// offer a preview or a syntax pass, and that a renderer with neither draws a
606 + /// textarea. A renderer taking the permission and emitting the same box as
607 + /// [`FieldKind::Textarea`] leaves an app converting onto the member with less
608 + /// than it had written by hand: MNW's `partial-item-text-editor.js` has a
609 + /// Write/Preview pair and a pane behind it, and describing the field without
610 + /// this would delete both. So the pair is here, on `facet`'s argument one
611 + /// field down -- the markup it replaces is not markup an app is keeping.
612 + ///
613 + /// # Nothing here renders markdown, and that is where the sanitising stays
614 + ///
615 + /// The pane arrives empty and this crate never turns a value into markup.
616 + /// Converting markdown is the host's, which is where the sanitiser already is:
617 + /// MNW renders through `docengine` over ammonia and holds an allowlist beside
618 + /// it. A converter here would move that guarantee into a crate with no view of
619 + /// the host's content-security posture, and `Rich`'s doc is explicit that a
620 + /// host with its own sanitiser still owns it. What this emits is a hook, and
621 + /// whatever fills it fills it with markup it has already made safe.
622 + ///
623 + /// # The direction the enhancement runs
624 + ///
625 + /// [`crate::stylesheet`]'s rule for a showing region, and for its reason: a
626 + /// control rendered into a document with no script is a control that looks live
627 + /// and answers nothing. Nothing is hidden here and no control is shown until
628 + /// whatever binds the editor sets `data-ready` on the wrapper, so a reader with
629 + /// no script gets the textarea alone -- what 0.50.0 emitted -- and a reader with
630 + /// script gets the modes. A bound editor says which mode it is in with
631 + /// `data-mode`, and [`editor_rules`] reads that.
632 + fn push_editor_open(out: &mut String, opts: &Emit) {
633 + // The mark sits on the wrapper as well as on the control, saying one thing
634 + // about two: this control's value is markdown, and this editor edits
635 + // markdown. The rules gate on the wrapper and they are attribute rules
636 + // rather than class rules for `data-format`'s own reason -- the gate has to
637 + // survive `Emit`'s class prefixing, because the enhancement selects on it
638 + // too.
639 + out.push_str("<div data-format=\"markdown\"><div class=\"");
640 + push_class(out, "form-editor-modes", opts);
641 + out.push_str("\">");
642 + push_mode(out, "write", "Write", true, opts);
643 + push_mode(out, "preview", "Preview", false, opts);
644 + out.push_str("</div>");
645 + }
646 +
647 + /// One of the two modes, as a segment of the pair.
648 + ///
649 + /// [`crate::option_class`] for [`Selector::Segmented`] rather than a name of
650 + /// its own: a Write/Preview pair is a segmented control, and spelling it as one
651 + /// gets it the depth, the focus ring and the chosen state every described
652 + /// selector gets, from rules that already exist. The words are written here for
653 + /// the reason `facet`'s exclude button writes its own: a description carrying
654 + /// them would be choosing them for the terminal as well.
655 + fn push_mode(out: &mut String, mode: &str, label: &str, chosen: bool, opts: &Emit) {
656 + out.push_str("<button type=\"button\" class=\"");
657 + push_class(out, crate::option_class(Selector::Segmented), opts);
658 + if chosen {
659 + // The sheet keys the held-in segment on the class and a screen reader
660 + // reads the attribute. Both, because they are two readings of one fact,
661 + // which is the arrangement a facet value already has.
662 + out.push_str(" chosen");
663 + }
664 + let _ = write!(
665 + out,
666 + "\" data-editor-mode=\"{mode}\" aria-pressed=\"{chosen}\">{label}</button>"
667 + );
668 + }
669 +
670 + /// The preview pane, and the wrapper closing over both halves.
671 + fn push_editor_close(out: &mut String, opts: &Emit) {
672 + out.push_str("<div class=\"");
673 + push_class(out, "form-editor-preview", opts);
674 + // `data-editor-preview` and not an id: a form appears twice in a document
675 + // often enough that `Filling::id_prefix` exists for it, and a binder holding
676 + // the control can reach this without either of them being unique.
677 + out.push_str("\" data-editor-preview></div></div>");
678 + }
679 +
680 + /// The rules the markdown editor's chrome needs.
681 + ///
682 + /// The one place this module writes CSS. The class names [`field_html`] emits
683 + /// are goingson's and are deliberately unruled -- `.form-group`, `.form-label`,
684 + /// `.form-hint` and `.form-error` are the app's own, and phase A emits only what
685 + /// it can generate from the description -- but the two names here have no app
686 + /// counterpart to keep, because the chrome did not exist before the member did.
687 + ///
688 + /// Every rule is gated on `[data-format="markdown"]`, which is what keeps them
689 + /// off a plain textarea, and every rule that hides content is gated on
690 + /// `data-ready` as well, which is what keeps them out of a document with no
691 + /// script.
692 + pub(crate) fn editor_rules(opts: &Emit) -> String {
693 + let mut css = String::new();
694 + let modes = class("form-editor-modes", opts);
695 + let preview = class("form-editor-preview", opts);
696 + let field = class("field", opts);
697 +
698 + // Hidden until something binds the editor, which is the whole argument in
699 + // `push_editor_open`.
700 + let _ = writeln!(
701 + css,
702 + "[data-format=\"markdown\"] > .{modes} {{\n display: none;\n}}"
703 + );
704 + // Block, and nothing about how the two segments sit in it. A button is
705 + // inline already, so they make a row without this crate saying so, and
706 + // saying so is where a gap would follow -- a magnitude, and
707 + // `makeover-geometry`'s.
708 + let _ = writeln!(
709 + css,
710 + "[data-format=\"markdown\"][data-ready] > .{modes} {{\n display: block;\n}}"
711 + );
712 +
713 + // The pane is empty until the host fills it, so it is out of flow in every
714 + // state but the one where a bound editor is showing it. An empty box under
715 + // the control is chrome claiming a preview nobody rendered.
716 + let _ = writeln!(
717 + css,
718 + "[data-format=\"markdown\"] > .{preview} {{\n display: none;\n}}"
719 + );
720 + let _ = writeln!(
721 + css,
722 + "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
723 + {{\n display: block;\n}}"
724 + );
725 + // One at a time. The source and the preview are the same content read two
726 + // ways, and a field showing both answers its own question twice.
727 + let _ = writeln!(
728 + css,
729 + "[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
730 + {{\n display: none;\n}}"
731 + );
732 +
733 + // The pane stands where the control stood, so it reads as the surface the
734 + // control was: `.field` is a well, and this is the well it stands in for.
735 + // Nothing about size -- how tall a preview is is the app's, the way the
736 + // height of a track is.
737 + let _ = write!(
738 + css,
739 + "[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
740 + crate::depth_declarations(Depth::Well)
741 + );
742 +
743 + css
744 + }
745 +
592 746 /// One field, as the group the app drops into its form.
593 747 ///
594 748 /// The shape is goingson's, down to the class names, so adoption there deletes
@@ -1187,6 +1341,119 @@
1187 1341 assert!(!html.contains("<input"), "{html}");
1188 1342 }
1189 1343
1344 + #[test]
1345 + fn a_markdown_field_gets_the_preview_the_member_permits() {
1346 + // The mark on its own is what 0.50.0 shipped, and nothing read it. What
1347 + // a conversion needs is the pair MNW's `partial-item-text-editor.js`
1348 + // already draws, so describing the field is not a way to lose it.
1349 + let filling = Filling::of(Value::Text("# Heading"));
1350 + let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
1351 + assert!(html.contains("data-editor-mode=\"write\""), "{html}");
1352 + assert!(html.contains("data-editor-mode=\"preview\""), "{html}");
1353 + assert!(html.contains("data-editor-preview"), "{html}");
1354 + // Write is the mode a fresh editor is in, and the segment says so twice
1355 + // because the sheet reads one and a screen reader reads the other.
1356 + assert!(
1357 + html.contains(
1358 + "class=\"segment chosen\" data-editor-mode=\"write\" aria-pressed=\"true\""
1359 + ),
1360 + "{html}"
1361 + );
1362 + assert!(
1363 + html.contains("data-editor-mode=\"preview\" aria-pressed=\"false\""),
1364 + "{html}"
1365 + );
1366 + // The value is still the textarea's, and still text rather than an
1367 + // attribute. The chrome sits around the control, not in place of it.
1368 + assert!(html.contains("># Heading</textarea>"), "{html}");
1369 + }
1370 +
1371 + #[test]
1372 + fn a_plain_textarea_gets_no_editor_chrome() {
1373 + let filling = Filling::of(Value::Text("plain"));
1374 + let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
1375 + assert!(!html.contains("data-editor-mode"), "{html}");
1376 + assert!(!html.contains("data-editor-preview"), "{html}");
1377 + assert!(!html.contains("segment"), "{html}");
1378 + }
1379 +
1380 + #[test]
1381 + fn nothing_the_editor_emits_renders_the_value_as_markup() {
1382 + // The whole of this crate's half of the sanitising question: the pane is
1383 + // empty, so no value reaches markup through it, and the host's own
1384 + // renderer keeps the guarantee it already has.
1385 + let filling = Filling::of(Value::Text("<img src=x onerror=alert(1)>"));
1386 + let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
1387 + assert!(html.contains("data-editor-preview></div>"), "{html}");
1388 + assert!(!html.contains("<img"), "{html}");
1389 + assert!(
1390 + html.contains("&lt;img src=x onerror=alert(1)&gt;"),
1391 + "{html}"
1392 + );
1393 + }
1394 +
1395 + #[test]
1396 + fn the_editor_rules_gate_on_the_attribute_and_on_a_binding() {
1397 + let css = editor_rules(&Emit::default());
1398 + // Behind the attribute, which is the reason the mark is an attribute:
1399 + // a class-keyed gate would be prefixed away from the enhancement that
1400 + // selects on it.
1401 + for line in css.lines().filter(|line| line.contains('{')) {
1402 + assert!(line.contains("[data-format=\"markdown\"]"), "{line}");
1403 + }
1404 + // Nothing is hidden and no control appears until something binds the
1405 + // editor. A reader with no script gets the textarea alone.
1406 + assert!(
1407 + css.contains(
1408 + "[data-format=\"markdown\"] > .form-editor-modes {\n display: none;\n}"
1409 + )
1410 + );
1411 + assert!(css.contains(
1412 + "[data-format=\"markdown\"][data-ready] > .form-editor-modes {\n display: block;\n}"
1413 + ));
1414 + assert!(css.contains(
1415 + "[data-ready][data-mode=\"preview\"] > .form-editor-preview {\n display: block;\n}"
1416 + ));
1417 + assert!(
1418 + css.contains("[data-ready][data-mode=\"preview\"] > .field {\n display: none;\n}")
1419 + );
1420 + // No magnitude, the line this crate holds everywhere else.
1421 + assert!(!css.contains("px"), "{css}");
1422 + assert!(!css.contains("rem"), "{css}");
1423 + }
1424 +
1425 + /// The prefix reaches the chrome as well, and the gate deliberately does
1426 + /// not: an app assembling the sheet with its own prefix still has the
1427 + /// selector an enhancement finds the editors by.
1428 + #[test]
1429 + fn the_editor_chrome_is_prefixed_and_its_gate_is_not() {
1430 + let opts = Emit {
1431 + class_prefix: "mk-",
1432 + ..Emit::default()
1433 + };
1434 + let html = field_html(&field(FieldKind::Rich), &Filling::default(), &opts);
1435 + assert!(html.contains("class=\"mk-form-editor-modes\""), "{html}");
1436 + assert!(html.contains("class=\"mk-form-editor-preview\""), "{html}");
1437 + assert!(html.contains("class=\"mk-segment chosen\""), "{html}");
1438 + assert!(html.contains("data-format=\"markdown\""), "{html}");
1439 +
1440 + let css = editor_rules(&opts);
1441 + assert!(css.contains(".mk-form-editor-modes"), "{css}");
1442 + assert!(css.contains("[data-format=\"markdown\"]"), "{css}");
1443 + }
1444 +
1445 + /// Every class the editor puts in markup is one the generated sheet rules,
1446 + /// which is `FACET_CLASSES`' obligation without a list to keep: these two
1447 + /// have rules, so the vocabulary seal picks them up from the sheet itself.
1448 + #[test]
1449 + fn the_editor_classes_are_in_the_vocabulary() {
1450 + let opts = Emit::default();
1451 + let names = crate::vocabulary::names(&opts);
1452 + for name in ["form-editor-modes", "form-editor-preview", "segment"] {
1453 + assert!(names.contains(name), "{name} is not in the vocabulary");
1454 + }
1455 + }
1456 +
1190 1457 #[test]
1191 1458 fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
1192 1459 let opts = Emit {
M src/lib.rs +22
@@ -166,6 +166,27 @@
166 166 //! the same magnitude with its sign off the depth. Both values are the measured
167 167 //! consensus rather than a new opinion.
168 168 //!
169 + //! # 0.52.0: a markdown field gets the preview it was permitted
170 + //!
171 + //! 0.50.0 marked a [`makeover_layout::FieldKind::Rich`] field with
172 + //! `data-format="markdown"` and stopped there, and nothing read the mark: no
173 + //! rule, no markup, no selector anywhere in the tree. So a described editor was
174 + //! a plain textarea carrying an attribute, and MNW converting its four
175 + //! hand-written section editors onto the member would have *lost* the
176 + //! Write/Preview pair one of them already has. A permission taken and not spent
177 + //! turns every conversion into a regression.
178 + //!
179 + //! [`form::editor_rules`] and the chrome beside it are the spending. The pair is
180 + //! a segmented control, so it takes the depth, the focus ring and the chosen
181 + //! state from rules that already existed; the preview pane is a well, because it
182 + //! stands where the control stood. Both are gated on the attribute rather than
183 + //! on a class, which is what the attribute was for.
184 + //!
185 + //! **This crate still renders no markdown.** The pane arrives empty and is
186 + //! filled by whatever binds the editor, which is where the host's sanitiser
187 + //! already is. A converter here would have moved that guarantee into a crate
188 + //! with no view of the host's content-security posture.
189 + //!
169 190 //! # 0.47.0: a range, a chooser's ghost text, and an option that cannot be
170 191 //! picked yet
171 192 //!
@@ -1644,6 +1665,7 @@
1644 1665 css.push_str(&state_rules(opts));
1645 1666 css.push_str(&table_rules(opts));
1646 1667 css.push_str(&facet::facet_rules(opts));
1668 + css.push_str(&form::editor_rules(opts));
1647 1669 css
1648 1670 }
1649 1671