Skip to main content

max / goingson

The settings sidebar offers Import & Export, as a row that leaves The section came off the "not describable from a route handler" list on 2026-08-21, when quasicoherent 67881a88 ruled that an export control does not ask where to save: the route answers with the file and the host decides. All three exports are described, and what kept the section off the sidebar after that was only addresses. It lives at /data, a screen of its own, and the sidebar offered sections. So the row navigates. A Section carries an optional `at`, and the one row with one points at /data instead of /settings/{slug}. Two shapes were on the table and this is the cheaper by a wide margin: the other is serving /data under /settings/data, which moves a finished screen for the sake of the sidebar's shape. What it costs is on `Section::at` rather than left to be discovered. The sidebar belongs to the settings screen, so following the row leaves it behind and /data draws its own band and no nav. The alternative was a sidebar with a section missing, which is what stood there before. Two consequences the type now enforces. A row that leaves is never `current`, because this screen is not showing it. And `section_of` does not answer for one, so /settings/data is a 404 rather than Appearance drawn under the Import & Export heading, which is what the fall-through in `screen`'s match would have done. Closes goingson 2b6b3575.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 15:10 UTC
Signed with PGP, not checked
Commit: fb0ad8d3b72f48454777d10f5820cfb742df6d52
Parent: dd39a42
2 files changed, +81 insertions, -11 deletions
@@ -44,9 +44,9 @@
44 44 //! 2026-08-21: a save dialog asks the host where to put something, and nothing
45 45 //! named that. The ruling was that the control does not ask — the route answers
46 46 //! with the file and the host decides where it lands — so all three exports are
47 - //! described now too. This sidebar still does not offer the section, and that is
48 - //! now only about addresses: what is described lives at `/data` rather than
49 - //! under this screen's.
47 + //! described now too. The sidebar offers the section as of 2026-08-22, as a row
48 + //! that navigates to `/data` rather than a pane this screen draws; see
49 + //! [`Section::at`] for what that costs and what the alternative was.
50 50 //!
51 51 //! Half of that is the app's own doing and is fixed here: the theme list also
52 52 //! needed an `AppHandle`, because the search path is built from the resource and
@@ -103,6 +103,29 @@
103 103 slug: &'static str,
104 104 /// What the section is called.
105 105 title: &'static str,
106 + /// Where the row goes, when that is not a section of this screen.
107 + ///
108 + /// `None` for the ordinary case, which is `/settings/{slug}` and a pane
109 + /// this module draws.
110 + ///
111 + /// `Some` is a row that navigates away, and the shipped sidebar's Import &
112 + /// Export is the one. That section is [`super::data`], a whole screen at
113 + /// `/data`, described on 2026-08-16 and finished on 2026-08-21 when
114 + /// `Outcome::File` made the exports sayable. Two shapes were on the table
115 + /// and this is the cheaper by a wide margin: the other is serving `/data`
116 + /// under `/settings/data`, which moves a finished screen for the sake of
117 + /// the sidebar's shape.
118 + ///
119 + /// What it costs, stated rather than discovered: the sidebar belongs to
120 + /// this screen, so following such a row leaves it behind and `/data` draws
121 + /// its own band and no nav. The alternative was a sidebar with a section
122 + /// missing, which is what stood here until 2026-08-22.
123 + ///
124 + /// Two consequences hold wherever this is read. A row that leaves is never
125 + /// [`Row::current`], because this screen is not showing it. And
126 + /// [`section_of`] does not answer for one, so `/settings/data` is a 404
127 + /// rather than Appearance drawn under somebody else's name.
128 + at: Option<&'static str>,
106 129 }
107 130
108 131 /// The sections that are described, in the sidebar's order.
@@ -111,18 +134,21 @@
111 134 /// absent rather than disabled, for the reason the task overview left Edit out:
112 135 /// a control that is drawn and does nothing is worse than a control that is not
113 136 /// drawn, and the module header says which and why.
114 - const SECTIONS: [Section; 4] = [
137 + const SECTIONS: [Section; 5] = [
115 138 Section {
116 139 slug: "appearance",
117 140 title: "Appearance",
141 + at: None,
118 142 },
119 143 Section {
120 144 slug: "notifications",
121 145 title: "Notifications",
146 + at: None,
122 147 },
123 148 Section {
124 149 slug: "planning",
125 150 title: "Planning & Review",
151 + at: None,
126 152 },
127 153 // Added 2026-08-21. The header above lists Email among the sections that
128 154 // are "about the host rather than about the app"; that was measured wrong.
@@ -130,6 +156,14 @@
130 156 Section {
131 157 slug: "email",
132 158 title: "Email",
159 + at: None,
160 + },
161 + // Added 2026-08-22. Described in full as `super::data` and not a section of
162 + // this screen; see `Section::at`.
163 + Section {
164 + slug: "data",
165 + title: "Import & Export",
166 + at: Some("/data"),
133 167 },
134 168 ];
135 169
@@ -392,10 +426,15 @@
392 426 }
393 427
394 428 /// The section under this slug, or 404.
429 + ///
430 + /// A row that navigates away is in [`SECTIONS`] for the sidebar's sake and is
431 + /// not a section of this screen, so it does not answer here. Without that,
432 + /// `/settings/data` would fall through [`screen`]'s match and draw Appearance
433 + /// under the Import & Export heading.
395 434 fn section_of(slug: &str) -> Result<&'static Section, RouteError> {
396 435 SECTIONS
397 436 .iter()
398 - .find(|section| section.slug == slug)
437 + .find(|section| section.at.is_none() && section.slug == slug)
399 438 .ok_or_else(|| RouteError::not_found("no such settings section"))
400 439 }
401 440
@@ -424,15 +463,18 @@
424 463 // `.settings-nav-item` buttons and tracks with an `active` class.
425 464 let nav = Slot::new("settings-nav", RegionKind::Sidebar).with(Node::list(SECTIONS.iter().map(
426 465 |item| {
427 - let mut row =
428 - Row::new(item.title).activate(Action::get(format!("/settings/{}", item.slug)));
466 + let at = item
467 + .at
468 + .map_or_else(|| format!("/settings/{}", item.slug), ToOwned::to_owned);
469 + let mut row = Row::new(item.title).activate(Action::get(at));
429 470 // `current` and not `selected`: this is the app's own pointer at
430 471 // what the pane is showing, which is the distinction the 2026-08-08
431 472 // rename drew, and the first place in the port where the sidebar
432 473 // half of it is what is wanted. Set on the field because the two
433 474 // have no paired constructor the way `toggling` pairs the other
434 475 // two, and inventing one for a plain bool would be noise.
435 - row.current = item.slug == section.slug;
476 + // A row that leaves is never current; see `Section::at`.
477 + row.current = item.at.is_none() && item.slug == section.slug;
436 478 row
437 479 },
438 480 )));
@@ -67,7 +67,7 @@
67 67 }
68 68
69 69 #[tokio::test]
70 - async fn the_sidebar_offers_the_three_described_sections_and_points_at_the_open_one() {
70 + async fn the_sidebar_offers_the_described_sections_and_points_at_the_open_one() {
71 71 let state = state().await;
72 72 let page = html(get(&state, "/settings/planning"));
73 73
@@ -76,13 +76,41 @@
76 76 assert!(page.contains("Planning &amp; Review"));
77 77 // The app's own pointer, not the user's tick.
78 78 assert!(page.contains("aria-current"));
79 - // The five sections that are about the host rather than the app are absent
79 + // The sections that are about the host rather than the app are absent
80 80 // rather than drawn as controls that do nothing. See the module header.
81 - for missing in ["Sync", "Sharing", "Import &amp; Export", "About"] {
81 + for missing in ["Sync", "Sharing", "About"] {
82 82 assert!(!page.contains(missing), "should not offer: {missing}");
83 83 }
84 84 }
85 85
86 + #[tokio::test]
87 + async fn import_and_export_is_a_sidebar_row_that_leaves_for_the_screen_it_lives_on() {
88 + // The section is described in full and lives at `/data`, so the row goes
89 + // there rather than swapping this screen's pane. See `Section::at`.
90 + let state = state().await;
91 + let page = html(get(&state, "/settings/planning"));
92 +
93 + assert!(page.contains("Import &amp; Export"), "{page}");
94 + assert!(page.contains("/data"), "{page}");
95 + }
96 +
97 + #[tokio::test]
98 + async fn the_row_that_leaves_has_no_section_of_its_own_to_open() {
99 + // Without this, `/settings/data` falls through `screen`'s match and draws
100 + // Appearance under the Import & Export heading.
101 + let state = state().await;
102 + let error = router()
103 + .handle(&state, Request::get("/settings/data"))
104 + .expect_err("data is a screen, not a section here");
105 + assert_eq!(error.class.http_status(), 404);
106 +
107 + // And it never reads as the open one, on any section.
108 + let page = html(get(&state, "/settings/planning"));
109 + let at = page.find("Import &amp; Export").expect("the row is drawn");
110 + let row = &page[page[..at].rfind('<').unwrap_or(0)..at];
111 + assert!(!row.contains("aria-current"), "{row}");
112 + }
113 +
86 114 #[tokio::test]
87 115 async fn a_section_that_is_not_described_is_a_not_found() {
88 116 let state = state().await;