Skip to main content

max / makeover-webview

0.23.0: a table lays itself out, and a row shows its controls Three things a description could say and this renderer had no rule for, found together by rendering the MNW server's SSH-keys settings tab through quasi and preferring the hand-written Askama original. A table had no layout at all. narrowing_css emits the track list and has to be called with the columns, so it works where the columns are known at build time: goingson builds tables.css in its own build.rs and is untouched. A table a description produced knows its columns at render time, and the rules would have had to travel with the markup: a <style> element per table, which needs style-src 'unsafe-inline' that the MNW server is working to drop, or the head, which an htmx fragment swap does not carry. table_rules uses display: table instead, which aligns columns across rows knowing nothing about how many there are. Priority hiding moves from a generated rule per dropped column to one rule per drop class, and column_classes is what puts those classes on a cell. A destructive button had nowhere for its tone to land. Every consumer had written the danger rule itself. A list rendered as a bulleted list, because nothing here reset the ul. RowPart::revealed_on_hover also stops being honoured, which its own doc sanctions. Every escape the rule grew was a report that hiding was wrong for somebody: focus-within for the keyboard, the capability gate for a fingertip with no way to unhide. What survived hid the controls from pointer users alone, who are the ones scanning a list to learn what can be done to a row.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 23:06 UTC
Signed with PGP, not checked
Commit: 21d23f29ae09ac0305c00014d3c884daeb7799e5
Parent: 7f2b621
3 files changed, +344 insertions, -119 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-webview"
3 - version = "0.22.1"
3 + version = "0.23.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"
M src/lib.rs +286 -114
@@ -159,6 +159,42 @@
159 159 //! the same magnitude with its sign off the depth. Both values are the measured
160 160 //! consensus rather than a new opinion.
161 161 //!
162 + //! # 0.23.0: a table lays itself out, and a row shows its controls
163 + //!
164 + //! Three things a description could say and this renderer had no rule for,
165 + //! found together by rendering the MNW server's SSH-keys settings tab through
166 + //! `quasi` and preferring the hand-written Askama original.
167 + //!
168 + //! **A table had no layout at all.** [`list::narrowing_css`] emits the track
169 + //! list, and it has to be called with the columns, so it works where the
170 + //! columns are known at build time: goingson builds `tables.css` in its own
171 + //! `build.rs` and is untouched. A table a description produced knows its
172 + //! columns at render time, and the rules would have had to travel with the
173 + //! markup: a `<style>` element per table, which needs `style-src
174 + //! 'unsafe-inline'` that the MNW server is working to drop, or the head, which
175 + //! an htmx fragment swap does not carry. [`table_rules`] lays a table out with
176 + //! `display: table` instead, which aligns columns across rows knowing nothing
177 + //! about how many there are. [`Priority`](makeover_layout::Priority) hiding
178 + //! moves from a generated rule per dropped column to one rule per drop class,
179 + //! and [`list::column_classes`] is what puts those classes on a cell. A header
180 + //! row emitted by a renderer's own code should call it too, or the header and
181 + //! the body disagree about which column just dropped.
182 + //!
183 + //! **A destructive button had nowhere for its tone to land.** `.button` carried
184 + //! no tone, on the reading that a control's colour is its surface. Every
185 + //! consumer had written the danger rule itself. It joins the badge in taking
186 + //! the four tones as colour, off `data-tone`.
187 + //!
188 + //! **A list rendered as a bulleted list**, because nothing here reset the `ul`
189 + //! a renderer emits for one.
190 + //!
191 + //! `RowPart::revealed_on_hover` also stops being honoured, which its own doc
192 + //! sanctions: a renderer decides. The rule hid a row's actions until hover,
193 + //! and every escape it grew was a report that hiding was wrong for somebody:
194 + //! `focus-within` for the keyboard, the capability gate for a fingertip with no
195 + //! way to unhide. What survived hid the controls from pointer users alone, who
196 + //! are the ones scanning a list to learn what can be done to a row.
197 + //!
162 198 //! # 0.17.0: the depth classes stop being controls
163 199 //!
164 200 //! [`depth_rules`] gave `.raised` the whole interactive set. A depth is a
@@ -627,6 +663,24 @@
627 663 );
628 664 }
629 665
666 + // A button carries the four tones a badge does. It had none, on the reading
667 + // that a control's colour is its surface rather than its text, and that
668 + // reading has one hole big enough to matter: the button that destroys
669 + // something. Every consumer had written that rule itself, and a description
670 + // that says `Tone::Danger` on an act had nowhere for it to land.
671 + //
672 + // Colour and not a fill, matching the badge. A red surface is a decision
673 + // about emphasis that belongs to an app's own layer, and two of them
674 + // fighting is worse than neither.
675 + let button = class("button", opts);
676 + for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
677 + let _ = writeln!(
678 + css,
679 + ".{button}[data-tone=\"{0}\"] {{\n color: var(--{0});\n}}",
680 + tone.token()
681 + );
682 + }
683 +
630 684 // A chip holds itself down, which is `Depth::pressed` arrived at
631 685 // independently by two apps. `removable` is a remove affordance, so it is
632 686 // markup and waits for phase B.
@@ -679,7 +733,18 @@
679 733 /// [`part_class`] carries the same list and the same obligation.
680 734 fn row_rules(opts: &Emit) -> String {
681 735 let mut css = String::new();
682 - let row = class("row", opts);
736 +
737 + // The container the rows sit in. Three zeroes and a keyword, all of them
738 + // undoing a browser default the description never asked for: a described
739 + // list of SSH keys is not a bulleted list, and it rendered as one because
740 + // nothing here said otherwise. Not a size: there is no magnitude in it,
741 + // which is the line this crate holds.
742 + let _ = writeln!(
743 + css,
744 + ".{} {{\n list-style: none;\n margin: 0;\n padding: 0;\n}}",
745 + class("list", opts)
746 + );
747 +
683 748 for part in [
684 749 RowPart::Primary,
685 750 RowPart::Secondary,
@@ -703,52 +768,26 @@
703 768 let _ = writeln!(css, ".{c} {{\n color: var(--{});\n}}", part.intent());
704 769 }
705 770
706 - if part.revealed_on_hover() {
707 - // Hidden rather than absent: the row must not change height when
708 - // the pointer arrives. `focus-within` carries the keyboard, which
709 - // hover on its own would lock out.
710 - //
711 - // Transparent rather than `visibility: hidden`, which was the first
712 - // form and defeated the very escape above: a `visibility: hidden`
713 - // element is out of the focus order and out of the accessibility
714 - // tree, so tabbing could never reach an action and could never
715 - // trigger the row's `focus-within`. goingson had reached the same
716 - // opacity form independently, on its own comment "always in the DOM
717 - // for keyboard and screen readers".
718 - //
719 - // `pointer-events` rides along because opacity leaves the hit area
720 - // behind: without it a renderer with no hover carries an invisible
721 - // tappable control. Keyboard focus is unaffected by it.
722 - // The hide is gated too, which it was not in 0.10.0, and that was
723 - // an incomplete capability answer rather than a deliberate one.
724 - // Ungated, a fingertip got actions hidden with no hover to bring
725 - // them back, so both webview apps hand-wrote the same
726 - // `opacity: 1` to undo it: goingson in its touch block and
727 - // Balanced Breakfast in its own. A primitive that owns the hiding
728 - // owes the answer for the device that cannot unhide, and the
729 - // answer both consumers already reached is not to hide at all.
730 - css.push_str(&gated(
731 - hover_condition(),
732 - &format!(".{c} {{\n opacity: 0;\n pointer-events: none;\n}}\n"),
733 - ));
734 -
735 - // The two halves split here, where they used to be one selector
736 - // list. Hover-to-reveal is the literal case `Affordance::Hover`
737 - // was written from, and on a touchscreen it does not fail
738 - // gracefully: the actions are simply unreachable, because there
739 - // is no pointer to bring them back. So the hover half is gated
740 - // and the app owes those rows another way in.
741 - //
742 - // `focus-within` stays outside the query. A touchscreen device
743 - // with a keyboard attached is a real thing, and it is the one
744 - // path to these actions that survives the gate.
745 - let revealed = " opacity: 1;\n pointer-events: auto;\n";
746 - css.push_str(&gated(
747 - hover_condition(),
748 - &format!(".{row}:hover .{c} {{\n{revealed}}}\n"),
749 - ));
750 - let _ = write!(css, ".{row}:focus-within .{c} {{\n{revealed}}}\n");
751 - }
771 + // `RowPart::revealed_on_hover` is deliberately not honoured, and its
772 + // own doc is what sanctions that: "a renderer with no hover shows it
773 + // always. That is a renderer decision and this returning `true` does
774 + // not forbid it." This renderer now makes the same decision for every
775 + // device, and the reasoning is the one the gate had already been
776 + // forced into twice.
777 + //
778 + // The rule was `opacity: 0` gated to pointer devices, revealed on
779 + // `:hover` and on `:focus-within`. Each escape it needed was a report
780 + // that hiding was wrong for somebody: `focus-within` because tabbing
781 + // could never reach an action; the gate because a fingertip had no way
782 + // to unhide, which both webview apps had already hand-written
783 + // `opacity: 1` to undo. What was left was a control hidden from
784 + // exactly one group: people using a pointer, who are also the group
785 + // scanning a list to find out what can be done to a row.
786 + //
787 + // A settings screen is where that reads worst: the whole reason to be
788 + // on it is to remove a key, and the button doing so was invisible
789 + // until pointed at. A table cell's actions were never hidden, so the
790 + // two arrangements now agree.
752 791 }
753 792 css
754 793 }
@@ -886,6 +925,95 @@
886 925 css
887 926 }
888 927
928 + /// The frame a table sits in.
929 + ///
930 + /// # Why this is a CSS table and not the grid the rest of the module assumes
931 + ///
932 + /// A grid row needs `grid-template-columns`, which has to name every column in
933 + /// order, so it cannot be written without knowing the columns. That is what
934 + /// [`list::narrowing_css`] is for, and it works: goingson builds `tables.css` in
935 + /// its own `build.rs` out of it, and nothing here changes that.
936 + ///
937 + /// It does not work for a table a *description* produced. Those columns are
938 + /// known at render time rather than at build time, and the rules would have to
939 + /// travel with the markup: a `<style>` element per table, which needs
940 + /// `style-src 'unsafe-inline'` and so blocks the MNW server's standing plan to
941 + /// drop it. The head is not an escape: a table swapped in by htmx after a
942 + /// delete arrives as a fragment with no head at all.
943 + ///
944 + /// A CSS table aligns its columns across rows knowing nothing about how many
945 + /// there are, so there is no track list to emit and nothing per-table to carry.
946 + /// The cost is that a described table cannot take a per-column fixed length,
947 + /// which costs nothing today: [`Sizing`](list::Sizing) is looked up by column
948 + /// name and a description carries no lengths to put in it, so every track a
949 + /// described table could ask for is already content, fill or auto.
950 + ///
951 + /// [`Priority`] hiding is unchanged in kind. It moves from a generated
952 + /// `display: none` per dropped column to one rule per drop class, which is the
953 + /// same fact addressed by class rather than by cutoff, and still never by
954 + /// position.
955 + fn table_rules(opts: &Emit) -> String {
956 + let table = class("table", opts);
957 + let head = class("table-head", opts);
958 + let row = class("table-row", opts);
959 + let heading = class("table-heading", opts);
960 + let cell = class("cell", opts);
961 + let mut css = String::new();
962 +
963 + let _ = writeln!(
964 + css,
965 + ".{table} {{\n display: table;\n width: 100%;\n}}"
966 + );
967 + let _ = writeln!(
968 + css,
969 + ".{head},\n.{row} {{\n display: table-row;\n}}"
970 + );
971 + let _ = writeln!(
972 + css,
973 + ".{heading},\n.{cell} {{\n display: table-cell;\n}}"
974 + );
975 +
976 + // A content column shrinks to what is in it. `width: 1%` is how a CSS table
977 + // is told that: auto layout hands the slack to the columns that asked for
978 + // room, and a column asking for almost none gets what it needs and no more.
979 + // The `nowrap` is what stops it being given less by wrapping.
980 + let _ = writeln!(
981 + css,
982 + ".{} {{\n white-space: nowrap;\n width: 1%;\n}}",
983 + class("cell-content", opts)
984 + );
985 +
986 + // A fixed column has no length to be fixed to. The description carries none
987 + // and `Sizing` is not reachable from here, so it behaves as content: the
988 + // honest answer to a width nobody supplied, and the same one `Sizing::track`
989 + // gives it.
990 + let _ = writeln!(
991 + css,
992 + ".{} {{\n white-space: nowrap;\n}}",
993 + class("cell-fixed", opts)
994 + );
995 +
996 + // Optional columns go at the narrowest class, secondary ones go with them,
997 + // which is the cutoff walk `kept_at` describes said as two media queries.
998 + // Essential columns have no rule at all, because never dropping is what not
999 + // being mentioned already means.
1000 + for (size, drops) in [
1001 + (SizeClass::Compact, &["cell-drops-first", "cell-drops-next"][..]),
1002 + (SizeClass::Medium, &["cell-drops-first"][..]),
1003 + ] {
1004 + let selectors: Vec<String> = drops
1005 + .iter()
1006 + .map(|drop| format!(".{}", class(drop, opts)))
1007 + .collect();
1008 + css.push_str(&gated(
1009 + Some(&size.media_condition()),
1010 + &format!("{} {{\n display: none;\n}}\n", selectors.join(",\n")),
1011 + ));
1012 + }
1013 +
1014 + css
1015 + }
1016 +
889 1017 /// The component layer: every named thing phase A emits.
890 1018 ///
891 1019 /// No scrollbar track. It was on the phase A list and came off: eight lines of
@@ -901,6 +1029,7 @@
901 1029 css.push_str(&progress_rules(opts));
902 1030 css.push_str(&figure_rules(opts));
903 1031 css.push_str(&state_rules(opts));
1032 + css.push_str(&table_rules(opts));
904 1033 css
905 1034 }
906 1035
@@ -1214,40 +1343,20 @@
1214 1343 let condition = format!("@media {}", Density::Pointer.media_condition());
1215 1344 assert!(css.contains(&condition));
1216 1345
1217 - // The row reveal splits: hover inside the query, focus-within outside,
1218 - // or a touchscreen with a keyboard loses its only way to the actions.
1219 - // Compared by indentation rather than by brace-hunting, because both
1220 - // sit inside the cascade layer now and every brace is nested.
1221 - let indent = |needle: &str| {
1222 - let line = css
1223 - .lines()
1224 - .find(|l| l.contains(needle))
1225 - .unwrap_or_else(|| panic!("no line for {needle}"));
1226 - line.len() - line.trim_start().len()
1227 - };
1228 - let hover = indent(".row:hover .row-actions");
1229 - let keyboard = indent(".row:focus-within .row-actions");
1230 - // The hide is gated with the reveal. Ungated it leaves a fingertip
1231 - // with actions it cannot bring back, which is what both webview apps
1232 - // were undoing by hand.
1233 - let hide = css
1346 + // What is gated is every hover state the surfaces carry. The row's
1347 + // actions used to be the other half of this test and are not gated any
1348 + // more, because they are not hidden any more: a rule that reveals
1349 + // nothing needs no capability answer.
1350 + let gated: Vec<&str> = css
1234 1351 .lines()
1235 - .position(|l| l.trim() == ".row-actions {")
1236 - .expect("hide rule");
1237 - let query = css
1238 - .lines()
1239 - .take(hide)
1240 - .enumerate()
1241 - .filter(|(_, l)| l.trim_start().starts_with("@media"))
1242 - .map(|(i, _)| i)
1243 - .last()
1244 - .expect("a query precedes it");
1245 - assert!(hide - query < 3, "the hide is not inside the query");
1246 - assert!(
1247 - hover > keyboard,
1248 - "hover reveal must be nested inside the capability query and the \
1249 - keyboard reveal must not be: hover indent {hover}, keyboard {keyboard}"
1250 - );
1352 + .filter(|line| line.contains(":hover"))
1353 + .collect();
1354 + assert!(!gated.is_empty(), "{css}");
1355 + for line in gated {
1356 + let indent = line.len() - line.trim_start().len();
1357 + assert!(indent > 4, "an ungated hover rule: {line}");
1358 + }
1359 + assert!(!css.contains(".row:hover"), "{css}");
1251 1360 }
1252 1361
1253 1362 #[test]
@@ -1365,43 +1474,29 @@
1365 1474 }
1366 1475
1367 1476 #[test]
1368 - fn row_actions_are_revealed_without_moving_the_row() {
1477 + fn a_rows_actions_are_shown_at_rest() {
1369 1478 let css = row_rules(&Emit::default());
1370 - assert!(css.contains(".row-actions {\n opacity: 0;"));
1371 - // Not display:none, which would reflow the row under the pointer.
1372 - assert!(!css.contains("display: none"));
1373 - // Hover alone would lock the keyboard out.
1374 - assert!(css.contains(".row:focus-within .row-actions"));
1479 +
1480 + // The hover reveal is gone, and with it every escape it needed. What
1481 + // it hid was hidden from pointer users alone, who are the ones
1482 + // scanning a list to learn what can be done to a row.
1483 + assert!(!css.contains("opacity"), "{css}");
1484 + assert!(!css.contains("pointer-events"), "{css}");
1485 + assert!(!css.contains(":hover"), "{css}");
1486 + assert!(!css.contains(":focus-within"), "{css}");
1487 +
1488 + // Nor is it hidden any other way. `display: none` would reflow the row
1489 + // and `visibility: hidden` would take the actions out of the focus
1490 + // order; the point is that neither is reached for.
1491 + assert!(!css.contains("display: none"), "{css}");
1492 + assert!(!css.contains("visibility:"), "{css}");
1493 +
1494 + // The description still says the part is revealed on hover. That is a
1495 + // renderer's decision to make, in the words of the method's own doc,
1496 + // and this renderer now decides otherwise for every device.
1375 1497 assert!(RowPart::Actions.revealed_on_hover());
1376 1498 }
1377 1499
1378 - #[test]
1379 - fn a_hidden_row_action_is_still_focusable_and_not_tappable() {
1380 - let css = row_rules(&Emit::default());
1381 - // `visibility: hidden` takes the actions out of the focus order, so the
1382 - // `focus-within` reveal above could never fire from an action itself.
1383 - assert!(!css.contains("visibility:"));
1384 - // Opacity leaves the hit area behind, so the pair travels together or
1385 - // the row carries an invisible tappable control.
1386 - assert!(
1387 - css.contains(
1388 - ".row-actions {\n opacity: 0;\n pointer-events: none;\n }"
1389 - )
1390 - );
1391 - assert!(css.contains("opacity: 1;\n pointer-events: auto;"));
1392 -
1393 - // And on a device with no hover the row carries no such control at
1394 - // all, because the hide never applies there. That is what the comment
1395 - // above used to be asking for and could not get: the hide is inside
1396 - // the capability query with the reveal.
1397 - let hide = css.find(".row-actions {").expect("hide rule");
1398 - let query = css[..hide].rfind("@media").expect("a query precedes it");
1399 - assert!(
1400 - css[query..hide].find('}').is_none(),
1401 - "the hide escaped the query"
1402 - );
1403 - }
1404 -
1405 1500 #[test]
1406 1501 fn the_three_text_parts_take_their_intents_and_actions_inherits() {
1407 1502 let css = row_rules(&Emit::default());
@@ -1549,6 +1644,63 @@
1549 1644 assert!(!css.contains(".badge {"));
1550 1645 }
1551 1646
1647 + #[test]
1648 + fn a_destructive_button_has_somewhere_for_its_tone_to_land() {
1649 + let css = component_rules(&Emit::default());
1650 +
1651 + for tone in [Tone::Info, Tone::Success, Tone::Warning, Tone::Danger] {
1652 + assert!(
1653 + css.contains(&format!(".button[data-tone=\"{}\"]", tone.token())),
1654 + "{css}"
1655 + );
1656 + }
1657 + // Colour, not a fill. A red surface is an app's decision about emphasis.
1658 + assert!(!css.contains(".button[data-tone=\"danger\"] {\n background"));
1659 + }
1660 +
1661 + #[test]
1662 + fn a_described_list_is_not_a_bulleted_list() {
1663 + let css = component_rules(&Emit::default());
1664 + assert!(css.contains(".list {\n list-style: none;"), "{css}");
1665 + }
1666 +
1667 + #[test]
1668 + fn a_table_lays_itself_out_without_being_told_its_columns() {
1669 + // The whole point of the CSS table. A described table's columns are
1670 + // known at render time, so anything the stylesheet has to be told about
1671 + // them would have to travel with the markup.
1672 + let css = component_rules(&Emit::default());
1673 +
1674 + assert!(css.contains(".table {\n display: table;"), "{css}");
1675 + assert!(css.contains("display: table-row;"), "{css}");
1676 + assert!(css.contains("display: table-cell;"), "{css}");
1677 + assert!(!css.contains("grid-template-columns"), "{css}");
1678 + }
1679 +
1680 + #[test]
1681 + fn a_column_drops_by_its_priority_and_never_by_its_position() {
1682 + let css = component_rules(&Emit::default());
1683 +
1684 + // Optional goes at the narrowest class and secondary goes with it,
1685 + // which is `kept_at`'s cutoff walk said as two queries.
1686 + let compact = css
1687 + .find(&format!("@media {}", SizeClass::Compact.media_condition()))
1688 + .expect("a compact query");
1689 + let medium = css
1690 + .find(&format!("@media {}", SizeClass::Medium.media_condition()))
1691 + .expect("a medium query");
1692 + assert!(css[compact..].contains(".cell-drops-next"), "{css}");
1693 + assert!(!css[medium..].contains(".cell-drops-next"), "{css}");
1694 +
1695 + // Essential columns are never mentioned, because not being mentioned is
1696 + // already what never dropping means.
1697 + assert!(!css.contains(".cell-keeps"), "{css}");
1698 +
1699 + // And nothing counts. `nth-child` is the bug the priority vocabulary
1700 + // exists to end.
1701 + assert!(!css.contains("nth-child"), "{css}");
1702 + }
1703 +
1552 1704 #[test]
1553 1705 fn the_whole_sheet_still_names_every_colour() {
1554 1706 // The crate's founding property, asserted over the component layer and
@@ -1592,7 +1744,27 @@
1592 1744 || value.starts_with("\"\\2")
1593 1745 || matches!(
1594 1746 value.trim_end_matches(';'),
1595 - "0" | "1" | "none" | "auto" | "not-allowed" | "pointer"
1747 + "0" | "1"
1748 + | "none"
1749 + | "auto"
1750 + | "not-allowed"
1751 + | "pointer"
1752 + // The table frame. `display` is structure and not a
1753 + // size; `nowrap` is what makes a content column
1754 + // content. The two widths are the awkward pair and
1755 + // they are still not sizes: `100%` is "all of
1756 + // whatever you were given" and `1%` is the CSS
1757 + // table idiom for "shrink to fit", which is a
1758 + // behaviour spelled as a number because CSS has no
1759 + // keyword for it. Neither names a magnitude, which
1760 + // is the thing this crate leaves to
1761 + // makeover-geometry.
1762 + | "table"
1763 + | "table-row"
1764 + | "table-cell"
1765 + | "nowrap"
1766 + | "100%"
1767 + | "1%"
1596 1768 ),
1597 1769 "unrecognised literal value: {line}"
1598 1770 );
M src/list.rs +57 -4
@@ -96,6 +96,56 @@
96 96 class(&format!("col-{}", column.name), opts)
97 97 }
98 98
99 + /// The class saying how wide a cell of this column asks to be.
100 + ///
101 + /// A bounded vocabulary, unlike [`column_class`], which is why the stylesheet
102 + /// can carry the rule. [`Width`] is `#[non_exhaustive]`, and a member added
103 + /// upstream lands on the fill class: a column that takes the slack is the
104 + /// behaviour that makes no claim, matching the `auto` track
105 + /// [`Sizing::track`] falls back to for the same reason.
106 + fn width_class(width: Width) -> &'static str {
107 + match width {
108 + Width::Content => "cell-content",
109 + Width::Fixed => "cell-fixed",
110 + _ => "cell-fill",
111 + }
112 + }
113 +
114 + /// The class saying when a cell of this column drops.
115 + ///
116 + /// [`Priority`] said as a class rather than as a cutoff, so the hiding can live
117 + /// in the stylesheet instead of being generated per table. That is what a
118 + /// [`display: table`](crate::table_rules) frame needs and a grid one cannot use:
119 + /// a grid also has to shorten its track list, which only the columns themselves
120 + /// can say.
121 + fn drop_class(priority: Priority) -> &'static str {
122 + match priority {
123 + Priority::Optional => "cell-drops-first",
124 + Priority::Secondary => "cell-drops-next",
125 + // A priority added upstream keeps its column. `Priority` is
126 + // `#[non_exhaustive]`, and of the two ways to be wrong about one this
127 + // renderer has not learned, showing a column that should have dropped
128 + // is the one the user can see and work around.
129 + _ => "cell-keeps",
130 + }
131 + }
132 +
133 + /// Every class a cell of this column carries.
134 + ///
135 + /// The column's own name, how wide it asks to be, and when it drops. A header
136 + /// cell has to carry the same three or the header and the body disagree about
137 + /// which column just disappeared, and a renderer emitting its own header row
138 + /// should call this rather than assemble the list a second time.
139 + #[must_use]
140 + pub fn column_classes(column: &Column<'_>, opts: &Emit) -> String {
141 + format!(
142 + "{} {} {}",
143 + column_class(column, opts),
144 + class(width_class(column.width), opts),
145 + class(drop_class(column.priority), opts)
146 + )
147 + }
148 +
99 149 /// The `grid-template-columns` value for the columns kept at `cutoff`.
100 150 ///
101 151 /// Emitting only the surviving tracks is what keeps the track list and the
@@ -241,7 +291,7 @@
241 291
242 292 for column in columns {
243 293 let found = cells.iter().find(|cell| cell.column == column.name);
244 - let mut classes = format!("{cell_class} {}", column_class(column, opts));
294 + let mut classes = format!("{cell_class} {}", column_classes(column, opts));
245 295 if let Some(part) = found.and_then(|cell| cell.part) {
246 296 let _ = write!(classes, " {}", class(part_class(part), opts));
247 297 }
@@ -386,18 +436,21 @@
386 436 let due = html.find("tomorrow").expect("due cell");
387 437 assert!(description < due, "{html}");
388 438
439 + // Three classes, not one: the column's own name, how wide it asks to
440 + // be, and when it drops. The last two are what lets the stylesheet
441 + // carry rules a described table cannot generate per table.
389 442 assert!(
390 - html.contains(r#"<div class="cell col-description">"#),
443 + html.contains(r#"<div class="cell col-description cell-fill cell-keeps">"#),
391 444 "{html}"
392 445 );
393 446 assert!(
394 - html.contains(r#"<div class="cell col-due row-meta">"#),
447 + html.contains(r#"<div class="cell col-due cell-fixed cell-drops-next row-meta">"#),
395 448 "{html}"
396 449 );
397 450 // progress had no cell, so it is present and empty rather than absent,
398 451 // or the grid would shift left by one.
399 452 assert!(
400 - html.contains(r#"<div class="cell col-progress"></div>"#),
453 + html.contains(r#"<div class="cell col-progress cell-fixed cell-drops-first"></div>"#),
401 454 "{html}"
402 455 );
403 456 }