max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
7 files changed,
+151 insertions,
-44 deletions
| @@ -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(""a""), "{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")) |