Skip to main content

max / quasi

A tab panel is a fragment, and an option can name the route it opens Node::Select.options becomes Vec<(Choice, Option<Action>)>. The tuple is Node::Stats' shape and it is here for the reason stated there: makeover-layout cannot name an action at all, so an address rides beside the described thing. Per-option is the minimum that fits what was measured. The server's dashboard-user shell has fifteen tabs and fifteen routes; one strip-level action with the value substituted in cannot address them, and building the route by convention ("/dashboard/tabs/" + value) would put route construction in a renderer. An option carrying None falls back to the strip's action with its value under Node::SELECTED, which is what every option did before, so every segmented control and toggle in the tree is unchanged in meaning and in markup. An option naming a read is emitted as a link rather than a button, the same reasoning every other read here follows: middle-click, copy-link, a crawler, and the page with JS off. Its action carries no target, because decision 7 says the response names what it replaces and a panel answers with a Fragment. Rejected: a Node::Panels. Two nodes rendering the identical Selector::Tabs markup is how two vocabularies for one shape start. Classification: the 34 panels are fragments, not screens. The server keeps an explicit FRAGMENT_PARENTS table and 302s any direct navigation to them, so a panel is not a landable page by the server's own declaration.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 18:45 UTC
Signed with PGP, not checked
Commit: 4a50ba47651e899795582754fa32eb8fab5e6d4a
Parent: a1124d3
5 files changed, +155 insertions, -27 deletions
M Cargo.lock +8 -8
@@ -4888,14 +4888,6 @@
4888 4888 source = "registry+https://github.com/rust-lang/crates.io-index"
4889 4889 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4890 4890
4891 - [[patch.unused]]
4892 - name = "synckit-client"
4893 - version = "0.8.0"
4894 -
4895 - [[patch.unused]]
4896 - name = "synckit-config"
4897 - version = "0.2.0"
4898 -
4899 4891 [[patch.unused]]
4900 4892 name = "kberg"
4901 4893 version = "0.1.0"
@@ -4907,3 +4899,11 @@
4907 4899 [[patch.unused]]
4908 4900 name = "tagtree"
4909 4901 version = "0.4.0"
4902 +
4903 + [[patch.unused]]
4904 + name = "synckit-client"
4905 + version = "0.8.0"
4906 +
4907 + [[patch.unused]]
4908 + name = "synckit-config"
4909 + version = "0.2.0"
@@ -1742,13 +1742,30 @@
1742 1742 Select {
1743 1743 /// Segmented, toggle, or tabs.
1744 1744 kind: layout::Selector,
1745 - /// What is on offer.
1746 - options: Vec<Choice>,
1745 + /// What is on offer, and what each one calls if it calls something of
1746 + /// its own.
1747 + ///
1748 + /// The tuple is [`Stats`](Self::Stats)' shape and it is here for the
1749 + /// same reason, stated there: `makeover-layout` cannot name an action
1750 + /// at all, so an address rides beside the described thing rather than
1751 + /// inside it. [`Choice::as_layout`] hands back a value and a label and
1752 + /// nothing else.
1753 + ///
1754 + /// It is what a tab strip needs. The MNW server's dashboard-user shell
1755 + /// has fifteen tabs and fifteen routes; one strip-level action with the
1756 + /// value substituted in cannot address them, and building the route by
1757 + /// convention would put route construction in a renderer.
1758 + ///
1759 + /// An option carrying `None` falls back to
1760 + /// [`action`](Self::Select::action) with its value under
1761 + /// [`Self::SELECTED`], which is what every option did before the tuple,
1762 + /// so a segmented control and a toggle are unchanged in meaning.
1763 + options: Vec<(Choice, Option<Action>)>,
1747 1764 /// Which option is currently picked, by its
1748 1765 /// [`value`](Choice::value).
1749 1766 chosen: Option<String>,
1750 - /// What picking an option calls. The picked value is sent under
1751 - /// [`Self::SELECTED`].
1767 + /// What picking an option calls, for the options that name nothing
1768 + /// themselves. The picked value is sent under [`Self::SELECTED`].
1752 1769 action: Option<Action>,
1753 1770 },
1754 1771 /// How much of a set is done.
@@ -1134,7 +1134,7 @@
1134 1134 /// A control that picks between things.
1135 1135 fn select_html(
1136 1136 kind: layout::Selector,
1137 - options: &[quasi_router::screen::Choice],
1137 + options: &[(quasi_router::screen::Choice, Option<Action>)],
1138 1138 chosen: Option<&str>,
1139 1139 action: Option<&Action>,
1140 1140 morphs: bool,
@@ -1158,14 +1158,23 @@
1158 1158 }
1159 1159 out.push('>');
1160 1160
1161 - for option in options {
1161 + for (option, own) in options {
1162 1162 let picked = chosen.is_some_and(|value| value == option.value);
1163 1163 let mut classes = vec!["option"];
1164 1164 if picked {
1165 1165 classes.push("option-chosen");
1166 1166 }
1167 1167
1168 - out.push_str("<button type=\"button\"");
1168 + // An option naming its own route is a link when that route is a read,
1169 + // for the reason every other read here is: middle-click, copy-link, a
1170 + // crawler and the page with JS off. A tab panel is fetched with a GET,
1171 + // so a tab strip is fifteen links rather than fifteen buttons.
1172 + let (open, close) = own
1173 + .as_ref()
1174 + .map_or(("<button type=\"button\"", "</button>"), |action| {
1175 + control_tag(action)
1176 + });
1177 + out.push_str(open);
1169 1178 class_attr(&classes, opts, out);
1170 1179 if matches!(kind, layout::Selector::Tabs) {
1171 1180 out.push_str(" role=\"tab\" aria-selected=\"");
@@ -1174,7 +1183,13 @@
1174 1183 out.push_str(" aria-pressed=\"true\"");
1175 1184 }
1176 1185
1177 - if let Some(action) = action {
1186 + // The option's own route wins, and there is nothing to substitute into
1187 + // it: it already names the panel. The strip's action is the fallback,
1188 + // and it is the one that needs the picked value, because it is one
1189 + // route standing for all the options.
1190 + if let Some(own) = own {
1191 + action_attrs(own, Fires::Click, None, morphs, out);
1192 + } else if let Some(action) = action {
1178 1193 // The picked value travels under one name, decided once in
1179 1194 // `Node::SELECTED`, rather than agreed per screen between a
1180 1195 // renderer and a handler.
@@ -1184,7 +1199,7 @@
1184 1199
1185 1200 out.push('>');
1186 1201 out.push_str(&escape(&option.label));
1187 - out.push_str("</button>");
1202 + out.push_str(close);
1188 1203 }
1189 1204
1190 1205 out.push_str("</div>");
@@ -497,14 +497,20 @@
497 497 let html = fragment(&Node::Select {
498 498 kind: layout::Selector::Tabs,
499 499 options: vec![
500 - Choice {
501 - value: "open".into(),
502 - label: "Open".into(),
503 - },
504 - Choice {
505 - value: "done".into(),
506 - label: "Done".into(),
507 - },
500 + (
501 + Choice {
502 + value: "open".into(),
503 + label: "Open".into(),
504 + },
505 + None,
506 + ),
507 + (
508 + Choice {
509 + value: "done".into(),
510 + label: "Done".into(),
511 + },
512 + None,
513 + ),
508 514 ],
509 515 chosen: Some("open".into()),
510 516 action: Some(Action::get("/tasks")),
@@ -1765,3 +1771,89 @@
1765 1771 assert!(!html.contains("push-url"), "{html}");
1766 1772 assert!(!html.contains("replace-url"), "{html}");
1767 1773 }
1774 +
1775 + #[test]
1776 + fn an_option_naming_its_own_route_calls_that_and_not_the_strips() {
1777 + // The tab strip. dashboard-user has fifteen panels and fifteen routes, and
1778 + // one strip-level action with the value substituted in cannot address them.
1779 + let html = fragment(&Node::Select {
1780 + kind: layout::Selector::Tabs,
1781 + options: vec![
1782 + (
1783 + Choice {
1784 + value: "projects".into(),
1785 + label: "Projects".into(),
1786 + },
1787 + // No target on the action: decision 7 says the response names
1788 + // what it replaces, and a panel route answers with a Fragment
1789 + // aimed at the content slot.
1790 + Some(Action::get("/dashboard/tabs/projects")),
1791 + ),
1792 + (
1793 + Choice {
1794 + value: "keys".into(),
1795 + label: "SSH keys".into(),
1796 + },
1797 + Some(Action::get("/dashboard/tabs/ssh-keys")),
1798 + ),
1799 + ],
1800 + chosen: Some("projects".into()),
1801 + action: None,
1802 + });
1803 +
1804 + // Each panel's own route, and no value substituted into anything: the
1805 + // option already names where it goes.
1806 + assert!(html.contains("/dashboard/tabs/projects"), "{html}");
1807 + assert!(html.contains("/dashboard/tabs/ssh-keys"), "{html}");
1808 + assert!(
1809 + !html.contains(&format!("{}=projects", Node::SELECTED)),
1810 + "{html}"
1811 + );
1812 +
1813 + // A read is a link, so the tab works with JS off and can be copied.
1814 + assert!(html.contains("<a"), "{html}");
1815 + assert!(html.contains("role=\"tablist\""), "{html}");
1816 + assert!(html.contains("aria-selected=\"true\""), "{html}");
1817 + }
1818 +
1819 + #[test]
1820 + fn an_option_with_no_route_of_its_own_is_unchanged() {
1821 + // Every segmented control and toggle in the tree is this case, and the
1822 + // tuple must not have moved it: the strip's action carries the picked value
1823 + // under the one agreed name, exactly as before.
1824 + let with_tuple = fragment(&Node::Select {
1825 + kind: layout::Selector::Segmented,
1826 + options: vec![
1827 + (
1828 + Choice {
1829 + value: "0".into(),
1830 + label: "Active".into(),
1831 + },
1832 + None,
1833 + ),
1834 + (
1835 + Choice {
1836 + value: "1".into(),
1837 + label: "Archived".into(),
1838 + },
1839 + None,
1840 + ),
1841 + ],
1842 + chosen: Some("0".into()),
1843 + action: Some(Action::get("/notes")),
1844 + });
1845 +
1846 + assert!(
1847 + with_tuple.contains(&format!("/notes?{}=0", Node::SELECTED)),
1848 + "{with_tuple}"
1849 + );
1850 + assert!(
1851 + with_tuple.contains(&format!("/notes?{}=1", Node::SELECTED)),
1852 + "{with_tuple}"
1853 + );
1854 + // Still a button: nothing about the fallback path changed.
1855 + assert!(
1856 + with_tuple.contains("<button type=\"button\""),
1857 + "{with_tuple}"
1858 + );
1859 + }
@@ -133,9 +133,13 @@
133 133
134 134 let filter = Node::Select {
135 135 kind: Selector::Segmented,
136 + // Each option pairs with the route it calls, and both of these call
137 + // the strip's. A tab strip is where the other half earns its keep: an
138 + // option naming its own route addresses one panel out of fifteen, which
139 + // one route with a value substituted in cannot do.
136 140 options: vec![
137 - Choice::new("0", format!("Active ({active_count})")),
138 - Choice::new("1", format!("Archived ({archived_count})")),
141 + (Choice::new("0", format!("Active ({active_count})")), None),
142 + (Choice::new("1", format!("Archived ({archived_count})")), None),
139 143 ],
140 144 chosen: Some(if archived { "1" } else { "0" }.to_owned()),
141 145 // The picked value arrives under `Node::SELECTED`, which is the name