Skip to main content

max / makeover-webview

Seed makeover-webview: the renderer that needs no palette Emits CSS for makeover-layout's Depth. Unlike the other two renderers it never learns a colour: var(--surface-raised) is the late binding and the browser resolves it, so this is the deferral rule with no adapter in the way. It is also why the webview was the wrong renderer to derive a vocabulary from, since it can express anything and never pushes back. Phase A only, the stylesheet and no markup. GO has 145 innerHTML sites and BB 175 createElement sites; moving markup is a migration, adopting a generated stylesheet is a deletion. The emitted bevel properties are byte-identical to what both apps already hand-write, asserted in a test, so adoption removes duplicated lines rather than changing a pixel. Completes the third answer to the well substitution: immediate does it in Rust, tui refuses and draws an edge, and here CSS's own var() fallback does it with no Rust decision at all. Three renderers, three answers, one intent, which is why the fallback did not belong in the description. .raised:active is the one thing this renderer gets free: the cascade carries a state the other two resolve per call site. 9 tests, clippy clean at -D warnings. Unpublished.
Author: Max Johnson <me@maxj.phd> · 2026-07-28 21:37 UTC
Signed with PGP, not checked
Commit: 91f1c1110f00ea2f3b78ba3f9458101f8c747d5c
7 files changed, +425 insertions, -0 deletions
A .gitignore +5
@@ -1,0 +1,5 @@
1 + /target
2 +
3 + # Claude Code instructions (project-local; not for the public repo)
4 + CLAUDE.md
5 + /Cargo.lock
A Cargo.toml +42
@@ -1,0 +1,42 @@
1 + [package]
2 + name = "makeover-webview"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "The webview renderer for makeover-layout. Emits CSS, and is the one renderer that needs no palette: var() is the late binding, so resolution stays with the browser."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-webview"
8 +
9 + [dependencies]
10 + makeover-layout = { path = "../makeover-layout" }
11 +
12 + [lints.rust]
13 + unused = "warn"
14 + unreachable_pub = "warn"
15 +
16 + [lints.clippy]
17 + pedantic = { level = "warn", priority = -1 }
18 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
19 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
20 + # else in `pedantic` stays a warning. Keep this block identical across repos.
21 + module_name_repetitions = "allow"
22 + # Doc lints. No docs-completeness push is underway.
23 + missing_errors_doc = "allow"
24 + missing_panics_doc = "allow"
25 + doc_markdown = "allow"
26 + # Numeric casts. Endemic and mostly intentional in size and byte math.
27 + cast_possible_truncation = "allow"
28 + cast_sign_loss = "allow"
29 + cast_precision_loss = "allow"
30 + cast_possible_wrap = "allow"
31 + cast_lossless = "allow"
32 + # Subjective structure and style nags. High churn, low signal.
33 + must_use_candidate = "allow"
34 + too_many_lines = "allow"
35 + struct_excessive_bools = "allow"
36 + similar_names = "allow"
37 + items_after_statements = "allow"
38 + single_match_else = "allow"
39 + # Frequent false-positives in TUI and router-heavy code.
40 + match_same_arms = "allow"
41 + unnecessary_wraps = "allow"
42 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +65
@@ -1,0 +1,65 @@
1 + # makeover-webview
2 +
3 + The webview renderer for
4 + [`makeover-layout`](https://makenot.work/git/max/makeover-layout). Emits CSS.
5 +
6 + ## The renderer that needs no palette
7 +
8 + `makeover-immediate` and `makeover-tui` both take a `Palette`, because egui and
9 + a terminal need an actual colour before they can put anything on screen. A
10 + webview does not. `var(--surface-raised)` *is* the late binding, and the browser
11 + resolves it against whatever the theme layer last wrote onto `:root`.
12 +
13 + So this crate emits text naming intents and never learns a colour, which is the
14 + deferral rule with no adapter in the way. It is also why the webview was always
15 + the wrong renderer to derive a vocabulary from: it can express anything, so it
16 + never pushes back.
17 +
18 + ## Phase A: the stylesheet only
19 +
20 + No markup, deliberately. GoingsOn has 145 `innerHTML` sites and Balanced
21 + Breakfast 175 `createElement` sites, so moving markup is a migration while
22 + adopting a generated stylesheet is a deletion. The apps keep every line of their
23 + markup and gain the classes.
24 +
25 + ```css
26 + :root {
27 + --bevel-raised: inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark);
28 + --bevel-inset: inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light);
29 + }
30 +
31 + .raised { background: var(--surface-raised); box-shadow: var(--bevel-raised); }
32 + .well { background: var(--surface-well, var(--surface-page)); box-shadow: var(--bevel-inset); }
33 + .raised:active { box-shadow: var(--bevel-inset); }
34 + ```
35 +
36 + Those two custom properties are byte-identical to what both apps already
37 + hand-write, which is asserted in the tests. Adoption removes duplicated lines
38 + rather than changing a pixel.
39 +
40 + `.raised:active` is the one thing this renderer gets for free: the cascade
41 + carries a pressed state that an immediate-mode renderer resolves per call site,
42 + eighteen of them in audiofiles.
43 +
44 + ## Substitution, three ways
45 +
46 + `Fill::Well` has no colour on makeover before 2.3.0, and the three renderers
47 + answer that differently. That is the evidence that taking `Fill::fallback` out of
48 + the description was right:
49 +
50 + - `makeover-immediate` substitutes the page in Rust.
51 + - `makeover-tui` refuses to substitute and draws an edge, because a terminal
52 + quantises the two together.
53 + - here, CSS already has the mechanism. `var(--surface-well, var(--surface-page))`
54 + falls back in the browser and nothing in Rust decides anything.
55 +
56 + ## Status
57 +
58 + Unpublished, path dep on `makeover-layout`. No consumer yet; GoingsOn goes first
59 + when phase A is adopted, since it holds the deletion test.
60 +
61 + `cargo run --example dump` prints the stylesheet.
62 +
63 + ## Licence
64 +
65 + MIT.
@@ -1,0 +1,3 @@
1 + fn main() {
2 + print!("{}", makeover_webview::stylesheet(&makeover_webview::Emit::default()));
3 + }
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "stable"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
A src/lib.rs +285
@@ -1,0 +1,285 @@
1 + //! The webview renderer for [`makeover_layout`].
2 + //!
3 + //! <!-- wiki: makeover-webview -->
4 + //!
5 + //! # The renderer that needs no palette
6 + //!
7 + //! `makeover-immediate` and `makeover-tui` both take a `Palette`, because egui
8 + //! and a terminal need an actual colour before they can put anything on
9 + //! screen. A webview does not: `var(--surface-raised)` *is* the late binding,
10 + //! and the browser resolves it against whatever `themes.js` last wrote onto
11 + //! `:root`.
12 + //!
13 + //! So this crate emits text naming intents, and never learns a colour. It is
14 + //! the deferral rule with no adapter in the way, and it is why the webview was
15 + //! always the wrong renderer to derive a vocabulary from: it can express
16 + //! anything, so it never pushes back.
17 + //!
18 + //! # Phase A: the stylesheet only
19 + //!
20 + //! This emits component CSS and no markup, deliberately. GoingsOn has 145
21 + //! `innerHTML` sites and Balanced Breakfast 175 `createElement` sites; moving
22 + //! markup is a migration, while adopting a generated stylesheet is a deletion.
23 + //! The apps keep every line of their markup and gain the classes.
24 + //!
25 + //! The output is byte-identical to the bevel composition both apps already
26 + //! hand-write, which is asserted below, so adoption removes duplicated lines
27 + //! rather than changing a pixel.
28 + //!
29 + //! # Substitution, three ways
30 + //!
31 + //! `Fill::Well` has no colour on makeover before 2.3.0, and each renderer
32 + //! answers that differently, which is the evidence that dropping
33 + //! `Fill::fallback` from the description was right:
34 + //!
35 + //! - `makeover-immediate` substitutes the page in Rust.
36 + //! - `makeover-tui` refuses to substitute and draws an edge instead, because a
37 + //! terminal would quantise the two together.
38 + //! - here, CSS already has the mechanism: `var(--surface-well,
39 + //! var(--surface-page))` falls back in the browser, and nothing in Rust
40 + //! decides anything.
41 +
42 + #![forbid(unsafe_code)]
43 +
44 + use makeover_layout::{Bevel, Depth, Fill, Intent};
45 + use std::fmt::Write as _;
46 +
47 + /// How the emitted CSS is shaped.
48 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49 + pub struct Emit {
50 + /// Bevel thickness, as a CSS length.
51 + ///
52 + /// A value, so it arrives from the caller: border widths belong to
53 + /// `makeover-geometry` and will come from there once it carries them.
54 + pub border_width: &'static str,
55 + /// Prefix for emitted class names, without the leading dot.
56 + pub class_prefix: &'static str,
57 + }
58 +
59 + impl Default for Emit {
60 + fn default() -> Self {
61 + Self {
62 + border_width: "1px",
63 + class_prefix: "",
64 + }
65 + }
66 + }
67 +
68 + /// The CSS custom property holding a bevel's composition.
69 + #[must_use]
70 + pub fn bevel_var(bevel: Bevel) -> &'static str {
71 + match bevel {
72 + Bevel::Raised => "--bevel-raised",
73 + Bevel::Inset => "--bevel-inset",
74 + }
75 + }
76 +
77 + /// A `var()` reference to a fill intent, with the browser's own fallback where
78 + /// the intent may be absent.
79 + ///
80 + /// The fallback is CSS syntax, not a decision made here. That is the whole
81 + /// difference between this renderer and the other two.
82 + #[must_use]
83 + pub fn fill_var(fill: Fill) -> String {
84 + match fill {
85 + Fill::Well => format!("var(--{}, var(--{}))", fill.token(), Fill::Page.token()),
86 + other => format!("var(--{})", other.token()),
87 + }
88 + }
89 +
90 + /// The two-tone edge as a `box-shadow` value.
91 + ///
92 + /// Two inset shadows, one per corner pair: the light one offset down and
93 + /// right so it lands on the top and left edges, the dark one the other way.
94 + /// The same assignment `makeover-immediate` draws with polylines and
95 + /// `makeover-tui` draws with box-drawing characters.
96 + #[must_use]
97 + pub fn bevel_shadow(bevel: Bevel, opts: &Emit) -> String {
98 + let (top_left, bottom_right) = bevel.edges();
99 + let w = opts.border_width;
100 + format!(
101 + "inset {w} {w} 0 var(--{}), inset -{w} -{w} 0 var(--{})",
102 + top_left.token(),
103 + bottom_right.token()
104 + )
105 + }
106 +
107 + /// The custom properties both bevels resolve through.
108 + ///
109 + /// Emitted as properties rather than inlined into every rule because that is
110 + /// what the apps already do, and because a consumer that wants the edge
111 + /// without the fill reads the property directly.
112 + #[must_use]
113 + pub fn bevel_properties(opts: &Emit) -> String {
114 + let mut css = String::new();
115 + for bevel in [Bevel::Raised, Bevel::Inset] {
116 + let _ = writeln!(
117 + css,
118 + " {}: {};",
119 + bevel_var(bevel),
120 + bevel_shadow(bevel, opts)
121 + );
122 + }
123 + css
124 + }
125 +
126 + /// The class name for a depth.
127 + #[must_use]
128 + pub fn depth_class(depth: Depth, opts: &Emit) -> Option<String> {
129 + let name = match depth {
130 + Depth::Flat => return None,
131 + Depth::Raised => "raised",
132 + Depth::Well => "well",
133 + };
134 + Some(format!("{}{name}", opts.class_prefix))
135 + }
136 +
137 + /// One rule per depth: its fill and its edge, together.
138 + ///
139 + /// `Depth::Flat` emits nothing. A class that sets no properties is a class
140 + /// that means "I thought about this", which is what comments are for.
141 + ///
142 + /// A pressed rule rides along with the raised one, because the cascade can
143 + /// carry a state that an immediate-mode renderer has to resolve per call site.
144 + /// That is the one thing this renderer gets for free and the others do not.
145 + #[must_use]
146 + pub fn depth_rules(opts: &Emit) -> String {
147 + let mut css = String::new();
148 + for depth in [Depth::Raised, Depth::Well] {
149 + let Some(class) = depth_class(depth, opts) else {
150 + continue;
151 + };
152 + let (Some(fill), Some(bevel)) = (depth.fill(), depth.bevel()) else {
153 + continue;
154 + };
155 + let _ = writeln!(
156 + css,
157 + ".{class} {{\n background: {};\n box-shadow: var({});\n}}",
158 + fill_var(fill),
159 + bevel_var(bevel)
160 + );
161 + }
162 + if let (Some(raised), Some(pressed)) = (
163 + depth_class(Depth::Raised, opts),
164 + Depth::Raised.pressed().bevel(),
165 + ) {
166 + let _ = writeln!(
167 + css,
168 + ".{raised}:active {{\n box-shadow: var({});\n}}",
169 + bevel_var(pressed)
170 + );
171 + }
172 + css
173 + }
174 +
175 + /// The whole phase-A stylesheet: properties and rules, with a generated-file
176 + /// banner.
177 + #[must_use]
178 + pub fn stylesheet(opts: &Emit) -> String {
179 + format!(
180 + "/* Generated by makeover-webview from makeover-layout. Do not edit.\n \
181 + Depth is a fill and an edge together; naming them apart is what let\n \
182 + them disagree. See the crate's README and wiki note makeover-layout. */\n\
183 + :root {{\n{}}}\n\n{}",
184 + bevel_properties(opts),
185 + depth_rules(opts)
186 + )
187 + }
188 +
189 + #[cfg(test)]
190 + mod tests {
191 + use super::*;
192 + use makeover_layout::Edge;
193 +
194 + #[test]
195 + fn the_emitted_bevel_matches_what_the_apps_already_hand_write() {
196 + // Balanced Breakfast's styles.css, verbatim. Adoption has to be a
197 + // deletion, not a redesign, or nobody will take it.
198 + let opts = Emit::default();
199 + assert_eq!(
200 + bevel_shadow(Bevel::Raised, &opts),
201 + "inset 1px 1px 0 var(--bevel-light), inset -1px -1px 0 var(--bevel-dark)"
202 + );
203 + assert_eq!(
204 + bevel_shadow(Bevel::Inset, &opts),
205 + "inset 1px 1px 0 var(--bevel-dark), inset -1px -1px 0 var(--bevel-light)"
206 + );
207 + }
208 +
209 + #[test]
210 + fn no_colour_ever_reaches_the_output() {
211 + let css = stylesheet(&Emit::default());
212 + assert!(!css.contains('#'), "a hex literal escaped into the CSS");
213 + assert!(
214 + !css.contains("rgb"),
215 + "a colour function escaped into the CSS"
216 + );
217 + // Every colour is named, never resolved.
218 + assert!(css.contains("var(--surface-raised)"));
219 + assert!(css.contains("var(--bevel-light)"));
220 + }
221 +
222 + #[test]
223 + fn a_well_falls_back_through_css_rather_than_through_rust() {
224 + assert_eq!(
225 + fill_var(Fill::Well),
226 + "var(--surface-well, var(--surface-page))"
227 + );
228 + // Nothing else needs one.
229 + assert_eq!(fill_var(Fill::Raised), "var(--surface-raised)");
230 + assert_eq!(fill_var(Fill::Page), "var(--surface-page)");
231 + }
232 +
233 + #[test]
234 + fn raised_and_well_do_not_collapse_onto_each_other() {
235 + let css = depth_rules(&Emit::default());
236 + assert!(css.contains(".raised {"));
237 + assert!(css.contains(".well {"));
238 + assert!(css.contains("var(--bevel-raised)"));
239 + assert!(css.contains("var(--bevel-inset)"));
240 + }
241 +
242 + #[test]
243 + fn the_cascade_carries_the_pressed_state() {
244 + let css = depth_rules(&Emit::default());
245 + // The one thing this renderer gets free that the other two resolve by
246 + // hand, eighteen call sites deep in audiofiles' case.
247 + assert!(css.contains(".raised:active {"));
248 + }
249 +
250 + #[test]
251 + fn flat_emits_nothing_at_all() {
252 + assert_eq!(depth_class(Depth::Flat, &Emit::default()), None);
253 + assert!(!depth_rules(&Emit::default()).contains("flat"));
254 + }
255 +
256 + #[test]
257 + fn a_prefix_namespaces_every_class() {
258 + let opts = Emit {
259 + class_prefix: "mo-",
260 + ..Emit::default()
261 + };
262 + let css = depth_rules(&opts);
263 + assert!(css.contains(".mo-raised {"));
264 + assert!(css.contains(".mo-well {"));
265 + assert!(!css.contains(".raised {"));
266 + }
267 +
268 + #[test]
269 + fn the_border_width_is_the_callers() {
270 + let opts = Emit {
271 + border_width: "2px",
272 + ..Emit::default()
273 + };
274 + assert!(bevel_shadow(Bevel::Raised, &opts).contains("inset 2px 2px 0"));
275 + }
276 +
277 + #[test]
278 + fn edges_agree_with_the_description() {
279 + // Not a tautology: it is the guard that a CSS-shaped convenience never
280 + // quietly reverses which side is lit.
281 + let (tl, br) = Bevel::Raised.edges();
282 + assert_eq!(tl.token(), Edge::Light.token());
283 + assert_eq!(br.token(), Edge::Dark.token());
284 + }
285 + }