Skip to main content

max / makenotwork

Describe the custom-pages host, strip and all quasicoherent 48a6e9e5. u.makenot.work was the last host assembled entirely by Askama, and the reason it was last is that a description could say neither half of it: the creator's markup is opaque and the stylesheet that goes with it is per request. quasi 0.92.0 added Node::Canvas and Document::style, and this is their first consumer. The strip is content, not Chrome. A Chrome is built once beside the router and every link here is per request, so the header and footer are Band regions on the screen. They stay siblings of the canvas, which is the whole isolation guarantee: css_sanitizer nests every creator rule inside .user-canvas#uc-{owner}, so a creator selector naming anything in the strip is emitted under the canvas and matches nothing. The published class names survive. site-docs tells creators the buy block is .mnw-buy, the file list .mnw-files and the item block .mnw-item, with worked CSS, so those are a contract with people outside this codebase and they ride on Canvas scopes. The one visible change is the call to action, which was an <a class="mnw-buy-cta"> and is now that class on the element around the link; the guide's own example sets color on it, and a color on an ancestor does not reach an anchor, so the sheet says color: inherit for a link inside one. Without that line the documented example would quietly stop working. Shell::without_scripts rather than thirteen refusals, under a CSP of default-src 'none'. Embeds take it too, where the list had grown to nine and gained one every time quasi added a script. templates/custom/ is gone, chrome partials included. The 16 custom-pages workflow tests pass unchanged, which is the check that matters: they assert the scoping, the CSP, the locked-owner fallback and the item canvas.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01WHu5vBgpCULUFXsEJBHryC
Author: Max Johnson <me@maxj.phd> · 2026-08-31 01:27 UTC
Signed with PGP, not checked
Commit: ac3a09d4745d331a4c1d439d59489df1110bd02a
Parent: ea6c2ac
12 files changed, +613 insertions, -291 deletions
M server/Cargo.lock +29 -29
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.92.0"
6639 + version = "0.93.0"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.92.0"
6650 + version = "0.93.0"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.92.0"
6658 + version = "0.93.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,7 +6664,7 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.92.0"
6667 + version = "0.93.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.92.0"
6690 + version = "0.93.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -10759,30 +10759,6 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 - [[patch.unused]]
10763 - name = "quasi-immediate"
10764 - version = "0.92.0"
10765 -
10766 - [[patch.unused]]
10767 - name = "quasi-notifs"
10768 - version = "0.92.0"
10769 -
10770 - [[patch.unused]]
10771 - name = "quasi-store"
10772 - version = "0.1.0"
10773 -
10774 - [[patch.unused]]
10775 - name = "quasi-tauri"
10776 - version = "0.92.0"
10777 -
10778 - [[patch.unused]]
10779 - name = "synckit-client"
10780 - version = "0.10.0"
10781 -
10782 - [[patch.unused]]
10783 - name = "synckit-config"
10784 - version = "0.2.0"
10785 -
10786 10762 [[patch.unused]]
10787 10763 name = "kberg"
10788 10764 version = "0.1.0"
@@ -10794,3 +10770,27 @@
10794 10770 [[patch.unused]]
10795 10771 name = "painhours"
10796 10772 version = "0.1.0"
10773 +
10774 + [[patch.unused]]
10775 + name = "quasi-immediate"
10776 + version = "0.93.0"
10777 +
10778 + [[patch.unused]]
10779 + name = "quasi-notifs"
10780 + version = "0.93.0"
10781 +
10782 + [[patch.unused]]
10783 + name = "quasi-store"
10784 + version = "0.1.0"
10785 +
10786 + [[patch.unused]]
10787 + name = "quasi-tauri"
10788 + version = "0.93.0"
10789 +
10790 + [[patch.unused]]
10791 + name = "synckit-client"
10792 + version = "0.10.0"
10793 +
10794 + [[patch.unused]]
10795 + name = "synckit-config"
10796 + version = "0.2.0"
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
167 167 # The description vocabulary quasi's screen types are built from. Pinned here
168 168 # rather than reached through quasi-router's re-export because a described
169 169 # screen names FieldKind and Tone directly; it has to track what quasi-router
@@ -179,13 +179,13 @@
179 179 # For the request head the per-viewer state factory reads. axum re-exports it,
180 180 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
181 181 http = "1.3.1"
182 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
184 184 # The first-party widget set. The carousel was proved here against three pages
185 185 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
186 186 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
187 187 # sits above quasi-router rather than inside the description suite.
188 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -277,7 +277,7 @@
277 277 # Written from the crate rather than copied into the repo for the reason the
278 278 # constants exist: a stale copy in a static directory is a silently dead bulk
279 279 # bar, and the version has to be the one the emitter agrees with.
280 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.92" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.93" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
@@ -115,25 +115,15 @@
115 115 // A document that does ask fails visibly on the first control pressed,
116 116 // and nothing here asks: every control is an external link.
117 117 //
118 - // Nine `without_` calls now. quasi 0.54.0 added the fill script,
119 - // 0.59.0 and 0.60.0 the reveal and repeat scripts, 0.68.0 the copy
120 - // script, 0.73.0 the menu script and 0.79.0 the outline script. All six
121 - // are independent of htmx by
122 - // design — what they read is attributes on a control, a region or a
123 - // fieldset, or the room a run has — so dropping the transport does not
124 - // drop them, and each has to be refused by name. An embed names no
125 - // destination field, no conditional region, no repeating question, no
126 - // act that copies, no run that folds and no list that discloses, so all
127 - // six have nothing to read here.
128 - .without_htmx()
129 - .without_hyperscript()
130 - .without_clock()
131 - .without_fill()
132 - .without_reveal()
133 - .without_repeat()
134 - .without_copy()
135 - .without_menu()
136 - .without_outline()
118 + // This was nine `without_` calls, growing by one every time quasi
119 + // added a script: 0.54.0 the fill script, 0.59.0 and 0.60.0 reveal and
120 + // repeat, 0.68.0 copy, 0.73.0 menu, 0.79.0 outline. Each is
121 + // independent of htmx by design -- what they read is attributes on a
122 + // control, a region or a fieldset -- so dropping the transport never
123 + // dropped them and each had to be refused by name. quasi 0.92.0 says
124 + // it in one, which is the list living where the scripts do rather than
125 + // at every host that wanted none.
126 + .without_scripts()
137 127 .with_chrome(Chrome::new())
138 128 .with_head_first(head_first());
139 129 Webview::new().with_shell(shell).screen(screen)
@@ -40,6 +40,7 @@
40 40
41 41 pub mod blog_delete_act;
42 42 pub mod buyer_contacts;
43 + pub mod custom_page;
43 44 pub mod discover_search;
44 45 pub mod discover_typeahead;
45 46 pub mod embeds;
@@ -14,14 +14,20 @@
14 14 //! Routing on this host (path under `u.makenot.work`):
15 15 //! `/{handle}` -> profile, `/{handle}/{project}` -> project,
16 16 //! `/{handle}/{project}/{item}` -> item. `/static/*` falls through to the
17 - //! normal app so chrome assets and primitives resolve.
17 + //! normal app, which is what serves a creator's own uploads and the favicon.
18 + //! The pages themselves link nothing from it: the strip's styling is inline
19 + //! and no script is loaded at all.
18 20 //!
19 21 //! Sanitization happens on render (Phase 2). The columns hold the creator's
20 22 //! original source; a future write-time cache can pre-sanitize, but rendering
21 23 //! through [`crate::custom_pages`] every time is the safe default and sits
22 24 //! behind a 5-minute edge cache.
25 + //!
26 + //! The three documents are described rather than templated, as of quasicoherent
27 + //! `48a6e9e5`. This module resolves and sanitizes; `crate::quasi::custom_page`
28 + //! says what the pages are and draws them. `templates/custom/` is gone with the
29 + //! change, the chrome partials included.
23 30
24 - use askama::Template;
25 31 use axum::{
26 32 body::Body,
27 33 extract::State,
@@ -34,57 +40,10 @@
34 40 config::Config,
35 41 custom_pages,
36 42 db::{self, PricingKind, Slug, Username},
43 + quasi::custom_page,
37 44 };
38 45 use sqlx::PgPool;
39 46
40 - /// One entry in a project's file-list system slot.
41 - struct SlotItem {
42 - title: String,
43 - url: String,
44 - }
45 -
46 - #[derive(Template)]
47 - #[template(path = "custom/user.html")]
48 - struct UserPageTemplate {
49 - page_title: String,
50 - apex_url: String,
51 - canonical_url: String,
52 - creator_label: String,
53 - canvas_id: String,
54 - sanitized_css: String,
55 - sanitized_html: String,
56 - }
57 -
58 - #[derive(Template)]
59 - #[template(path = "custom/project.html")]
60 - struct ProjectPageTemplate {
61 - page_title: String,
62 - apex_url: String,
63 - canonical_url: String,
64 - creator_label: String,
65 - canvas_id: String,
66 - sanitized_css: String,
67 - sanitized_html: String,
68 - price_label: String,
69 - buy_url: String,
70 - items: Vec<SlotItem>,
71 - }
72 -
73 - #[derive(Template)]
74 - #[template(path = "custom/item.html")]
75 - struct ItemPageTemplate {
76 - page_title: String,
77 - apex_url: String,
78 - canonical_url: String,
79 - creator_label: String,
80 - canvas_id: String,
81 - sanitized_css: String,
82 - item_title: String,
83 - item_description: Option<String>,
84 - price_label: String,
85 - buy_url: String,
86 - }
87 -
88 47 /// Dispatch middleware: intercept the user-pages host, pass everything else
89 48 /// (and `/static`) through to the normal app. Placed outermost so it runs
90 49 /// before the session and access-gate layers, custom pages never touch them.
@@ -163,25 +122,30 @@
163 122 sanitize_user_page(config, &user)
164 123 };
165 124
166 - render_html(user_template(
125 + render(&custom_page::user(&user_view(
167 126 &user,
168 127 apex_url,
169 128 canonical_url,
170 129 creator_label,
171 130 sanitized_html,
172 131 sanitized_css,
173 - ))
132 + )))
174 133 }
175 134
176 - fn user_template(
135 + /// What a profile page is, as the description wants it.
136 + ///
137 + /// Also the whole of what a project page's strip and canvas need, which is why
138 + /// [`custom_page::ProjectView`] carries one rather than repeating its seven
139 + /// fields.
140 + fn user_view(
177 141 user: &db::DbUser,
178 142 apex_url: String,
179 143 canonical_url: String,
180 144 creator_label: String,
181 145 sanitized_html: String,
182 146 sanitized_css: String,
183 - ) -> UserPageTemplate {
184 - UserPageTemplate {
147 + ) -> custom_page::UserView {
148 + custom_page::UserView {
185 149 page_title: format!("{creator_label} - makenot.work"),
186 150 apex_url,
187 151 canonical_url,
@@ -192,11 +156,13 @@
192 156 }
193 157 }
194 158
195 - fn render_html(template: impl Template) -> Result<Response, StatusCode> {
196 - template
197 - .render()
198 - .map(|h| Html(h).into_response())
199 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
159 + /// The described screen as a response.
160 + ///
161 + /// Infallible, unlike the `render_html` it replaces: a description is drawn
162 + /// rather than rendered from a template that can fail to parse, so there is no
163 + /// error branch left to map to a 500.
164 + fn render(screen: &quasi_router::Screen) -> Result<Response, StatusCode> {
165 + Ok(Html(custom_page::document(screen)).into_response())
200 166 }
201 167
202 168 async fn render_project(
@@ -227,52 +193,64 @@
227 193 sanitize_project_page(config, &project)
228 194 };
229 195
230 - let items = project_slot_items(db, &project, &apex_url).await;
231 - render_html(project_template(
196 + let files = project_files(db, &project, &apex_url).await;
197 + render(&custom_page::project(&project_view(
232 198 &user,
233 199 &project,
234 200 apex_url,
235 201 canonical_url,
236 202 sanitized_html,
237 203 sanitized_css,
238 - items,
239 - ))
204 + files,
205 + )))
240 206 }
241 207
242 - /// The project's published items as file-list slot entries (links to the apex).
243 - async fn project_slot_items(db: &PgPool, project: &db::DbProject, apex_url: &str) -> Vec<SlotItem> {
208 + /// The project's published items as file-list entries (links to the apex).
209 + async fn project_files(
210 + db: &PgPool,
211 + project: &db::DbProject,
212 + apex_url: &str,
213 + ) -> Vec<custom_page::File> {
244 214 db::items::get_public_items_by_project(db, project.id)
245 215 .await
246 216 .unwrap_or_default()
247 217 .into_iter()
248 - .map(|it| SlotItem {
218 + .map(|it| custom_page::File {
249 219 title: it.title,
250 220 url: format!("{apex_url}/i/{}", it.id),
251 221 })
252 222 .collect()
253 223 }
254 224
225 + /// What a project page is, as the description wants it.
226 + ///
227 + /// The title is the project's and the canvas id is the project's, so this is
228 + /// not the profile's view with two fields added: `canvas_id` in particular
229 + /// scopes the *project's* stylesheet, and passing the owner's would unstyle
230 + /// every project page.
255 231 #[allow(clippy::too_many_arguments)]
256 - fn project_template(
232 + fn project_view(
257 233 user: &db::DbUser,
258 234 project: &db::DbProject,
259 235 apex_url: String,
260 236 canonical_url: String,
261 237 sanitized_html: String,
262 238 sanitized_css: String,
263 - items: Vec<SlotItem>,
264 - ) -> ProjectPageTemplate {
265 - ProjectPageTemplate {
266 - page_title: format!("{} - makenot.work", project.title),
267 - apex_url,
268 - creator_label: display_name(user),
269 - canvas_id: project.id.to_string(),
270 - sanitized_css,
271 - sanitized_html,
239 + files: Vec<custom_page::File>,
240 + ) -> custom_page::ProjectView {
241 + custom_page::ProjectView {
242 + page: custom_page::UserView {
243 + page_title: format!("{} - makenot.work", project.title),
244 + apex_url,
245 + canonical_url: canonical_url.clone(),
246 + creator_label: display_name(user),
247 + canvas_id: project.id.to_string(),
248 + sanitized_css,
249 + sanitized_html,
250 + },
272 251 price_label: project_price_label(project),
273 - buy_url: canonical_url.clone(),
274 - canonical_url,
275 - items,
252 + buy_url: canonical_url,
253 + files,
276 254 }
277 255 }
278 256
@@ -313,14 +291,14 @@
313 291 };
314 292 let canonical_url = format!("{apex_url}/u/{}", user.username);
315 293 let label = display_name(&user);
316 - render_html(user_template(
294 + render(&custom_page::user(&user_view(
317 295 &user,
318 296 apex_url,
319 297 canonical_url,
320 298 label,
321 299 html,
322 300 css,
323 - ))
301 + )))
324 302 }
325 303 db::custom_pages::KIND_PROJECT => {
326 304 let project =
@@ -345,16 +323,16 @@
345 323 None => (String::new(), String::new()),
346 324 };
347 325 let canonical_url = format!("{apex_url}/p/{}", project.slug);
348 - let items = project_slot_items(db, &project, &apex_url).await;
349 - render_html(project_template(
326 + let files = project_files(db, &project, &apex_url).await;
327 + render(&custom_page::project(&project_view(
350 328 &user,
351 329 &project,
352 330 apex_url,
353 331 canonical_url,
354 332 html,
355 333 css,
356 - items,
357 - ))
334 + files,
335 + )))
358 336 }
359 337 _ => Err(StatusCode::NOT_FOUND),
360 338 }
@@ -397,23 +375,20 @@
397 375 sanitize_item_css(config, &project)
398 376 };
399 377
400 - let price_label = item_price_label(&item);
401 - let html = ItemPageTemplate {
378 + render(&custom_page::item(&custom_page::ItemView {
402 379 page_title: format!("{} - makenot.work", item.title),
403 380 apex_url,
404 381 canonical_url: canonical_url.clone(),
405 382 creator_label: display_name(&user),
383 + // The *project's* id: an item page wears the parent's stylesheet,
384 + // re-scoped to the item canvas by `sanitize_item_css`.
406 385 canvas_id: project.id.to_string(),
407 386 sanitized_css,
387 + price_label: item_price_label(&item),
408 388 item_title: item.title,
409 389 item_description: item.description,
410 - price_label,
411 390 buy_url: canonical_url,
412 - }
413 - .render()
414 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
415 -
416 - Ok(Html(html).into_response())
391 + }))
417 392 }
418 393
419 394 // --- Sanitization (on render) ---
@@ -1,0 +1,558 @@
1 + //! The custom-pages host, described.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! quasicoherent `48a6e9e5`, decided 2026-08-30. `u.makenot.work` serves a
6 + //! creator's own HTML and CSS with a platform strip around it. It was the last
7 + //! host in the server assembled entirely by Askama, and the reason it was last
8 + //! is that a description had no way to say either half of it: the creator's
9 + //! markup is opaque, and the stylesheet that goes with it is per request. quasi
10 + //! 0.92.0 added both -- [`Node::Canvas`] and [`Document::style`] -- and this
11 + //! module is their first consumer.
12 + //!
13 + //! # What this host is, and why its shell is its own
14 + //!
15 + //! Read `crate::routes::user_pages` for the isolation rules; they are unchanged
16 + //! by the conversion and they are what shapes the shell here:
17 + //!
18 + //! - **No script, at all.** The CSP is `default-src 'none'`, so a `<script>`
19 + //! in the head is a console error rather than a feature.
20 + //! [`Shell::without_scripts`] is the whole of it. Nothing on these pages
21 + //! calls a route: every control is a link to the apex.
22 + //! - **No linked stylesheet.** Not because the CSP forbids it -- `style-src`
23 + //! allows `'self'` -- but because linking the platform sheet would pull
24 + //! every platform class into scope on the one surface built for creator
25 + //! control. Settled 2026-08-16 and unchanged here. [`CHROME_CSS`] arrives
26 + //! through [`Shell::with_head_first`], the way an embed's design system
27 + //! does.
28 + //! - **No chrome and no session.** These are cookieless documents for readers
29 + //! who are not signed in, so `Chrome::new()` and ordinary axum handlers, as
30 + //! in [`super::embeds`].
31 + //!
32 + //! # The strip is content here, not `Chrome`
33 + //!
34 + //! A [`quasi_router::Chrome`] is built once and held beside the router, and
35 + //! every link in this strip is per request: the canonical URL and the creator's
36 + //! name change with the page. So the header and footer are regions on the
37 + //! screen, [`RegionKind::Band`] each, which is what a band is -- a full-width
38 + //! strip with a title and an actions cluster.
39 + //!
40 + //! They are siblings of the canvas rather than ancestors, which is the whole of
41 + //! the isolation guarantee and is unchanged: `css_sanitizer` re-emits every
42 + //! creator rule nested inside `.user-canvas#uc-{owner}`, so a creator selector
43 + //! naming anything in the strip is emitted under the canvas and matches
44 + //! nothing. No specificity contest, no layer, no `!important`. See
45 + //! `css_sanitizer::platform_chrome_is_unreachable_from_creator_css`.
46 + //!
47 + //! # Every link out of here is [`Action::leaving`]
48 + //!
49 + //! quasi 0.93.0. These pages are a satellite of the apex and the strip exists
50 + //! to send readers to it, so the links replace the page rather than opening
51 + //! tabs, which is what they did as Askama and what `Destination::External`
52 + //! could not say.
53 + //!
54 + //! # The published class names survive the conversion
55 + //!
56 + //! `site-docs/public/guide/custom-pages.md` tells creators that the buy block
57 + //! is `.mnw-buy`, the file list `.mnw-files` and the item block `.mnw-item`,
58 + //! and gives worked CSS against them. Those are a contract with people outside
59 + //! this codebase, so they are carried on [`Node::Canvas`] scopes -- described
60 + //! content under a name the app has promised, which is the second use that
61 + //! member documents. Every class the strip itself uses is this renderer's and
62 + //! [`CHROME_CSS`] is written against them, because nobody outside was ever
63 + //! promised those.
64 + //!
65 + //! What did change is the markup *inside* those blocks, and it had to: a price
66 + //! is a described node now rather than a hand-written div. The one place that
67 + //! is visible to a creator is the call to action, which was an `<a class=
68 + //! "mnw-buy-cta">` and is now that class on the element around the link. The
69 + //! guide's own example sets `color` on it, and a `color` on an ancestor does
70 + //! not reach an anchor -- the UA sheet beats inheritance -- so [`CHROME_CSS`]
71 + //! says `color: inherit` for a link inside one. Without that line the documented
72 + //! example would quietly stop working.
73 +
74 + use quasi_axum::Serves as _;
75 + use quasi_router::screen::{Canvas, Document, Row};
76 + use quasi_router::{Action, Chrome, Node, RegionKind, Screen, Slot, layout};
77 + use quasi_webview::{Shell, Webview};
78 +
79 + /// The platform strip's styling, and the system slots' defaults.
80 + ///
81 + /// Inline rather than linked, and the argument is `crate::routes::user_pages`'s
82 + /// rather than this module's: ~1.3KB against a second request, and a fetch that
83 + /// fails leaves the strip unstyled while the creator's inline CSS still renders.
84 + /// Inline cannot fail that way.
85 + ///
86 + /// Written against the classes and ids quasi emits, with two exceptions that
87 + /// are deliberate and are named in the module header: the `mnw-` names are a
88 + /// published contract with creators, and `#chrome-top` / `#chrome-bottom` are
89 + /// region ids this module chose.
90 + const CHROME_CSS: &str = "\
91 + <style>\
92 + html,body{margin:0;padding:0}\
93 + body{font-family:system-ui,-apple-system,\"Segoe UI\",sans-serif}\
94 + #chrome-top,#chrome-bottom{background:#161616;color:#e9e9e9;font-size:.85rem;line-height:1.4}\
95 + #chrome-top{display:flex;justify-content:space-between;align-items:center;gap:1rem;\
96 + padding:.55rem 1rem;border-bottom:1px solid #2c2c2c}\
97 + #chrome-top a,#chrome-bottom a{color:#e9e9e9;text-decoration:none}\
98 + #chrome-top a:hover,#chrome-bottom a:hover{text-decoration:underline}\
99 + #chrome-top a:first-child{font-weight:600;letter-spacing:.01em}\
100 + #chrome-bottom{padding:1rem;text-align:center;border-top:1px solid #2c2c2c;color:#9a9a9a}\
101 + .mnw-buy,.mnw-item{margin:1.5rem 0;padding:1rem;border:1px solid currentColor;border-radius:4px}\
102 + .mnw-buy-cta,.mnw-item-cta{display:inline-block;margin-top:.5rem;padding:.4rem .9rem;\
103 + border:1px solid currentColor;border-radius:4px;font-weight:600}\
104 + .mnw-buy-cta a,.mnw-item-cta a{color:inherit;text-decoration:none}\
105 + .mnw-files ul{margin:0;padding:0;list-style:none}\
106 + .mnw-files{margin:1.5rem 0}\
107 + .mnw-files li{padding:.35rem 0;border-bottom:1px solid currentColor}\
108 + .mnw-price{font-weight:600}\
109 + </style>";
110 +
111 + /// One entry in a project's file list.
112 + pub struct File {
113 + /// What the file is called.
114 + pub title: String,
115 + /// Where it is on the apex.
116 + pub url: String,
117 + }
118 +
119 + /// Everything a creator profile draws from.
120 + pub struct UserView {
121 + /// The document's title.
122 + pub page_title: String,
123 + /// The apex, for the brand link.
124 + pub apex_url: String,
125 + /// This page's home on the apex.
126 + pub canonical_url: String,
127 + /// What to call the creator.
128 + pub creator_label: String,
129 + /// The owner id the sanitiser scoped the CSS to.
130 + pub canvas_id: String,
131 + /// The creator's stylesheet, sanitised.
132 + pub sanitized_css: String,
133 + /// The creator's markup, sanitised.
134 + pub sanitized_html: String,
135 + }
136 +
137 + /// Everything a project page draws from.
138 + pub struct ProjectView {
139 + /// The profile half, which a project page carries all of.
140 + pub page: UserView,
141 + /// What the project costs, as a sentence.
142 + pub price_label: String,
143 + /// Where buying happens.
144 + pub buy_url: String,
145 + /// The project's published items.
146 + pub files: Vec<File>,
147 + }
148 +
149 + /// Everything an item page draws from.
150 + ///
151 + /// No creator markup of any kind: an item page is the trustworthy default
152 + /// layout wearing the parent project's CSS, re-scoped to the item canvas. It
153 + /// takes the head half of the conversion and no part of [`Node::Canvas`]'s
154 + /// markup, which is why it was the one to convert first.
155 + pub struct ItemView {
156 + /// The document's title.
157 + pub page_title: String,
158 + /// The apex, for the brand link.
159 + pub apex_url: String,
160 + /// This page's home on the apex.
161 + pub canonical_url: String,
162 + /// What to call the creator.
163 + pub creator_label: String,
164 + /// The project id the parent's CSS was re-scoped to.
165 + pub canvas_id: String,
166 + /// The parent project's stylesheet, re-scoped to the item canvas.
167 + pub sanitized_css: String,
168 + /// The item's name.
169 + pub item_title: String,
170 + /// What the creator said about it, if anything.
171 + pub item_description: Option<String>,
172 + /// What the item costs, as a sentence.
173 + pub price_label: String,
174 + /// Where buying happens.
175 + pub buy_url: String,
176 + }
177 +
178 + /// The whole document for one of these screens.
179 + ///
180 + /// [`super::embeds::document`]'s shape and for its reasons, with one addition:
181 + /// the screen carries a stylesheet of its own, which the renderer writes last
182 + /// in the head and outside the layer statement. That is what puts the creator's
183 + /// CSS on top of [`CHROME_CSS`] without either of them naming a layer.
184 + #[must_use]
185 + pub fn document(screen: &Screen) -> String {
186 + let shell = Shell::default()
187 + .without_scripts()
188 + .with_chrome(Chrome::new())
189 + .with_head_first(CHROME_CSS);
190 + Webview::new().with_shell(shell).screen(screen)
191 + }
192 +
193 + /// A creator's profile.
194 + #[must_use]
195 + pub fn user(view: &UserView) -> Screen {
196 + strip(
197 + Screen::single(&view.page_title)
198 + .documented(Document::default().styled(&view.sanitized_css)),
199 + view,
200 + )
201 + .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(canvas(view)))))
202 + .with(footer(view))
203 + }
204 +
205 + /// A creator's project page: their markup, then the platform's own blocks
206 + /// inside the same scope.
207 + #[must_use]
208 + pub fn project(view: &ProjectView) -> Screen {
209 + let page = &view.page;
210 + // Inside the canvas and after the markup, which is what the Askama template
211 + // did and is what lets a creator's sheet reach these blocks at all. Outside
212 + // would be a different page, quietly.
213 + let mut scope = canvas(page).with(slot_block(
214 + "mnw-buy",
215 + "mnw-buy-cta",
216 + &view.price_label,
217 + &view.buy_url,
218 + ));
219 + if !view.files.is_empty() {
220 + scope = scope.with(Node::Canvas(Box::new(
221 + Canvas::default().classed("mnw-files").with(Node::List {
222 + rows: view
223 + .files
224 + .iter()
225 + .map(|file| Row::new(&file.title).activate(Action::leaving(&file.url)))
226 + .collect(),
227 + more: None,
228 + }),
229 + )));
230 + }
231 +
232 + strip(
233 + Screen::single(&page.page_title)
234 + .documented(Document::default().styled(&page.sanitized_css)),
235 + page,
236 + )
237 + .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(scope))))
238 + .with(footer(page))
239 + }
240 +
241 + /// An item page, which is the default layout in the parent project's clothes.
242 + #[must_use]
243 + pub fn item(view: &ItemView) -> Screen {
244 + let mut block = Canvas::default().classed("mnw-item").with(Node::Heading {
245 + level: layout::Heading::Page,
246 + text: view.item_title.clone(),
247 + });
248 + if let Some(description) = &view.item_description {
249 + block = block.with(Node::text(description));
250 + }
251 + let block = block
252 + .with(price(&view.price_label))
253 + .with(cta("mnw-item-cta", &view.buy_url));
254 +
255 + let page = UserView {
256 + page_title: view.page_title.clone(),
257 + apex_url: view.apex_url.clone(),
258 + canonical_url: view.canonical_url.clone(),
259 + creator_label: view.creator_label.clone(),
260 + canvas_id: view.canvas_id.clone(),
261 + sanitized_css: view.sanitized_css.clone(),
262 + sanitized_html: String::new(),
263 + };
264 +
265 + strip(
266 + Screen::single(&view.page_title)
267 + .documented(Document::default().styled(&view.sanitized_css)),
268 + &page,
269 + )
270 + // `item-canvas` and `ic-`, which is what `sanitize_item_css` scopes to. The
271 + // two names travel together and neither is this module's to choose.
272 + .with(
273 + Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(
274 + Canvas::default()
275 + .classed("item-canvas")
276 + .identified(format!("ic-{}", view.canvas_id))
277 + .with(Node::Canvas(Box::new(block))),
278 + ))),
279 + )
280 + .with(footer(&page))
281 + }
282 +
283 + /// The creator's markup under the scope its stylesheet was rewritten for.
284 + ///
285 + /// `.user-canvas#uc-{owner}` is `css_sanitizer::sanitize_css`'s, not this
286 + /// module's: the class and the id are what every creator rule was nested
287 + /// inside, so a name changed here silently unstyles every custom page.
288 + fn canvas(view: &UserView) -> Canvas {
289 + Canvas::new(&view.sanitized_html)
290 + .classed("user-canvas")
291 + .identified(format!("uc-{}", view.canvas_id))
292 + }
293 +
294 + /// The platform strip across the top.
295 + fn strip(screen: Screen, view: &UserView) -> Screen {
296 + screen.with(
297 + Slot::new("chrome-top", RegionKind::Band)
298 + .with(Node::Link {
299 + text: "makenot.work".into(),
300 + action: Action::leaving(&view.apex_url),
301 + })
302 + .with(Node::Link {
303 + text: "View on makenot.work".into(),
304 + action: Action::leaving(&view.canonical_url),
305 + }),
306 + )
307 + }
308 +
309 + /// The platform strip across the bottom.
310 + fn footer(view: &UserView) -> Slot {
311 + Slot::new("chrome-bottom", RegionKind::Band).with(Node::Link {
312 + text: format!("{} on makenot.work", view.creator_label),
313 + action: Action::leaving(&view.canonical_url),
314 + })
315 + }
316 +
317 + /// A system slot: a price and the way to buy, under a published class name.
318 + fn slot_block(block_class: &str, cta_class: &str, price_label: &str, buy_url: &str) -> Node {
319 + Node::Canvas(Box::new(
320 + Canvas::default()
321 + .classed(block_class)
322 + .with(price(price_label))
323 + .with(cta(cta_class, buy_url)),
324 + ))
325 + }
326 +
327 + /// What it costs, under the class the guide names.
328 + fn price(label: &str) -> Node {
329 + Node::Canvas(Box::new(
330 + Canvas::default()
331 + .classed("mnw-price")
332 + .with(Node::text(label)),
333 + ))
334 + }
335 +
336 + /// The way to buy, under the class the guide names.
337 + fn cta(class: &str, buy_url: &str) -> Node {
338 + Node::Canvas(Box::new(Canvas::default().classed(class).with(
339 + Node::Link {
340 + text: "Get on makenot.work".into(),
341 + action: Action::leaving(buy_url),
342 + },
343 + )))
344 + }
345 +
346 + #[cfg(test)]
347 + mod tests {
348 + use super::*;
349 +
350 + /// Everything from the open `<body>` tag on.
351 + ///
352 + /// Every assertion about what is *drawn* uses this rather than the whole
353 + /// document, because `CHROME_CSS` names `.mnw-files` and `.mnw-price` in
354 + /// the head: a search over the document finds the rule and concludes the
355 + /// block is on the page.
356 + fn body(html: &str) -> &str {
357 + &html[html.find("<body").expect("a document has a body")..]
358 + }
359 +
360 + fn a_user() -> UserView {
361 + UserView {
362 + page_title: "Ada - makenot.work".into(),
363 + apex_url: "https://makenot.work".into(),
364 + canonical_url: "https://makenot.work/u/ada".into(),
365 + creator_label: "Ada".into(),
366 + canvas_id: "11111111".into(),
367 + sanitized_css: ".user-canvas#uc-11111111 p{color:red}".into(),
368 + sanitized_html: "<h1>Ada</h1>".into(),
369 + }
370 + }
371 +
372 + #[test]
373 + fn the_canvas_carries_the_scope_the_sanitiser_rewrote_every_rule_for() {
374 + // `css_sanitizer` nests every creator rule inside
375 + // `.user-canvas#uc-{owner}`. If the element stops carrying both halves,
376 + // every custom page silently loses its styling, so this is the
377 + // load-bearing assertion in the module.
378 + let html = document(&user(&a_user()));
379 +
380 + assert!(
381 + html.contains("<div class=\"user-canvas\" id=\"uc-11111111\"><h1>Ada</h1></div>"),
382 + "{html}"
383 + );
384 + }
385 +
386 + #[test]
387 + fn the_creators_stylesheet_is_in_the_head_after_the_platforms() {
388 + // Both are inline and neither is layered, so the later one wins. The
389 + // creator's is the screen's own and the renderer writes it last.
390 + let html = document(&user(&a_user()));
391 + let chrome = html.find("#chrome-top").expect("the strip is styled");
392 + let creator = html
393 + .find(".user-canvas#uc-11111111 p{color:red}")
394 + .expect("the creator's sheet is written");
395 + let head_end = html.find("</head>").expect("the head closes");
396 +
397 + assert!(chrome < creator, "{html}");
398 + assert!(creator < head_end, "{html}");
399 + }
400 +
401 + #[test]
402 + fn the_strip_is_a_sibling_of_the_canvas_and_not_an_ancestor() {
403 + // The whole isolation guarantee. A creator selector naming anything in
404 + // the strip is emitted under the canvas and matches nothing, which only
405 + // holds while the strip is outside it.
406 + let html = document(&user(&a_user()));
407 + let body = body(&html);
408 + let top = body.find("id=\"chrome-top\"").expect("the top strip");
409 + let canvas = body.find("class=\"user-canvas\"").expect("the canvas");
410 + let bottom = body.find("id=\"chrome-bottom\"").expect("the bottom strip");
411 +
412 + assert!(top < canvas && canvas < bottom, "{body}");
413 + // Not nested: the canvas region closes before the footer opens.
414 + assert!(!body[canvas..bottom].contains("id=\"chrome-"), "{body}");
415 + }
416 +
417 + #[test]
418 + fn no_script_reaches_a_page_whose_csp_forbids_every_one() {
419 + // `default-src 'none'`. A script element here is a console error.
420 + let html = document(&user(&a_user()));
421 +
422 + assert!(!html.contains("<script"), "{html}");
423 + assert!(!html.contains("<link"), "{html}");
424 + }
425 +
426 + #[test]
427 + fn every_way_off_this_page_replaces_it_rather_than_opening_a_tab() {
428 + // These pages are a satellite of the apex and the strip exists to send
429 + // readers to it. This is what the Askama version did.
430 + let html = document(&user(&a_user()));
431 +
432 + assert!(!html.contains("target=\"_blank\""), "{html}");
433 + assert!(html.contains("href=\"https://makenot.work\""), "{html}");
434 + }
435 +
436 + #[test]
437 + fn the_published_system_slot_names_survive_the_conversion() {
438 + // `site-docs/public/guide/custom-pages.md` names these to creators and
439 + // gives worked CSS against them, so they are a contract with people
440 + // outside this codebase rather than class names this module picked.
441 + let view = ProjectView {
442 + page: a_user(),
443 + price_label: "$12".into(),
444 + buy_url: "https://makenot.work/p/thing".into(),
445 + files: vec![File {
446 + title: "one.wav".into(),
447 + url: "https://makenot.work/i/1".into(),
448 + }],
449 + };
450 + let html = document(&project(&view));
451 +
452 + assert!(html.contains("class=\"mnw-buy\""), "{html}");
453 + assert!(html.contains("class=\"mnw-buy-cta\""), "{html}");
454 + assert!(html.contains("class=\"mnw-price\""), "{html}");
455 + assert!(html.contains("class=\"mnw-files\""), "{html}");
456 + }
457 +
458 + #[test]
459 + fn the_platform_blocks_sit_inside_the_creators_scope_after_their_markup() {
460 + // What the Askama template did, and what lets a creator's sheet reach
461 + // the buy block at all. Outside would be a different page, quietly.
462 + let view = ProjectView {
463 + page: a_user(),
464 + price_label: "$12".into(),
465 + buy_url: "https://makenot.work/p/thing".into(),
466 + files: Vec::new(),
467 + };
468 + let html = document(&project(&view));
469 + let canvas = html
470 + .find("class=\"user-canvas\"")
471 + .expect("the canvas opens");
472 + let markup = html.find("<h1>Ada</h1>").expect("the creator's markup");
473 + let buy = html.find("class=\"mnw-buy\"").expect("the buy block");
474 +
475 + assert!(canvas < markup && markup < buy, "{html}");
476 + }
477 +
478 + #[test]
479 + fn a_project_with_no_published_items_draws_no_file_list() {
480 + // The template guarded on `items.is_empty()`, and an empty bordered box
481 + // is worse than no box.
482 + let view = ProjectView {
483 + page: a_user(),
484 + price_label: "Free".into(),
485 + buy_url: "https://makenot.work/p/thing".into(),
486 + files: Vec::new(),
487 + };
488 +
489 + assert!(!body(&document(&project(&view))).contains("mnw-files"));
490 + }
491 +
492 + #[test]
493 + fn an_item_page_wears_the_parents_stylesheet_under_the_item_canvas() {
494 + // `sanitize_item_css` scopes to `.item-canvas#ic-{project}`, so the two
495 + // names travel together and neither is this module's to choose. An item
496 + // page has no creator markup of its own.
497 + let html = document(&item(&ItemView {
498 + page_title: "Track - makenot.work".into(),
499 + apex_url: "https://makenot.work".into(),
500 + canonical_url: "https://makenot.work/i/9".into(),
Lines truncated
@@ -1,66 +1,0 @@
1 - <style>
2 - /* Platform chrome for custom pages. Lives outside the user canvas, so creator
3 - CSS (scoped to .user-canvas / .item-canvas) can never restyle or hide it.
4 - Deliberately minimal and theme-independent.
5 -
6 - STAYS INLINE. Asked and answered 2026-08-16.
7 -
8 - The guarantee is structural, not a cascade contest. css_sanitizer.rs
9 - re-emits every creator rule nested inside .user-canvas#uc-{owner}; the
10 - header and footer are siblings of that canvas, so a creator selector
11 - naming .mnw-chrome is emitted under the canvas and matches nothing. No
12 - specificity, layer or !important is involved, which is why this block
13 - needs none. Demonstrated by css_sanitizer.rs's
14 - platform_chrome_is_unreachable_from_creator_css, which walks eight escape
15 - attempts including `*`, `html body .mnw-chrome-brand` and `:root`.
16 -
17 - So a cascade layer would buy nothing here, and these pages have none to
18 - join: custom/*.html are standalone documents that do not go through
19 - shell.rs, link no stylesheet, and never emit the `@layer makeover, base,
20 - components, responsive` statement. Moving these lines into style.css
21 - would pull the whole platform sheet onto every creator page and put every
22 - platform class in scope on the one surface built for creator control.
23 -
24 - A separate linked chrome.css would be cacheable, and is still not worth
25 - it: ~1.3KB against a second request, and a fetch that fails leaves the
26 - chrome unstyled while the creator's inline CSS still renders. Inline
27 - cannot fail that way. Revisit only if this block grows past the point
28 - where its bytes beat the round trip. */
29 - html, body { margin: 0; padding: 0; }
30 - /* NOT the house font, and deliberately so. The typography standard covers
31 - MNW's own frontends; this is a creator's document with our chrome strip on
32 - it, and the body element belongs to them. Pulling Quasi Body in here would
33 - restyle their page and fetch 81KB onto it to do so, neither of which they
34 - asked for. The chrome bar below inherits it for the same reason: one face
35 - on the page is the creator's choice, and two would be worse than a
36 - compromise. */
37 - body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
38 - .mnw-chrome, .mnw-chrome-footer {
39 - background: #161616; color: #e9e9e9; font-size: .85rem; line-height: 1.4;
40 - }
41 - .mnw-chrome {
42 - display: flex; justify-content: space-between; align-items: center;
43 - gap: 1rem; padding: .55rem 1rem; border-bottom: 1px solid #2c2c2c;
44 - }
45 - .mnw-chrome a, .mnw-chrome-footer a { color: #e9e9e9; text-decoration: none; }
46 - .mnw-chrome a:hover, .mnw-chrome-footer a:hover { text-decoration: underline; }
47 - .mnw-chrome-brand { font-weight: 600; letter-spacing: .01em; }
48 - .mnw-chrome-actions { display: flex; gap: 1rem; }
49 - .mnw-chrome-footer {
50 - padding: 1rem; text-align: center; border-top: 1px solid #2c2c2c;
51 - color: #9a9a9a;
52 - }
53 - /* Canvas baseline so an unstyled page is still readable. */
54 - .user-canvas, .item-canvas { min-height: 50vh; padding: 1rem; box-sizing: border-box; }
55 - /* System slots: visible defaults the creator can restyle but not remove. */
56 - .mnw-buy, .mnw-item {
57 - margin: 1.5rem 0; padding: 1rem; border: 1px solid currentColor; border-radius: 4px;
58 - }
59 - .mnw-buy-cta, .mnw-item-cta {
60 - display: inline-block; margin-top: .5rem; padding: .4rem .9rem;
61 - border: 1px solid currentColor; border-radius: 4px; font-weight: 600;
62 - }
63 - .mnw-files { margin: 1.5rem 0; padding: 0; list-style: none; }
64 - .mnw-files li { padding: .35rem 0; border-bottom: 1px solid currentColor; }
65 - .mnw-price { font-weight: 600; }
66 - </style>
@@ -1,3 +1,0 @@
1 - <footer class="mnw-chrome-footer">
2 - <a href="{{ canonical_url }}">{{ creator_label }} on makenot.work</a>
3 - </footer>
@@ -1,6 +1,0 @@
1 - <header class="mnw-chrome">
2 - <a class="mnw-chrome-brand" href="{{ apex_url }}">makenot.work</a>
3 - <nav class="mnw-chrome-actions">
4 - <a href="{{ canonical_url }}">View on makenot.work</a>
5 - </nav>
6 - </header>
@@ -1,24 +1,0 @@
1 - <!DOCTYPE html>
2 - <html lang="en">
3 - <head>
4 - <meta charset="utf-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1">
6 - <title>{{ page_title }}</title>
7 - {% include "custom/_chrome_style.html" %}
8 - <style>{{ sanitized_css|safe }}</style>
9 - </head>
10 - <body>
11 - {% include "custom/_header.html" %}
12 - {# Item pages have no creator HTML: the trustworthy default layout wears the
13 - parent project's CSS, re-scoped to the item canvas. #}
14 - <div class="item-canvas" id="ic-{{ canvas_id }}">
15 - <div class="mnw-item">
16 - <h1>{{ item_title }}</h1>
17 - {% if let Some(desc) = item_description %}<p>{{ desc }}</p>{% endif %}
18 - <div class="mnw-price">{{ price_label }}</div>
19 - <a class="mnw-item-cta" href="{{ buy_url }}">Get on makenot.work</a>
20 - </div>
21 - </div>
22 - {% include "custom/_footer.html" %}
23 - </body>
24 - </html>
@@ -1,28 +1,0 @@
1 - <!DOCTYPE html>
2 - <html lang="en">
3 - <head>
4 - <meta charset="utf-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1">
6 - <title>{{ page_title }}</title>
7 - {% include "custom/_chrome_style.html" %}
8 - <style>{{ sanitized_css|safe }}</style>
9 - </head>
10 - <body>
11 - {% include "custom/_header.html" %}
12 - <div class="user-canvas" id="uc-{{ canvas_id }}">
13 - {{ sanitized_html|safe }}
14 - <div class="mnw-buy">
15 - <div class="mnw-price">{{ price_label }}</div>
16 - <a class="mnw-buy-cta" href="{{ buy_url }}">Get on makenot.work</a>
17 - </div>
18 - {% if !items.is_empty() %}
19 - <ul class="mnw-files">
20 - {% for it in items %}
21 - <li><a href="{{ it.url }}">{{ it.title }}</a></li>
22 - {% endfor %}
23 - </ul>
24 - {% endif %}
25 - </div>
26 - {% include "custom/_footer.html" %}
27 - </body>
28 - </html>
@@ -1,17 +1,0 @@
1 - <!DOCTYPE html>
2 - <html lang="en">
3 - <head>
4 - <meta charset="utf-8">
5 - <meta name="viewport" content="width=device-width, initial-scale=1">
6 - <title>{{ page_title }}</title>
7 - {% include "custom/_chrome_style.html" %}
8 - <style>{{ sanitized_css|safe }}</style>
9 - </head>
10 - <body>
11 - {% include "custom/_header.html" %}
12 - <div class="user-canvas" id="uc-{{ canvas_id }}">
13 - {{ sanitized_html|safe }}
14 - </div>
15 - {% include "custom/_footer.html" %}
16 - </body>
17 - </html>