Skip to main content

max / quasi

Give Tag a hint, so a described badge keeps the detail 436bc223. A badge's label is short because a badge is small, and the shipped goingson board carried the long form in a title: the block depth behind "Blocked", the freed count's wording behind "Unblocks N", the repair instruction behind "Cycle". A described card said the label and dropped the detail. Each renderer decides what a hint is, and each says so in its own docs: quasi-webview emits `title`, which is what the shipped badges used; quasi-immediate calls on_hover_text, since egui has hover and a token is already a Response; quasi-tui drops it, having no hover and no second surface, with a test asserting the drop so that appending it to the label is not tried later. Tag::removable is new in the same pass. It was the one shape with no constructor, so every site wanting it wrote the struct out and had to be visited when this field landed; the bare literals are on constructors now and the next field is free.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
Author: Max Johnson <me@maxj.phd> · 2026-08-29 17:51 UTC
Signed with PGP, not checked
Commit: 91c21f1ca89c80f8c958815130f8e1686813653b
Parent: 1be61d1
7 files changed, +151 insertions, -44 deletions
M Cargo.lock +4 -4
@@ -6211,6 +6211,10 @@
6211 6211 "winnow 1.0.4",
6212 6212 ]
6213 6213
6214 + [[patch.unused]]
6215 + name = "synckit-client"
6216 + version = "0.10.0"
6217 +
6214 6218 [[patch.unused]]
6215 6219 name = "kberg"
6216 6220 version = "0.1.0"
@@ -6230,7 +6234,3 @@
6230 6234 [[patch.unused]]
6231 6235 name = "quasi-type"
6232 6236 version = "0.1.3"
6233 -
6234 - [[patch.unused]]
6235 - name = "synckit-client"
6236 - version = "0.10.0"
@@ -113,7 +113,7 @@
113 113 }
114 114
115 115 Node::Token(tag) => {
116 - let pressed = widget::token(
116 + let mut pressed = widget::token(
117 117 ui,
118 118 &tag.label,
119 119 tag.kind,
@@ -122,6 +122,13 @@
122 122 &pass.immediate.palette,
123 123 &pass.immediate.widget,
124 124 );
125 + // `436bc223`: the detail behind the label. egui has hover, so this
126 + // renderer draws it -- `on_hover_text` is the same affordance the
127 + // webview spends `title` on, and a token is already a `Response`,
128 + // so nothing in makeover-immediate had to grow a parameter for it.
129 + if let Some(hint) = &tag.hint {
130 + pressed = pressed.on_hover_text(hint);
131 + }
125 132 if let Some(action) = &tag.action
126 133 && pressed.clicked()
127 134 {
@@ -722,7 +722,7 @@
722 722 /// Its own struct as of 2026-08-08, having been the inline payload of
723 723 /// [`Node::Token`]. Extracted because a row can carry these now
724 724 /// ([`Row::tokens`], against `makeover-layout`'s `RowPart::Tokens`), and the
725 - /// alternative was defining the same five fields twice and watching them drift.
725 + /// alternative was defining the same fields twice and watching them drift.
726 726 ///
727 727 /// The tone rides on the tag rather than on whatever holds it, which is what
728 728 /// lets a strip of them say different things: a neutral type and an amber
@@ -742,6 +742,23 @@
742 742 pub latched: bool,
743 743 /// What clicking it calls, if it answers a click.
744 744 pub action: Option<Action>,
745 + /// The detail behind the label, for a renderer that has somewhere to put
746 + /// it.
747 + ///
748 + /// `436bc223`. A badge's label is short because a badge is small, and the
749 + /// shipped goingson board says the short thing and carries the long one:
750 + /// "Blocked" with the block depth behind it, "Unblocks 3" with the wording,
751 + /// "Cycle" with the repair instruction. A described card said the label and
752 + /// dropped the detail, so the description could not say what the shipped
753 + /// markup already did.
754 + ///
755 + /// Standing detail, not a message: it is true whenever the tag is on
756 + /// screen, which is what makes it a property of the tag rather than
757 + /// something a response says. **Every renderer may drop it**, and dropping
758 + /// is the graceful degradation this vocabulary keeps choosing rather than a
759 + /// gap; what each one does is stated in its own docs. Never put anything
760 + /// here that is the only place a fact appears.
761 + pub hint: Option<String>,
745 762 }
746 763
747 764 impl Tag {
@@ -753,6 +770,7 @@
753 770 tone: layout::Tone::Neutral,
754 771 latched: false,
755 772 action: None,
773 + hint: None,
756 774 }
757 775 }
758 776
@@ -768,6 +786,21 @@
768 786 tone: layout::Tone::Neutral,
769 787 latched: false,
770 788 action: Some(action),
789 + hint: None,
790 + }
791 + }
792 +
793 + /// A chip the reader can take off again.
794 + ///
795 + /// The affordance a removable chip has and a plain one does not, said
796 + /// rather than inferred, which is the same reason [`chip`](Self::chip) is
797 + /// not removable by virtue of carrying an action. Added 2026-08-29: this
798 + /// was the one shape with no constructor, so every site that wanted it
799 + /// wrote the struct out and had to be visited when `hint` was added.
800 + pub fn removable(label: impl Into<String>, action: Action) -> Self {
801 + Self {
802 + kind: layout::Token::Chip { removable: true },
803 + ..Self::chip(label, action)
771 804 }
772 805 }
773 806
@@ -784,6 +817,16 @@
784 817 self.latched = latched;
785 818 self
786 819 }
820 +
821 + /// The detail behind the label; see [`hint`](Self::hint).
822 + ///
823 + /// A renderer with nowhere to put it drops it, so this must never be the
824 + /// only place a fact appears.
825 + #[must_use]
826 + pub fn hinted(mut self, hint: impl Into<String>) -> Self {
827 + self.hint = Some(hint.into());
828 + self
829 + }
787 830 }
788 831
789 832 /// One option offered by a field, owned.
@@ -1076,6 +1076,18 @@
1076 1076 /// The bracket, the latch and the collision between latched and focused are all
1077 1077 /// `makeover-tui`'s answers now. What is left here is the translation: our
1078 1078 /// owned [`Tag`] into the parts the shared drawing takes.
1079 + /// # What this renderer does with a hint
1080 + ///
1081 + /// Drops it. `436bc223`: a terminal has no hover and no second surface to put
1082 + /// standing help on, and the alternatives are both worse than nothing --
1083 + /// appending it to the label turns a badge into a sentence and defeats the
1084 + /// reason a badge is short, and a status line borrowed for it would be
1085 + /// competing with what the runtime already puts there.
1086 + ///
1087 + /// This is the graceful degradation [`Tag::hint`] describes rather than a gap,
1088 + /// and it is why that field says nothing may live only there. Stated here so
1089 + /// that a reader comparing the three renderers finds an answer rather than an
1090 + /// omission.
1079 1091 fn tag_span(tui: &Tui, tag: &Tag, focused: bool) -> Span<'static> {
1080 1092 piece::token(
1081 1093 tui.style(),
@@ -5130,3 +5130,21 @@
5130 5130 assert!(matches!(runtime.key(Key::Escape), Step::Idle));
5131 5131 assert!(!runtime.overlaid());
5132 5132 }
5133 +
5134 + /// `436bc223`. A terminal has no hover and no second surface for standing help,
5135 + /// so this renderer drops a hint. Asserted rather than only documented: the
5136 + /// alternatives -- appending it to the label, or borrowing the status line --
5137 + /// both look like improvements until you see what they cost, and a test is what
5138 + /// stops one being tried.
5139 + #[test]
5140 + fn a_hint_is_dropped_because_a_terminal_has_nowhere_to_put_one() {
5141 + let plain = drawn(&Node::Token(Tag::badge("Blocked")), 40, 3);
5142 + let hinted = drawn(
5143 + &Node::Token(Tag::badge("Blocked").hinted("3 steps away")),
5144 + 40,
5145 + 3,
5146 + );
5147 +
5148 + assert_eq!(plain, hinted);
5149 + assert!(!hinted.join("").contains("3 steps away"), "{hinted:?}");
5150 + }
@@ -2956,6 +2956,14 @@
2956 2956 /// Took eight arguments, one per field of `Node::Token`, until the payload
2957 2957 /// became [`Tag`] so a row could carry one. The `too_many_arguments` allow went
2958 2958 /// with them.
2959 + ///
2960 + /// # What this renderer does with a hint
2961 + ///
2962 + /// Emits it as `title`. That is what the shipped goingson badges used and is
2963 + /// the only standing-help affordance a browser gives a `span`, so a described
2964 + /// badge draws what the hand-written one drew. It is hover-only and therefore
2965 + /// unreachable by touch and unreliable to a screen reader, which is why
2966 + /// [`Tag::hint`] says nothing may live only there.
2959 2967 fn tag_html(tag: &Tag, opts: &Emit, out: &mut String) {
2960 2968 let Tag {
2961 2969 kind,
@@ -2963,6 +2971,7 @@
2963 2971 tone,
2964 2972 latched,
2965 2973 action,
2974 + hint,
2966 2975 } = tag;
2967 2976 let (kind, tone, latched) = (*kind, *tone, *latched);
2968 2977 let action = action.as_ref();
@@ -3021,6 +3030,16 @@
3021 3030 }
3022 3031 }
3023 3032
3033 + // `436bc223`: the detail behind the label, as `title`, which is what the
3034 + // shipped goingson badges use and is the only standing-help affordance a
3035 + // browser gives a span. Hover-only, so it is genuinely a hint and the
3036 + // vocabulary says so -- nothing may live only here.
3037 + if let Some(hint) = hint {
3038 + out.push_str(" title=\"");
3039 + escape_into(hint, out);
3040 + out.push('"');
3041 + }
3042 +
3024 3043 out.push('>');
3025 3044 escape_into(label, out);
3026 3045 if matches!(kind, layout::Token::Chip { removable: true }) {
@@ -807,11 +807,9 @@
807 807 #[test]
808 808 fn a_badge_is_not_a_button_and_a_chip_is() {
809 809 let badge = fragment(&Node::Token(Tag {
810 - kind: layout::Token::Badge,
811 - label: "3".into(),
812 810 tone: layout::Tone::Info,
813 - latched: false,
814 811 action: Some(Action::get("/x")),
812 + ..Tag::badge("3")
815 813 }));
816 814 // A badge answers no click however it is dressed, so it emits no transport
817 815 // even when a description hands it an action.
@@ -820,13 +818,9 @@
820 818 assert!(!badge.contains("hx-get"));
821 819 assert!(!badge.contains("href"));
822 820
823 - let chip = fragment(&Node::Token(Tag {
824 - kind: layout::Token::Chip { removable: false },
825 - label: "open".into(),
826 - tone: layout::Tone::Neutral,
827 - latched: true,
828 - action: Some(Action::get("/x")),
829 - }));
821 + let chip = fragment(&Node::Token(
822 + Tag::chip("open", Action::get("/x")).latched(true),
823 + ));
830 824 // A read of a route, so the chip is a link and says its state the way a
831 825 // link says it. aria-pressed is a button's word and means nothing here.
832 826 assert!(chip.contains("<a "));
@@ -836,18 +830,46 @@
836 830 assert!(chip.contains("hx-get=\"/x\""));
837 831
838 832 // A chip whose activation writes is still a button, and still says so.
839 - let toggle = fragment(&Node::Token(Tag {
840 - kind: layout::Token::Chip { removable: false },
841 - label: "open".into(),
842 - tone: layout::Tone::Neutral,
843 - latched: true,
844 - action: Some(Action::post("/x/toggle")),
845 - }));
833 + let toggle = fragment(&Node::Token(
834 + Tag::chip("open", Action::post("/x/toggle")).latched(true),
835 + ));
846 836 assert!(toggle.contains("<button"));
847 837 assert!(toggle.contains("aria-pressed=\"true\""));
848 838 assert!(!toggle.contains("href"));
849 839 }
850 840
841 + /// `436bc223`. A badge's label is short because a badge is small, and the
842 + /// shipped goingson board carried the long form in `title`. This renderer draws
843 + /// it there.
844 + #[test]
845 + fn a_badge_carries_the_detail_behind_its_label_as_a_title() {
846 + let html = fragment(&Node::Token(Tag::badge("Blocked").hinted("3 steps away")));
847 +
848 + assert!(html.contains("title=\"3 steps away\""), "{html}");
849 + // Still a badge: the hint is standing help, not an affordance.
850 + assert!(!html.contains("<button"), "{html}");
851 + assert!(!html.contains("data-act"), "{html}");
852 + }
853 +
854 + /// A hint is user-authored text going into an attribute value, so it takes the
855 + /// same escaping every other attribute here takes.
856 + #[test]
857 + fn a_hint_is_escaped_like_every_other_attribute() {
858 + let html = fragment(&Node::Token(
859 + Tag::badge("Cycle").hinted("remove an edge: a -> b -> \"a\""),
860 + ));
861 +
862 + assert!(!html.contains("-> \"a\">"), "{html}");
863 + assert!(html.contains("&quot;a&quot;"), "{html}");
864 + }
865 +
866 + /// No hint, no attribute. An empty `title` is a tooltip that opens on nothing.
867 + #[test]
868 + fn a_tag_with_nothing_behind_its_label_emits_no_title() {
869 + let html = fragment(&Node::Token(Tag::badge("Blocked")));
870 + assert!(!html.contains("title="), "{html}");
871 + }
872 +
851 873 #[test]
852 874 fn a_select_sends_its_value_under_the_one_agreed_name() {
853 875 let html = fragment(&Node::Select {
@@ -2660,13 +2682,7 @@
2660 2682 .secondary(hostile)
2661 2683 .meta(hostile)
2662 2684 .act(Act::new(hostile, Action::post("/y")))]))
2663 - .with(Node::Token(Tag {
2664 - kind: layout::Token::Chip { removable: true },
2665 - label: hostile.into(),
2666 - tone: layout::Tone::Neutral,
2667 - latched: false,
2668 - action: Some(Action::get("/z")),
2669 - })),
2685 + .with(Node::Token(Tag::removable(hostile, Action::get("/z")))),
2670 2686 );
2671 2687
2672 2688 let html = render(&screen);
@@ -5564,21 +5580,13 @@
5564 5580 ))
5565 5581 .with(Node::Token(Tag::badge("Badge")))
5566 5582 // Removable, which is the only way `chip-remove` is emitted.
5567 - .with(Node::Token(Tag {
5568 - kind: layout::Token::Chip { removable: true },
5569 - label: "Chip".into(),
5570 - tone: layout::Tone::Neutral,
5571 - latched: false,
5572 - action: Some(Action::post("/x")),
5573 - }))
5583 + .with(Node::Token(Tag::removable("Chip", Action::post("/x"))))
5574 5584 // Latched, for the state class.
5575 - .with(Node::Token(Tag {
5576 - kind: layout::Token::Chip { removable: false },
5577 - label: "Latched".into(),
5578 - tone: layout::Tone::Info,
5579 - latched: true,
5580 - action: Some(Action::post("/t")),
5581 - }))
5585 + .with(Node::Token(
5586 + Tag::chip("Latched", Action::post("/t"))
5587 + .tone(layout::Tone::Info)
5588 + .latched(true),
5589 + ))
5582 5590 .with(Node::banner(layout::Tone::Info, "A banner"))
5583 5591 .with(Node::toast(layout::Tone::Success, "A toast"))
5584 5592 .with(Node::empty("Nothing here"))