Skip to main content

max / quasi

Shell states the cascade layer order, and gains a head_first slot The MNW server cannot take Shell for its document chrome until the layer statement can precede the stylesheet links, and `head` is appended last by design. That was filed as "add a head_first slot", with the design question attached: is a general first-slot right, or does the @layer statement belong to the renderer, which knows the layer names it emits into? Neither, exactly. The statement names four layers and only one of them is the renderer's: `makeover` is ours, `base`, `components` and `responsive` are the app's, and makeover-webview's own doc already rules that the app declares the order. So the renderer cannot own the statement outright. What it can own is the part a host gets wrong. A layer's position is fixed where its name is FIRST seen, so with no statement the generated stylesheets establish `makeover` by load order, and reordering two link tags silently reorders the cascade. The CSS stays valid and buttons look subtly wrong. That is the class of thing this module's header says lives here: a host that could get it wrong is a host that can diverge. So `app_layers` is a typed field, the host names only its own layers, and the renderer always emits `@layer makeover, ...;` before any stylesheet. Emitted even when the vec is empty, because an app that declares no layers is the case where forgetting is easiest, and `@layer makeover;` is the whole fix there. Layer names are filtered to CSS identifier characters rather than escaped. The statement is inside a <style> element, where HTML escaping does not apply, so a stray `<` would be a way out of the element rather than a character in a name. `head_first` is the general slot, and it is still needed: a font preload's whole value is being early, and `head` lands after the sheets it was meant to race. Both slots append across repeated calls, matching with_head.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 02:36 UTC
Signed with PGP, not checked
Commit: 1f2f512da32b0e05e8e9ad7c1256dad775c8b6f3
Parent: ba79bdb
3 files changed, +177 insertions, -8 deletions
M Cargo.lock +8 -8
@@ -4888,6 +4888,14 @@
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 +
4891 4899 [[patch.unused]]
4892 4900 name = "kberg"
4893 4901 version = "0.1.0"
@@ -4899,11 +4907,3 @@
4899 4907 [[patch.unused]]
4900 4908 name = "tagtree"
4901 4909 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"
@@ -38,6 +38,41 @@
38 38 pub morph_src: Option<String>,
39 39 /// Stylesheets, in link order.
40 40 pub stylesheets: Vec<String>,
41 + /// The app's own cascade layer names, in priority order, lowest first.
42 + ///
43 + /// The renderer always emits the layer statement, with `makeover` first and
44 + /// these after it, before any stylesheet link:
45 + ///
46 + /// ```css
47 + /// @layer makeover, base, components, responsive;
48 + /// ```
49 + ///
50 + /// It is emitted even when this is empty, because the point is not the
51 + /// app's names but `makeover`'s position. A layer's place in the cascade is
52 + /// fixed where its name is FIRST seen, so with no statement the generated
53 + /// stylesheets establish `makeover` simply by loading first, and reordering
54 + /// two link tags silently reorders the cascade.
55 + ///
56 + /// This is a field rather than something the host writes into
57 + /// [`head_first`](Self::head_first) because it is exactly the class of
58 + /// thing the module header says belongs here: a host that could get it
59 + /// wrong is a host that can diverge, and getting it wrong is silent. The
60 + /// CSS stays valid, the minifier stays happy, and buttons and badges look
61 + /// subtly wrong. Only the app's own names are the host's to supply, since
62 + /// the renderer cannot know them.
63 + ///
64 + /// Names are filtered to CSS identifier characters. A name is markup inside
65 + /// a `<style>` element, where HTML escaping does not apply, so a stray `<`
66 + /// would be a way out of the element rather than a character.
67 + pub app_layers: Vec<String>,
68 + /// Markup emitted near the top of the head verbatim, before the layer
69 + /// statement and every stylesheet. Not escaped.
70 + ///
71 + /// For the things whose whole value is being early: a font preload, a
72 + /// preconnect. [`head`](Self::head) is appended last and cannot serve them,
73 + /// and a preload discovered after the stylesheets it races is a preload
74 + /// that bought nothing.
75 + pub head_first: Option<String>,
41 76 /// Markup appended to the head verbatim. Not escaped.
42 77 ///
43 78 /// The escape hatch for what no description will ever name: a favicon, a
@@ -55,6 +90,8 @@
55 90 htmx_src: "/static/htmx.min.js".into(),
56 91 morph_src: Some("/static/idiomorph-ext.min.js".into()),
57 92 stylesheets: Vec::new(),
93 + app_layers: Vec::new(),
94 + head_first: None,
58 95 head: None,
59 96 body_class: None,
60 97 }
@@ -83,6 +120,32 @@
83 120 self
84 121 }
85 122
123 + /// Name the app's cascade layers, in priority order, lowest first.
124 + ///
125 + /// `makeover` is not named here; the renderer puts it first on its own.
126 + #[must_use]
127 + pub fn layered<I, S>(mut self, names: I) -> Self
128 + where
129 + I: IntoIterator<Item = S>,
130 + S: Into<String>,
131 + {
132 + self.app_layers = names.into_iter().map(Into::into).collect();
133 + self
134 + }
135 +
136 + /// Prepend markup to the head, chaining. Not escaped.
137 + ///
138 + /// Repeated calls append to each other, so the emitted order matches the
139 + /// call order, the way [`with_head`](Self::with_head) already does.
140 + #[must_use]
141 + pub fn with_head_first(mut self, markup: impl Into<String>) -> Self {
142 + self.head_first = Some(match self.head_first.take() {
143 + Some(existing) => format!("{existing}{}", markup.into()),
144 + None => markup.into(),
145 + });
146 + self
147 + }
148 +
86 149 /// Append markup to the head, chaining. Not escaped.
87 150 #[must_use]
88 151 pub fn with_head(mut self, markup: impl Into<String>) -> Self {
@@ -126,6 +189,27 @@
126 189 // `script-src` with no `unsafe-inline`.
127 190 out.push_str(quasi_http::htmx::CONFIG_META);
128 191
192 + if let Some(first) = &self.head_first {
193 + out.push_str(first);
194 + }
195 +
196 + // Before every stylesheet, or the statement is not a statement. See
197 + // `app_layers`.
198 + out.push_str("<style>@layer ");
199 + out.push_str(makeover_webview::CSS_LAYER);
200 + for name in &self.app_layers {
201 + let name: String = name
202 + .chars()
203 + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
204 + .collect();
205 + if name.is_empty() {
206 + continue;
207 + }
208 + out.push_str(", ");
209 + out.push_str(&name);
210 + }
211 + out.push_str(";</style>");
212 +
129 213 for href in &self.stylesheets {
130 214 out.push_str("<link rel=\"stylesheet\" href=\"");
131 215 out.push_str(&escape(href));
@@ -842,6 +842,91 @@
842 842 assert!(icon < html.find("</head>").expect("the head closes"));
843 843 }
844 844
845 + #[test]
846 + fn the_layer_statement_precedes_every_stylesheet() {
847 + // The whole point: a layer's position is fixed where its name is first
848 + // seen, so a statement after the links is not a statement.
849 + let shell = Shell::default()
850 + .layered(["base", "components", "responsive"])
851 + .styled("/geometry.css")
852 + .styled("/style.css");
853 + let html = Webview::new()
854 + .with_shell(shell)
855 + .screen(&Screen::list_detail("A", false));
856 +
857 + let stmt = html
858 + .find("@layer makeover, base, components, responsive;")
859 + .expect("the order is stated");
860 + let first_sheet = html.find("/geometry.css").expect("the sheet is linked");
861 + assert!(stmt < first_sheet);
862 + }
863 +
864 + #[test]
865 + fn the_layer_statement_is_emitted_even_with_no_app_layers() {
866 + // An app that names no layers of its own still needs makeover pinned to the
867 + // bottom of the cascade, and that is the case where forgetting is easiest.
868 + let html = Webview::new().screen(&Screen::list_detail("A", false));
869 + assert!(html.contains("@layer makeover;"));
870 + }
871 +
872 + #[test]
873 + fn a_layer_name_cannot_escape_the_style_element() {
874 + // HTML escaping does not apply inside <style>, so a `<` here would be a way
875 + // out of the element rather than a character in a name.
876 + let shell = Shell::default().layered(["base</style><script>alert(1)</script>"]);
877 + let html = Webview::new()
878 + .with_shell(shell)
879 + .screen(&Screen::list_detail("A", false));
880 +
881 + assert!(!html.contains("<script>alert(1)"));
882 + assert!(html.contains("@layer makeover, basestylescriptalert1script;"));
883 + }
884 +
885 + #[test]
886 + fn head_first_markup_lands_before_the_layer_statement_and_the_sheets() {
887 + // A preload discovered after the stylesheets it races bought nothing.
888 + let shell = Shell::default()
889 + .with_head_first("<link rel=\"preload\" href=\"/f.woff2\" as=\"font\">")
890 + .styled("/style.css");
891 + let html = Webview::new()
892 + .with_shell(shell)
893 + .screen(&Screen::list_detail("A", false));
894 +
895 + let preload = html.find("/f.woff2").expect("the font is preloaded");
896 + let stmt = html.find("@layer makeover").expect("the order is stated");
897 + let sheet = html.find("/style.css").expect("the sheet is linked");
898 + assert!(preload < stmt);
899 + assert!(stmt < sheet);
900 + }
901 +
902 + #[test]
903 + fn head_first_and_head_are_different_ends_of_the_same_head() {
904 + let shell = Shell::default()
905 + .with_head_first("<meta name=\"first\">")
906 + .with_head("<meta name=\"last\">");
907 + let html = Webview::new()
908 + .with_shell(shell)
909 + .screen(&Screen::list_detail("A", false));
910 +
911 + let first = html.find("name=\"first\"").expect("first is emitted");
912 + let last = html.find("name=\"last\"").expect("last is emitted");
913 + let htmx = html.find("htmx.min.js").expect("htmx is linked");
914 + assert!(first < htmx);
915 + assert!(htmx < last);
916 + }
917 +
918 + #[test]
919 + fn repeated_head_first_calls_keep_their_call_order() {
920 + let shell = Shell::default()
921 + .with_head_first("<meta name=\"a\">")
922 + .with_head_first("<meta name=\"b\">");
923 + let html = Webview::new()
924 + .with_shell(shell)
925 + .screen(&Screen::list_detail("A", false));
926 +
927 + assert!(html.find("name=\"a\"") < html.find("name=\"b\""));
928 + }
929 +
845 930 #[test]
846 931 fn a_nested_region_renders_inside_its_parent() {
847 932 let screen =