max / makenotwork
| 1 | //! The library page's tab strip, described. |
| 2 | //! |
| 3 | //! Shape 2, step 1 (`6b24f2df`), and the first described tab strip in the tree. |
| 4 | //! An Askama entry point rather than a mounted screen, the same shape |
| 5 | //! `widgets::carousel` has: `pages/library.html` is still an Askama document and |
| 6 | //! this is one region inside it. |
| 7 | //! |
| 8 | //! # The panels are not described and are not meant to be |
| 9 | //! |
| 10 | //! Max's ruling: describe the strip, leave the panels as routes. So every tab is |
| 11 | //! a [`RegionKind::Bespoke`], a place and nothing else, and what lands in one |
| 12 | //! is whatever its route already answered with. That is the honest kind for a |
| 13 | //! region whose contents are Askama's, and it is what lets the five panel routes |
| 14 | //! in `routes::pages::public` stay untouched. |
| 15 | //! |
| 16 | //! # Who fetches, and why the shown tab is different |
| 17 | //! |
| 18 | //! `dfbc88ce`: the strip button carries its panel's address and the panel emits |
| 19 | //! no load trigger, so a reader downloads the tab they pressed and not the four |
| 20 | //! they did not. The shown tab carries no address at all and arrives with its |
| 21 | //! contents already in it, which is `9b958e7b`: a screen renders once at final |
| 22 | //! geometry, and a placeholder on the panel being looked at is the one place |
| 23 | //! that rule bites hardest. It is also what `library.html` already did with its |
| 24 | //! `{% include %}`, so this conversion changes the page's request count by zero. |
| 25 | //! |
| 26 | //! # Why each tab says what it replaces |
| 27 | //! |
| 28 | //! Three of the five panels are plain Askama routes that quasi never sees, and a |
| 29 | //! route that cannot name a region leaves the answer wherever htmx's default |
| 30 | //! puts it, which is inside the button that was pressed. [`Action::replacing`] |
| 31 | //! is exactly this case and its own doc says so. The other two are described |
| 32 | //! screens that name their region themselves, so they are left to, and their |
| 33 | //! regions were renamed to match the ids here. See `library_contacts::REGION` |
| 34 | //! and `forum_memberships::LIBRARY_REGION`. |
| 35 | //! |
| 36 | //! # What changed for a reader, and it is one thing |
| 37 | //! |
| 38 | //! The overflow control. `Fallback::Menu` is declared and makeover's own CSS |
| 39 | //! renders a menu run as `flex-wrap: wrap`, so a narrow viewport wraps the strip |
| 40 | //! instead of folding the last tabs behind a More button. `core/tabs.ts`'s |
| 41 | //! `tabOverflow` still serves the four Askama strips and does not see this one: |
| 42 | //! it looks for `.tabs`, and the renderer writes `.selector[data-selector=tab]`. |
| 43 | //! |
| 44 | //! # Two things the markup said and the description cannot |
| 45 | //! |
| 46 | //! `aria-label="Library sections"` on the strip, and `title="Updates from |
| 47 | //! creators you follow"` on the Feed tab. A region's own accessible name and a |
| 48 | //! control's tooltip are both absent from the vocabulary: [`Slot::label`] is a |
| 49 | //! child's tab name, which is a different thing, and nothing carries a hint. |
| 50 | //! |
| 51 | //! Dropped rather than worked around, and filed, because a `Node::Text` smuggled |
| 52 | //! in to stand for a label is how a vocabulary stops being one. The strip sits |
| 53 | //! under the page's `<h1>`, so a reader is not lost; they are told less than they |
| 54 | //! were. |
| 55 | //! |
| 56 | //! That is the ruling working rather than a gap in it. The menu's construction |
| 57 | //! and its measurement policy were explicitly left undescribed, and a renderer |
| 58 | //! is free to honour `Menu` as wrapping. Worth knowing before someone reads the |
| 59 | //! missing More button as a bug. |
| 60 | |
| 61 | use makeover_layout as layout; |
| 62 | use ; |
| 63 | use Webview; |
| 64 | |
| 65 | /// The region the whole strip occupies: the id `library.html` used for its |
| 66 | /// single panel container, kept so nothing that aims at the library's tab area |
| 67 | /// has to learn a new name. |
| 68 | const STRIP: &str = "tab-content"; |
| 69 | |
| 70 | /// What a tab is conditional on. |
| 71 | /// |
| 72 | /// A field rather than a match on the label, which is what this was until the |
| 73 | /// first person to reword a tab would have silently changed who could see it. |
| 74 | |
| 75 | |
| 76 | /// Every reader sees it. |
| 77 | Always, |
| 78 | /// Only where the Multithreaded integration is configured. |
| 79 | Communities, |
| 80 | /// Only a reader who can create projects, since only they have buyers. |
| 81 | Creators, |
| 82 | |
| 83 | |
| 84 | /// One tab: what it is called, where its panel lives, and what serves it. |
| 85 | |
| 86 | label: &'static str, |
| 87 | /// Who sees it. Membership has been conditional since before the strip was |
| 88 | /// described, for two of the five. |
| 89 | gate: Gate, |
| 90 | /// The id the panel's answer lands in. Also the described screen's own |
| 91 | /// region name, for the two tabs that have one. |
| 92 | panel: &'static str, |
| 93 | route: &'static str, |
| 94 | /// The described screen behind this panel, when there is one. `None` means |
| 95 | /// an Askama route, which is what [`Action::replacing`] is for. |
| 96 | screen: , |
| 97 | |
| 98 | |
| 99 | /// Every tab the library can show, in the order the strip draws them. |
| 100 | /// |
| 101 | /// Membership is conditional for two of the five and always has been: |
| 102 | /// Communities needs the Multithreaded integration configured and Contacts |
| 103 | /// needs a reader who can create projects. The strip is built from what |
| 104 | /// survives those two tests rather than written out flat. |
| 105 | const TABS: & = & |
| 106 | Tab |
| 107 | label: "Purchases", |
| 108 | gate: Always, |
| 109 | panel: "library-purchases", |
| 110 | route: "/library/tabs/purchases", |
| 111 | screen: None, |
| 112 | , |
| 113 | Tab |
| 114 | label: "Feed", |
| 115 | gate: Always, |
| 116 | panel: "library-feed", |
| 117 | route: "/library/tabs/feed", |
| 118 | screen: None, |
| 119 | , |
| 120 | Tab |
| 121 | label: "Collections", |
| 122 | gate: Always, |
| 123 | panel: "library-collections", |
| 124 | route: "/library/tabs/collections", |
| 125 | screen: None, |
| 126 | , |
| 127 | Tab |
| 128 | label: "Communities", |
| 129 | gate: Communities, |
| 130 | panel: LIBRARY_REGION, |
| 131 | route: LIBRARY_PATH, |
| 132 | screen: Some, |
| 133 | , |
| 134 | Tab |
| 135 | label: "Contacts", |
| 136 | gate: Creators, |
| 137 | panel: REGION, |
| 138 | route: PATH, |
| 139 | screen: Some, |
| 140 | , |
| 141 | ]; |
| 142 | |
| 143 | /// The markup, for `pages/library.html` to drop in. |
| 144 | /// |
| 145 | /// `purchases` is the first panel's contents, rendered by the caller: the page |
| 146 | /// handler already has the rows, and the shown panel arriving with the document |
| 147 | /// is the whole of what keeps this page at one request. |
| 148 | |
| 149 | |
| 150 | let shown: = TABS |
| 151 | .iter |
| 152 | .filter |
| 153 | Always => true, |
| 154 | Communities => has_mt_memberships, |
| 155 | Creators => can_create_projects, |
| 156 | |
| 157 | .collect; |
| 158 | |
| 159 | let mut strip = new |
| 160 | // A run with no members, only a fallback. `Run` has no `Default` on |
| 161 | // purpose, so a strip cannot be described while staying silent about |
| 162 | // what it does when it runs out of room. `Menu` is what the page means |
| 163 | // -- the last tabs are worth less than the first ones and should fold |
| 164 | // away rather than squeeze -- and what this renderer currently does with |
| 165 | // that is wrap. See the module header. |
| 166 | .across |
| 167 | .showing_one; |
| 168 | |
| 169 | for in shown.iter.enumerate |
| 170 | let mut panel = bespoke.label; |
| 171 | if at > 0 |
| 172 | let mut call = get.awaiting; |
| 173 | // Described routes name their own region and must be left to; |
| 174 | // setting it here would override an answer that already knew |
| 175 | // better. See `Action::replaces`. |
| 176 | if tab.screen.is_none |
| 177 | call = call.replacing; |
| 178 | |
| 179 | panel = panel.fed_by; |
| 180 | |
| 181 | strip = strip.with; |
| 182 | |
| 183 | |
| 184 | use Serves as _; |
| 185 | |
| 186 | // No shell: this is a fragment landing inside a document Askama already |
| 187 | // built, which is exactly what `fragment` is for. |
| 188 | new |
| 189 | .with_fill |
| 190 | .fragment |
| 191 | |
| 192 | |
| 193 | |
| 194 | |
| 195 | use *; |
| 196 | |
| 197 | |
| 198 | html |
| 199 | |
| 200 | |
| 201 | |
| 202 | |
| 203 | // The whole safety argument for this conversion. `library.html` rendered |
| 204 | // the shown panel inline and fetched the rest on a press; if the |
| 205 | // described strip fetched on load instead, the page would go from zero |
| 206 | // panel requests to four, each its own set of queries. |
| 207 | let html = strip; |
| 208 | |
| 209 | assert!; |
| 210 | assert!; |
| 211 | assert_eq!; |
| 212 | |
| 213 | |
| 214 | |
| 215 | |
| 216 | // Three of these routes are Askama and name no region, so without this |
| 217 | // the answer swaps into the button that was pressed -- which is what |
| 218 | // `DELETE /api/users/me/ssh-keys` did before the described screens |
| 219 | // served their own writes. |
| 220 | let html = strip; |
| 221 | |
| 222 | // The Askama tabs. `library-communities` and `library-contacts` are |
| 223 | // deliberately absent: both are described screens that name their own |
| 224 | // region, so the strip must NOT retarget them. Until `64b33b26` they |
| 225 | // were here, because the switch was off in tests and every tab was |
| 226 | // Askama. |
| 227 | for panel in |
| 228 | assert!; |
| 229 | assert!; |
| 230 | |
| 231 | // Every tab still gets its frame, described or not. |
| 232 | for panel in |
| 233 | assert!; |
| 234 | assert! |
| 235 | !html.contains, |
| 236 | "{panel} is described and names its own region:\n{html}" |
| 237 | ; |
| 238 | |
| 239 | // The shown panel is not fetched, so it has nowhere to aim and says so |
| 240 | // by carrying no transport at all. |
| 241 | assert!; |
| 242 | |
| 243 | |
| 244 | |
| 245 | |
| 246 | // Decision 7. A described route answers with a fragment naming what it |
| 247 | // changed, so a target written here would override an answer that |
| 248 | // already knew better. Both described panels, and it follows the panel |
| 249 | // rather than a switch since `64b33b26` deleted the switch -- |
| 250 | // `library-communities` used to be asserted the other way here, because |
| 251 | // it was described but not switched on in this test. |
| 252 | let html = html; |
| 253 | |
| 254 | for panel in |
| 255 | assert! |
| 256 | !html.contains, |
| 257 | "a described panel retargets its own answer:\n{html}" |
| 258 | ; |
| 259 | |
| 260 | // And the ones still served by Askama are untouched by that. |
| 261 | assert!; |
| 262 | |
| 263 | |
| 264 | |
| 265 | |
| 266 | // Membership was conditional before it was described and stays so. The |
| 267 | // recon that planned this shape recorded the library strip as five |
| 268 | // unconditional buttons, which the template contradicts twice. |
| 269 | let both = strip; |
| 270 | assert!; |
| 271 | assert!; |
| 272 | |
| 273 | let neither = strip; |
| 274 | assert!; |
| 275 | assert!; |
| 276 | // And the strip is still a strip, with the shown panel where it was. |
| 277 | assert!; |
| 278 | assert!; |
| 279 | |
| 280 | |
| 281 | |
| 282 | |
| 283 | // `Run` has no `Default`, so this is not something a strip can forget; |
| 284 | // what it can do is pick the wrong one. `Menu` is the page's own |
| 285 | // behaviour today -- the overflow goes behind a More control. |
| 286 | assert!; |
| 287 | |
| 288 | |
| 289 |