Skip to main content

max / makeover-webview

0.9.0: the list module, on makeover-layout 0.6.0 Adds Sizing, grid_template_columns, narrowing_css, column_class, Cell and cells_html. Breaking because the description's own version moved under it, and because Width and Priority became non_exhaustive there. Drops the temporary patch.crates-io that pointed the description at the working tree while goingson's adoption was finding gaps. It found two more in the lists half, both in goingson rather than here: a task table whose mobile tracks and mobile hiding had drifted apart, and an events table that was really two tables sharing one grid. Also carries a fix to narrowing_css found by reading its output rather than its tests. A selector list needs the child combinator distributed across each part; appended to the whole, ".head, .row > .col-x" reads as "head, or a col-x inside row", which would have hidden an entire table header.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 14:13 UTC
Signed with PGP, not checked
Commit: ab536fb0fcb1b4710258d4c8b3732d685959565f
Parent: 1c39e29
3 files changed, +79 insertions, -14 deletions
M Cargo.toml +2 -2
@@ -1,13 +1,13 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.8.0"
3 + version = "0.9.0"
4 4 edition = "2024"
5 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 6 license = "MIT"
7 7 repository = "https://makenot.work/git/max/makeover-webview"
8 8
9 9 [dependencies]
10 - makeover-layout = "0.5.0"
10 + makeover-layout = "0.6.0"
11 11
12 12 [lints.rust]
13 13 unused = "warn"
M src/form.rs +29 -9
@@ -298,9 +298,9 @@
298 298 // read by every extension on the page and is the first thing a crash
299 299 // reporter serialises. Neither app pre-fills one today, so this costs
300 300 // nothing and closes the door before something does.
301 - FieldKind::Secret => format!(
302 - "<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>"
303 - ),
301 + FieldKind::Secret => {
302 + format!("<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>")
303 + }
304 304 kind => format!(
305 305 "<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
306 306 input_type(kind),
@@ -446,7 +446,10 @@
446 446 ..Filling::default()
447 447 };
448 448 let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
449 - assert!(html.contains("<div class=\"recurrence-config\"></div>"), "{html}");
449 + assert!(
450 + html.contains("<div class=\"recurrence-config\"></div>"),
451 + "{html}"
452 + );
450 453 }
451 454
452 455 #[test]
@@ -465,7 +468,11 @@
465 468
466 469 #[test]
467 470 fn a_valid_field_claims_nothing_about_being_invalid() {
468 - let html = field_html(&field(FieldKind::Text), &Filling::default(), &Emit::default());
471 + let html = field_html(
472 + &field(FieldKind::Text),
473 + &Filling::default(),
474 + &Emit::default(),
475 + );
469 476 assert!(!html.contains("aria-invalid"), "{html}");
470 477 assert!(!html.contains("has-error"), "{html}");
471 478 }
@@ -531,7 +538,10 @@
531 538 value: "3",
532 539 });
533 540 let html = field_html(&field(FieldKind::Select), &filling, &Emit::default());
534 - assert!(html.contains("<option value=\"3\" selected>3</option>"), "{html}");
541 + assert!(
542 + html.contains("<option value=\"3\" selected>3</option>"),
543 + "{html}"
544 + );
535 545 assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
536 546 assert!(!html.contains("data-unmatched"), "{html}");
537 547 }
@@ -576,9 +586,15 @@
576 586 };
577 587 let html = field_html(&f, &filling, &Emit::default());
578 588
579 - assert!(html.contains(r#"id="form-modal-task-edit-title""#), "{html}");
589 + assert!(
590 + html.contains(r#"id="form-modal-task-edit-title""#),
591 + "{html}"
592 + );
580 593 assert!(html.contains(r#"name="title""#), "{html}");
581 - assert!(!html.contains(r#"name="form-modal-task-edit-title""#), "{html}");
594 + assert!(
595 + !html.contains(r#"name="form-modal-task-edit-title""#),
596 + "{html}"
597 + );
582 598
583 599 // The label and both associations follow the id, or they point at
584 600 // nothing once the same form is on screen twice.
@@ -629,7 +645,11 @@
629 645
630 646 #[test]
631 647 fn no_prefix_leaves_the_id_as_the_name() {
632 - let html = field_html(&field(FieldKind::Text), &Filling::default(), &Emit::default());
648 + let html = field_html(
649 + &field(FieldKind::Text),
650 + &Filling::default(),
651 + &Emit::default(),
652 + );
633 653 assert!(html.contains(r#"id="title" name="title""#), "{html}");
634 654 }
635 655 }
M src/list.rs +48 -3
@@ -122,6 +122,11 @@
122 122 /// Both halves together: the shortened track list, and `display: none` on each
123 123 /// dropped column *by its own class*. Nothing counts positions, so inserting a
124 124 /// column changes what is emitted rather than changing which column vanishes.
125 + ///
126 + /// `selector` may be a selector list. A descendant is appended to each part
127 + /// rather than to the whole, because appending to the whole changes what the
128 + /// earlier parts match: `.head, .row > .col-x` reads as "`.head`, or a `.col-x`
129 + /// inside `.row`", so `.head` itself would be hidden.
125 130 #[must_use]
126 131 pub fn narrowing_css(
127 132 columns: &[Column<'_>],
@@ -130,16 +135,24 @@
130 135 cutoff: Priority,
131 136 opts: &Emit,
132 137 ) -> String {
138 + let parts: Vec<&str> = selector.split(',').map(str::trim).collect();
139 +
133 140 let mut css = format!(
134 - "{selector} {{\n grid-template-columns: {};\n}}\n",
141 + "{} {{\n grid-template-columns: {};\n}}\n",
142 + parts.join(", "),
135 143 grid_template_columns(columns, sizing, cutoff)
136 144 );
137 145
138 146 for column in columns.iter().filter(|c| !c.kept_at(cutoff)) {
147 + let class = column_class(column, opts);
148 + let targets: Vec<String> = parts
149 + .iter()
150 + .map(|part| format!("{part} > .{class}"))
151 + .collect();
139 152 let _ = write!(
140 153 css,
141 - "{selector} > .{} {{\n display: none;\n}}\n",
142 - column_class(column, opts)
154 + "{} {{\n display: none;\n}}\n",
155 + targets.join(",\n")
143 156 );
144 157 }
145 158 css
@@ -202,6 +215,20 @@
202 215 /// Emits the cells alone, not the row element. The row carries the app's
203 216 /// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
204 217 /// state classes — and none of that is describable here.
218 + ///
219 + /// # Not for a webview's scroll path
220 + ///
221 + /// This has no consumer in either webview app, deliberately, and wiring one in
222 + /// would be a mistake worth naming. goingson renders rows through a virtual
223 + /// scroller whose `_render` calls its row builder **synchronously** while
224 + /// scrolling; the code's own comment says scroll events fire at 60Hz+ and that
225 + /// this is the hot path. Reaching Rust from there means an IPC round trip and
226 + /// an `await` in that loop, per visible range, during a drag.
227 + ///
228 + /// So this is for the hosts where rendering already happens in Rust: an axum
229 + /// route, and the router when it lands. There the objection does not apply,
230 + /// because nothing crosses a process boundary to reach it. A webview app should
231 + /// take [`narrowing_css`] and [`column_class`] and keep building its own rows.
205 232 #[must_use]
206 233 pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
207 234 let cell_class = class("cell", opts);
@@ -310,6 +337,24 @@
310 337 assert!(!css.contains(".col-due {\n display: none"), "{css}");
311 338 }
312 339
340 + /// A selector list has to distribute, or the earlier parts of it get the
341 + /// child combinator appended to the whole and start matching things they
342 + /// never named. This hid an entire table header the first time it ran.
343 + #[test]
344 + fn a_selector_list_distributes_the_hidden_column() {
345 + let css = narrowing_css(
346 + &columns(),
347 + ".task-header-row, .task-row",
348 + &sizing(),
349 + Priority::Secondary,
350 + &Emit::default(),
351 + );
352 + assert!(css.contains(".task-header-row > .col-progress,\n.task-row > .col-progress {"), "{css}");
353 + // The bare header selector must never appear as a hiding target.
354 + assert!(!css.contains(".task-header-row {\n display: none"), "{css}");
355 + assert!(css.contains(".task-header-row, .task-row {\n grid-template-columns:"), "{css}");
356 + }
357 +
313 358 #[test]
314 359 fn cells_follow_the_columns_and_carry_their_column_class() {
315 360 let cells = [