Skip to main content

max / makeover-webview

Emit column tracks, narrowing rules and row cells The lists half. Columns in, a grid track list and a set of cell containers out, plus the rules that narrow a table on a small viewport. Narrowing by priority rather than by position is the point. goingson hides its mobile columns with nth-child(n+5) against a seven-column table, plus a separate nth-child(3), plus two class-based rules: one fact said three ways, two of them positional. Insert a column left of the cut and the wrong one disappears, silently, because nothing in the stylesheet knows what column five is. Here a renderer raises a cutoff and the emitter drops columns by what they are worth, hiding each by its own class. The track list and the hiding come out of the same call, which closes the other half of that bug: goingson's narrow rule also shortens the track list by hand, so the two have to be edited in step and nothing checks that they were. Sizing carries what the description deferred. Width says Content, Fixed or Fill and no magnitude, because a magnitude is a CSS answer and the description is read by renderers with no pixels. So lengths arrive renderer-side, by column name, the way a field's value arrives in Filling. What this does not do is render a cell's contents, and that is the crate's own limit rather than a shortcut: makeover_layout's "Where the description stops" says generate the boring 80% so the bespoke 20% gets the attention. A task row carries delegated hooks with argument substitution, four nested sub-renderers, conditional state classes and aria labels built from data. So a Cell holds Markup and the app says what goes in it, which is the same split trailing already drew for forms.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 13:27 UTC
Signed with PGP, not checked
Commit: 1c39e29e2c58b7df40727168395e19f613954099
Parent: c686089
2 files changed, +376 insertions, -4 deletions
M src/lib.rs +6 -4
@@ -81,6 +81,7 @@
81 81 #![forbid(unsafe_code)]
82 82
83 83 pub mod form;
84 + pub mod list;
84 85
85 86 use makeover_layout::{Bevel, Depth, Fill, Intent, RowPart, Selector, Token, Tone};
86 87 use std::fmt::Write as _;
@@ -676,7 +677,10 @@
676 677 .take_while(|l| !l.starts_with('}'))
677 678 .collect::<Vec<_>>()
678 679 .join("\n");
679 - assert!(tab.contains("var(--bevel-raised)"), "tab was held in: {tab}");
680 + assert!(
681 + tab.contains("var(--bevel-raised)"),
682 + "tab was held in: {tab}"
683 + );
680 684 }
681 685
682 686 #[test]
@@ -758,9 +762,7 @@
758 762 assert!(!css.contains("progress-fill {\n color: var(--content-muted)"));
759 763 for tone in ["info", "success", "warning", "danger"] {
760 764 assert!(
761 - css.contains(&format!(
762 - ".progress > .progress-fill[data-tone=\"{tone}\"]"
763 - )),
765 + css.contains(&format!(".progress > .progress-fill[data-tone=\"{tone}\"]")),
764 766 "missing progress tone {tone}"
765 767 );
766 768 }
A src/list.rs +370
@@ -1,0 +1,370 @@
1 + //! Column layout and row structure for lists and tables.
2 + //!
3 + //! The other half of phase B. [`form`](crate::form) renders a field; this
4 + //! renders the frame a list of rows sits in: which columns exist, how wide they
5 + //! are, which ones survive a narrow viewport, and the cell containers a row is
6 + //! made of.
7 + //!
8 + //! # What this does not do
9 + //!
10 + //! It does not render a cell's contents. That is the crate's own limit, stated
11 + //! in `makeover_layout`'s "Where the description stops": generate the boring
12 + //! 80% so the bespoke 20% gets the attention. A goingson task row carries
13 + //! delegated action hooks with argument substitution, four nested sub-renderers,
14 + //! conditional state classes and aria labels built from data. A description
15 + //! expressive enough to emit that is a templating language wearing a
16 + //! description's name.
17 + //!
18 + //! So the split is the one [`Markup`] already draws for forms: this owns the
19 + //! structure and the app owns what goes in it. What that removes from an app is
20 + //! not small — cell order, cell classes, the grid tracks, and above all the
21 + //! narrowing rules, which is where addressing columns by position goes wrong.
22 + //!
23 + //! # Why positions are the bug
24 + //!
25 + //! goingson hides its mobile columns with `nth-child(n+5)` against a
26 + //! seven-column table, plus a separate `nth-child(3)`, plus two class-based
27 + //! rules — the same fact said three ways, two of them positional. Insert a
28 + //! column anywhere left of the cut and the wrong one disappears, silently,
29 + //! because nothing in the stylesheet knows what column five *is*.
30 + //! [`Priority`] is the fix: a renderer narrows by raising a cutoff, and never
31 + //! by counting.
32 +
33 + use crate::form::Markup;
34 + use crate::{Emit, class};
35 + use makeover_layout::{Column, Priority, RowPart, Width};
36 + use std::fmt::Write as _;
37 +
38 + /// The lengths the description deferred.
39 + ///
40 + /// [`Width`] says `Content`, `Fixed` or `Fill` and deliberately carries no
41 + /// magnitude, because a magnitude is a CSS answer and the description is read
42 + /// by renderers that have no pixels. So the numbers arrive here instead, the
43 + /// way a field's value arrives in [`Filling`](crate::form::Filling) rather than
44 + /// in `Field`.
45 + ///
46 + /// Looked up by column name, because an app's columns are not all one size:
47 + /// goingson's task table has six distinct fixed widths.
48 + #[derive(Debug, Clone, Copy, Default)]
49 + pub struct Sizing<'a> {
50 + /// `(column name, CSS length)`. The length is the track for a
51 + /// [`Width::Fixed`] column and the floor for a [`Width::Fill`] one.
52 + pub lengths: &'a [(&'a str, &'a str)],
53 + /// Used for a column with no entry above. Empty means `auto`.
54 + pub fallback: &'a str,
55 + }
56 +
57 + impl Sizing<'_> {
58 + /// The length for a named column.
59 + fn length_for(&self, name: &str) -> &str {
60 + self.lengths
61 + .iter()
62 + .find(|(column, _)| *column == name)
63 + .map_or_else(
64 + || {
65 + if self.fallback.is_empty() {
66 + "auto"
67 + } else {
68 + self.fallback
69 + }
70 + },
71 + |(_, length)| *length,
72 + )
73 + }
74 +
75 + /// The grid track for one column.
76 + fn track(&self, column: &Column<'_>) -> String {
77 + match column.width {
78 + Width::Content => "max-content".to_owned(),
79 + Width::Fixed => self.length_for(column.name).to_owned(),
80 + Width::Fill => format!("minmax({}, 1fr)", self.length_for(column.name)),
81 + // A width added to the description since this renderer was built.
82 + // `auto` is the track that makes no claim, which is the honest
83 + // answer to a claim this renderer cannot read.
84 + _ => "auto".to_owned(),
85 + }
86 + }
87 + }
88 +
89 + /// The class a cell of this column carries.
90 + ///
91 + /// Derived from the column's own name, which is what makes the narrowing rules
92 + /// addressable. `data-column` would do as well; a class is what both webview
93 + /// apps already key their cell styling on.
94 + #[must_use]
95 + pub fn column_class(column: &Column<'_>, opts: &Emit) -> String {
96 + class(&format!("col-{}", column.name), opts)
97 + }
98 +
99 + /// The `grid-template-columns` value for the columns kept at `cutoff`.
100 + ///
101 + /// Emitting only the surviving tracks is what keeps the track list and the
102 + /// hiding in agreement. An app that hides a cell with `display: none` but
103 + /// leaves its track in place gets a column of empty space, which is the other
104 + /// half of goingson's mobile bug: its narrow rule drops to four tracks by hand
105 + /// and has to be edited in step with the `nth-child` cut.
106 + #[must_use]
107 + pub fn grid_template_columns(
108 + columns: &[Column<'_>],
109 + sizing: &Sizing<'_>,
110 + cutoff: Priority,
111 + ) -> String {
112 + columns
113 + .iter()
114 + .filter(|column| column.kept_at(cutoff))
115 + .map(|column| sizing.track(column))
116 + .collect::<Vec<_>>()
117 + .join(" ")
118 + }
119 +
120 + /// The rules that narrow `selector` to the columns kept at `cutoff`.
121 + ///
122 + /// Both halves together: the shortened track list, and `display: none` on each
123 + /// dropped column *by its own class*. Nothing counts positions, so inserting a
124 + /// column changes what is emitted rather than changing which column vanishes.
125 + #[must_use]
126 + pub fn narrowing_css(
127 + columns: &[Column<'_>],
128 + selector: &str,
129 + sizing: &Sizing<'_>,
130 + cutoff: Priority,
131 + opts: &Emit,
132 + ) -> String {
133 + let mut css = format!(
134 + "{selector} {{\n grid-template-columns: {};\n}}\n",
135 + grid_template_columns(columns, sizing, cutoff)
136 + );
137 +
138 + for column in columns.iter().filter(|c| !c.kept_at(cutoff)) {
139 + let _ = write!(
140 + css,
141 + "{selector} > .{} {{\n display: none;\n}}\n",
142 + column_class(column, opts)
143 + );
144 + }
145 + css
146 + }
147 +
148 + /// One cell of a row.
149 + ///
150 + /// The contents are [`Markup`] rather than text, and that is the whole shape of
151 + /// this module: a cell holds whatever the app builds, and the app says so by
152 + /// naming it. Escaping a cell here would be wrong as well as impossible — a
153 + /// task row's description cell is five nested spans and a badge.
154 + #[derive(Debug, Clone, Copy)]
155 + pub struct Cell<'a> {
156 + /// Which column this fills, by name.
157 + pub column: &'a str,
158 + /// What kind of text it is, when it is text.
159 + ///
160 + /// Carries the row-part class the stylesheet half already emits, so a
161 + /// secondary cell says it is secondary in the description's own words
162 + /// rather than in the app's.
163 + pub part: Option<RowPart>,
164 + /// The contents. Trusted app markup.
165 + pub content: Markup<'a>,
166 + }
167 +
168 + impl<'a> Cell<'a> {
169 + /// A cell with no row part.
170 + #[must_use]
171 + pub const fn new(column: &'a str, content: Markup<'a>) -> Self {
172 + Self {
173 + column,
174 + part: None,
175 + content,
176 + }
177 + }
178 + }
179 +
180 + /// The class for a row part.
181 + ///
182 + /// Exhaustive, unlike the matches on [`Width`] and [`Priority`] above:
183 + /// `RowPart` is the one vocabulary in this module that is still a closed enum.
184 + /// If it ever gains a member this stops compiling, which is the same lockstep
185 + /// break `non_exhaustive` was added elsewhere to end.
186 + fn part_class(part: RowPart) -> &'static str {
187 + match part {
188 + RowPart::Primary => "row-primary",
189 + RowPart::Secondary => "row-secondary",
190 + RowPart::Meta => "row-meta",
191 + RowPart::Actions => "row-actions",
192 + }
193 + }
194 +
195 + /// A row's cells, in column order.
196 + ///
197 + /// Ordered by the columns and not by the cells, so a row cannot silently
198 + /// disagree with its table about what comes where. A column with no cell gets
199 + /// an empty container, which keeps the grid aligned; a cell naming no column is
200 + /// dropped, because there is nowhere to put it.
201 + ///
202 + /// Emits the cells alone, not the row element. The row carries the app's
203 + /// identity and hooks — `data-id`, a context-menu binding, a tabindex, its
204 + /// state classes — and none of that is describable here.
205 + #[must_use]
206 + pub fn cells_html(columns: &[Column<'_>], cells: &[Cell<'_>], opts: &Emit) -> String {
207 + let cell_class = class("cell", opts);
208 + let mut html = String::new();
209 +
210 + for column in columns {
211 + let found = cells.iter().find(|cell| cell.column == column.name);
212 + let mut classes = format!("{cell_class} {}", column_class(column, opts));
213 + if let Some(part) = found.and_then(|cell| cell.part) {
214 + let _ = write!(classes, " {}", class(part_class(part), opts));
215 + }
216 + let _ = write!(
217 + html,
218 + "<div class=\"{classes}\">{}</div>",
219 + found.map_or("", |cell| cell.content.0)
220 + );
221 + }
222 + html
223 + }
224 +
225 + #[cfg(test)]
226 + mod tests {
227 + use super::*;
228 +
229 + fn columns() -> Vec<Column<'static>> {
230 + vec![
231 + Column {
232 + name: "description",
233 + width: Width::Fill,
234 + priority: Priority::Essential,
235 + },
236 + Column {
237 + name: "due",
238 + width: Width::Fixed,
239 + priority: Priority::Secondary,
240 + },
241 + Column {
242 + name: "progress",
243 + width: Width::Fixed,
244 + priority: Priority::Optional,
245 + },
246 + ]
247 + }
248 +
249 + fn sizing() -> Sizing<'static> {
250 + Sizing {
251 + lengths: &[
252 + ("description", "200px"),
253 + ("due", "110px"),
254 + ("progress", "100px"),
255 + ],
256 + fallback: "",
257 + }
258 + }
259 +
260 + #[test]
261 + fn a_fill_column_gets_a_floor_and_the_slack() {
262 + let tracks = grid_template_columns(&columns(), &sizing(), Priority::Optional);
263 + assert_eq!(tracks, "minmax(200px, 1fr) 110px 100px");
264 + }
265 +
266 + #[test]
267 + fn a_column_with_no_length_makes_no_claim() {
268 + let sizing = Sizing::default();
269 + let tracks = grid_template_columns(&columns(), &sizing, Priority::Optional);
270 + assert_eq!(tracks, "minmax(auto, 1fr) auto auto");
271 + }
272 +
273 + /// The point of the module. Raising the cutoff drops columns by what they
274 + /// are worth, and the track list shortens to match, so the two cannot
275 + /// disagree the way a hand-written `nth-child` cut and a hand-written
276 + /// track list can.
277 + #[test]
278 + fn raising_the_cutoff_drops_columns_and_their_tracks_together() {
279 + let columns = columns();
280 +
281 + let wide = grid_template_columns(&columns, &sizing(), Priority::Optional);
282 + assert_eq!(wide.split(' ').count(), 4); // minmax(200px, + 1fr) + 2
283 +
284 + let narrow = grid_template_columns(&columns, &sizing(), Priority::Secondary);
285 + assert_eq!(narrow, "minmax(200px, 1fr) 110px");
286 +
287 + let narrowest = grid_template_columns(&columns, &sizing(), Priority::Essential);
288 + assert_eq!(narrowest, "minmax(200px, 1fr)");
289 + }
290 +
291 + #[test]
292 + fn narrowing_hides_a_dropped_column_by_its_own_class_not_its_position() {
293 + let css = narrowing_css(
294 + &columns(),
295 + ".ui-mode-mobile .task-row",
296 + &sizing(),
297 + Priority::Secondary,
298 + &Emit::default(),
299 + );
300 + assert!(
301 + css.contains("grid-template-columns: minmax(200px, 1fr) 110px;"),
302 + "{css}"
303 + );
304 + assert!(
305 + css.contains(".ui-mode-mobile .task-row > .col-progress {"),
306 + "{css}"
307 + );
308 + assert!(!css.contains("nth-child"), "{css}");
309 + // The kept columns are not mentioned as hidden.
310 + assert!(!css.contains(".col-due {\n display: none"), "{css}");
311 + }
312 +
313 + #[test]
314 + fn cells_follow_the_columns_and_carry_their_column_class() {
315 + let cells = [
316 + Cell {
317 + column: "due",
318 + part: Some(RowPart::Meta),
319 + content: Markup("tomorrow"),
320 + },
321 + Cell::new("description", Markup("<span>Ship it</span>")),
322 + ];
323 + let html = cells_html(&columns(), &cells, &Emit::default());
324 +
325 + // Column order, not cell order: description was passed second.
326 + let description = html.find("Ship it").expect("description cell");
327 + let due = html.find("tomorrow").expect("due cell");
328 + assert!(description < due, "{html}");
329 +
330 + assert!(
331 + html.contains(r#"<div class="cell col-description">"#),
332 + "{html}"
333 + );
334 + assert!(
335 + html.contains(r#"<div class="cell col-due row-meta">"#),
336 + "{html}"
337 + );
338 + // progress had no cell, so it is present and empty rather than absent,
339 + // or the grid would shift left by one.
340 + assert!(
341 + html.contains(r#"<div class="cell col-progress"></div>"#),
342 + "{html}"
343 + );
344 + }
345 +
346 + #[test]
347 + fn a_cell_naming_no_column_is_dropped() {
348 + let cells = [Cell::new("nonexistent", Markup("nowhere"))];
349 + let html = cells_html(&columns(), &cells, &Emit::default());
350 + assert!(!html.contains("nowhere"), "{html}");
351 + }
352 +
353 + #[test]
354 + fn the_class_prefix_reaches_the_cells_and_the_narrowing() {
355 + let opts = Emit {
356 + class_prefix: "mk-",
357 + ..Emit::default()
358 + };
359 + let cells = [Cell::new("due", Markup("x"))];
360 + assert!(
361 + cells_html(&columns(), &cells, &opts).contains("mk-cell mk-col-due"),
362 + "prefix missing"
363 + );
364 + assert!(
365 + narrowing_css(&columns(), ".t", &sizing(), Priority::Secondary, &opts)
366 + .contains(".mk-col-progress"),
367 + "prefix missing"
368 + );
369 + }
370 + }