Skip to main content

max / quasi

Draw a list row and a table row with one walk Wave 1a. The two row emitters were one function written twice: row_html and cells_row_html each decided the row's classes, then each wrote data-row, the address as an id, the depth and its aria-level, the fold, aria-current and aria-selected. The class names differed by a prefix and the element differed, and nothing else about the decision did. RowLook carries what is genuinely different. It is not a preference: the element, the class names and the gutter's wrapping are a contract with makeover's stylesheet and with the hosts that bind to them, so both looks emit exactly what they emitted before. Which look a container takes is the container's, and once the grammar is one grammar it becomes whether the columns carry headings rather than which node it was. row_html loses 60 lines and cells_row_html 97, against an enum and two shared functions. Roughly line-neutral and that is the wrong measure: a change to what a row carries now happens once instead of twice, which is the drift these two had already suffered. Wave 0's placeholder is gone. A cell keyed to a declared column reaching a list row is a description error, not something to style as Primary, so it is a debug assertion and a benign release fallback. That is the bargain Table::row's two assertions already strike (d41d00a): a panic in a description is worse than a row drawn under the wrong style. Two asymmetries are left alone and recorded on the task rather than fixed here, because changing what a served screen emits is not a refactor: a list row emits no data-value, spending the same member on its checkbox's value instead, and it emits no data-change, which the merge made reachable on a list row for the first time. 382 tests green, output byte-identical.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-06 01:22 UTC
Signed with PGP, not checked
Commit: 2c6ff89f4223eb6658e1d0c4292011b5a4023cf0
Parent: 194cded
2 files changed, +169 insertions, -152 deletions
@@ -6212,12 +6212,12 @@
6212 6212 }
6213 6213 }
6214 6214
6215 - /// `Part` was merged into [`Cell`] on 2026-09-05.
6216 - ///
6217 - /// A part was a cell that carried its own role because a list had no columns to
6218 - /// carry it. Now a list declares columns like a table does, so the role is
6219 - /// [`CellKey::Role`] and there is one type. `Part::worth` is [`Cell::priority`]
6220 - /// falling back to its key.
6215 + // `Part` was merged into `Cell` on 2026-09-05.
6216 + //
6217 + // A part was a cell that carried its own role because a list had no columns to
6218 + // carry it. Now a list declares columns like a table does, so the role is
6219 + // `CellKey::Role` and there is one type. `Part::worth` is `Cell::priority`
6220 + // falling back to its key.
6221 6221
6222 6222 /// One row of a list.
6223 6223 ///
@@ -2014,8 +2014,85 @@
2014 2014 /// branch above it is hiding it. The row is emitted regardless and marked
2015 2015 /// `hidden`, which is what lets `outline.js` open a branch without asking the
2016 2016 /// app for rows the document is already holding.
2017 - fn row_html(row: &Row, folded: bool, opts: &Emit, out: &mut String) {
2018 - let mut classes = vec!["row"];
2017 + /// How a row is drawn: as a list item, or as a row of a declared table.
2018 + ///
2019 + /// The 2026-09-05 collapse made a list row and a table row one type, and this
2020 + /// is what is genuinely left of the difference. It is not a preference: the
2021 + /// element, the class names and the gutter's wrapping are a contract with
2022 + /// makeover's stylesheet and with the hosts that bind to them, so the two looks
2023 + /// keep emitting exactly what they emitted before. What they no longer do is
2024 + /// keep two copies of the walk that decides it.
2025 + ///
2026 + /// Which look a container takes is the container's, not the row's. Today that
2027 + /// is `Node::List` against `Node::Table`; once the grammar is one grammar
2028 + /// (wave `bd8d5ce7`) it becomes whether the columns carry headings.
2029 + #[derive(Clone, Copy, PartialEq, Eq)]
2030 + enum RowLook {
2031 + /// A list item. Flows its cells as spans on one wrapped line.
2032 + List,
2033 + /// A row of a declared table. Emits a cell per column, aligned by
2034 + /// `display: table`.
2035 + Table,
2036 + }
2037 +
2038 + impl RowLook {
2039 + /// The opening tag, without its attributes.
2040 + const fn open_tag(self) -> &'static str {
2041 + match self {
2042 + Self::List => "<li",
2043 + Self::Table => "<div role=\"row\"",
2044 + }
2045 + }
2046 +
2047 + /// The closing tag.
2048 + const fn close_tag(self) -> &'static str {
2049 + match self {
2050 + Self::List => "</li>",
2051 + Self::Table => "</div>",
2052 + }
2053 + }
2054 +
2055 + /// The class every row of this look carries.
2056 + const fn base_class(self) -> &'static str {
2057 + match self {
2058 + Self::List => "row",
2059 + Self::Table => "table-row",
2060 + }
2061 + }
2062 +
2063 + /// The class for the row the detail side is showing.
2064 + const fn current_class(self) -> &'static str {
2065 + match self {
2066 + Self::List => "row-current",
2067 + Self::Table => "table-row-current",
2068 + }
2069 + }
2070 +
2071 + /// The class for a row in a selection already in force.
2072 + const fn chosen_class(self) -> &'static str {
2073 + match self {
2074 + Self::List => "row-chosen",
2075 + Self::Table => "table-row-chosen",
2076 + }
2077 + }
2078 +
2079 + /// The class for a ticked row.
2080 + const fn selected_class(self) -> &'static str {
2081 + match self {
2082 + Self::List => "row-selected",
2083 + Self::Table => "table-row-selected",
2084 + }
2085 + }
2086 + }
2087 +
2088 + /// The classes a row carries, in the order this emitter has always written
2089 + /// them.
2090 + ///
2091 + /// Shared by both looks since 2026-09-05. `row-nested` and `row-branch` were
2092 + /// already spelled the same on both, which is the tell that the two functions
2093 + /// were one function.
2094 + fn row_classes(row: &Row, look: RowLook) -> Vec<&'static str> {
2095 + let mut classes = vec![look.base_class()];
2019 2096 if row.depth.is_nested() {
2020 2097 classes.push("row-nested");
2021 2098 }
@@ -2023,43 +2100,71 @@
2023 2100 classes.push("row-branch");
2024 2101 }
2025 2102 if row.current {
2026 - classes.push("row-current");
2103 + // `1894e95d`. A live selection, which is neither of the two below it:
2104 + // not the app's pointer at one row and not a staged tick. Its own class
2105 + // rather than reusing the tick's, because the tick's class is what a
2106 + // commit control gathers by and these rows have nothing to commit.
2107 + classes.push(look.current_class());
2027 2108 }
2028 - // `1894e95d`. A live selection, which is neither of the two below it: not
2029 - // the app's pointer at one row and not a staged tick. `row-chosen` rather
2030 - // than reusing `row-selected`, because the tick's class is what a commit
2031 - // control gathers by and these rows have nothing to commit.
2032 2109 if row.chosen == Some(true) {
2033 - classes.push("row-chosen");
2110 + classes.push(look.chosen_class());
2034 2111 }
2035 2112 if row.selected == Some(true) {
2036 - classes.push("row-selected");
2113 + classes.push(look.selected_class());
2037 2114 }
2115 + classes
2116 + }
2038 2117
2039 - out.push_str("<li");
2040 - class_attr(&classes, opts, out);
2041 - // What `Replaces::Enclosing` targets. Emitted on every row rather than only
2042 - // on rows that happen to hold such an act: the act is inside the row, the
2043 - // row cannot see it, and a walk to find out would cost more than the
2044 - // attribute.
2118 + /// The row's own attributes, from `data-row` to the `>` that closes the tag.
2119 + ///
2120 + /// Shared by both looks. Every attribute here was written twice before
2121 + /// 2026-09-05, in two functions that had drifted: the table row emitted
2122 + /// `data-value` and `data-change` and the list row did not, which is recorded
2123 + /// as a finding on wave `f0dac2d6` rather than silently fixed here, because
2124 + /// changing what a served screen emits is not a refactor.
2125 + fn row_attrs_html(row: &Row, look: RowLook, folded: bool, out: &mut String) {
2126 + // `Replaces::Enclosing`'s target.
2045 2127 out.push_str(" data-row");
2046 - // The address a reader links to, which is the one row member that is an
2047 - // `id` rather than a `data-` attribute: `Row::address` is documented as
2048 - // unique in the document, which is exactly what an id promises and what
2049 - // `data-value` beside it deliberately does not.
2128 + if look == RowLook::Table {
2129 + // What the app calls this row, unique within its table. A list row
2130 + // carries the same fact and spends it on its checkbox's `value=`
2131 + // instead; see the finding on wave `f0dac2d6`.
2132 + if let Some(value) = &row.value {
2133 + out.push_str(" data-value=\"");
2134 + escape_into(value, out);
2135 + out.push('"');
2136 + }
2137 + }
2138 + // The address, which is the document's name for the row rather than the
2139 + // app's, and so is the one that gets an `id`.
2050 2140 if let Some(address) = &row.address {
2051 2141 out.push_str(" id=\"");
2052 2142 escape_into(address, out);
2053 2143 out.push('"');
2054 2144 }
2055 - // The indent, as a number the stylesheet multiplies by whatever a step is
2056 - // worth. Emitted as a custom property for `--track-at`'s reason: the rule
2057 - // stays static while the data moves, and how wide a level is stays a size
2058 - // this crate does not name.
2059 - //
2060 - // `data-depth` beside it because `outline.js` reads the depth back to work
2061 - // out what a branch covers, and reading a style property to do arithmetic
2062 - // is not what a style property is.
2145 + if look == RowLook::Table {
2146 + // Which side of a change this line is on, when the table is a diff.
2147 + // `19d7602d`. An attribute rather than a class, for `tone_attr`'s
2148 + // reason: makeover keys every intent off a data attribute, so a host
2149 + // that has never heard of a diff still gets the tones it styles.
2150 + //
2151 + // `None` writes nothing, which keeps every ordinary table from reading
2152 + // as a diff whose lines are all context.
2153 + if let Some(change) = row.change {
2154 + out.push_str(" data-change=\"");
2155 + out.push_str(match change {
2156 + layout::Change::Added => "added",
2157 + layout::Change::Removed => "removed",
2158 + // Including a kind added to this `#[non_exhaustive]` axis that
2159 + // this renderer has not learned: an unknown side reads as an
2160 + // unchanged line, which draws the text and loses only the tint.
2161 + _ => "context",
2162 + });
2163 + out.push_str("\" data-tone=\"");
2164 + out.push_str(layout::Intent::token(change));
2165 + out.push('"');
2166 + }
2167 + }
2063 2168 if row.depth.is_nested() {
2064 2169 let _ = write!(
2065 2170 out,
@@ -2068,24 +2173,12 @@
2068 2173 level = row.depth.level + 1
2069 2174 );
2070 2175 }
2071 - // A row under a shut branch. In the document and out of the picture: a
2072 - // reader who opens the branch gets it back with no request, and a screen
2073 - // reader is told the same thing the eye is.
2074 2176 if folded {
2075 2177 out.push_str(" hidden");
2076 2178 }
2077 2179 if row.current {
2078 - // `current` is what the detail side is showing: a current item within a
2079 - // set rather than a pressed control, and rather than anything the user
2080 - // ticked. That distinction is why the description carries two fields.
2081 2180 out.push_str(" aria-current=\"true\"");
2082 2181 }
2083 - // `aria-selected` and not `aria-current`, which is the one place the two
2084 - // ARIA names line up with the vocabulary's own distinction: a screen reader
2085 - // says "selected" of a row that is part of the set being acted on and
2086 - // "current" of the one the view is showing. A row that can be chosen and is
2087 - // not says so rather than staying silent, because "one of eleven selected"
2088 - // is only audible if the unselected ones are announced too.
2089 2182 if let Some(chosen) = row.chosen {
2090 2183 out.push_str(if chosen {
2091 2184 " aria-selected=\"true\""
@@ -2093,6 +2186,13 @@
2093 2186 " aria-selected=\"false\""
2094 2187 });
2095 2188 }
2189 + }
2190 +
2191 + fn row_html(row: &Row, folded: bool, opts: &Emit, out: &mut String) {
2192 + let classes = row_classes(row, RowLook::List);
2193 + out.push_str(RowLook::List.open_tag());
2194 + class_attr(&classes, opts, out);
2195 + row_attrs_html(row, RowLook::List, folded, out);
2096 2196 out.push('>');
2097 2197
2098 2198 // The disclosure, when the row is a branch. Before the tick and before the
@@ -2181,10 +2281,24 @@
2181 2281 .take_while(|cell| cell.key == key && cell.room() == flow)
2182 2282 .count();
2183 2283 let (group, tail) = rest.split_at(taken);
2284 + // A list draws over the default column set, so every cell in one is
2285 + // keyed by role. A cell keyed to a declared column has reached a list
2286 + // row, which is a description error rather than something to render:
2287 + // the table constructors (`Row::cells`, `at`, `cell`) produce those
2288 + // keys and a list row is built by `Row::new` and its siblings.
2289 + //
2290 + // Loud in debug and benign in release, which is the bargain
2291 + // `Table::row`'s two assertions already strike here (`d41d00a`): a
2292 + // panic in a description is worse than a row that draws its text under
2293 + // the wrong style.
2294 + debug_assert!(
2295 + matches!(key, quasi_router::CellKey::Role(_)),
2296 + "a cell keyed to a declared column reached a list row, which draws \
2297 + over the default column set and has no column to style it from. \
2298 + Key: {key:?}",
2299 + );
2184 2300 let role = match &key {
2185 2301 quasi_router::CellKey::Role(role) => *role,
2186 - // A declared column has no role; it styles from its column, which
2187 - // this walk does not hold. Wave 1a is where that is done properly.
2188 2302 _ => quasi_router::layout::RowPart::Primary,
2189 2303 };
2190 2304 row_part_html(row, role, flow, group, opts, out);
@@ -2207,7 +2321,7 @@
2207 2321 out.push_str("</div>");
2208 2322 }
2209 2323
2210 - out.push_str("</li>");
2324 + out.push_str(RowLook::List.close_tag());
2211 2325 }
2212 2326
2213 2327 /// One run of consecutive row parts sharing a role.
@@ -2450,111 +2564,14 @@
2450 2564 opts: &Emit,
2451 2565 out: &mut String,
2452 2566 ) {
2453 - let mut classes = vec!["table-row"];
2454 - if cells.depth.is_nested() {
2455 - classes.push("row-nested");
2456 - }
2457 - if cells.open.is_some() {
2458 - classes.push("row-branch");
2459 - }
2460 - if cells.current {
2461 - // `table-row-current`, matching `row-current` on a list row. It was
2462 - // `table-row-selected` while the field was, so the class said one thing
2463 - // and the `aria-current` two lines down said the other.
2464 - classes.push("table-row-current");
2465 - }
2466 -
2467 - // `table-row-chosen`, shadowing `row-chosen` the way `table-row-current`
2468 - // shadows `row-current`. See the list row's own comment. `1894e95d`.
2469 - if cells.chosen == Some(true) {
2470 - classes.push("table-row-chosen");
2471 - }
2472 -
2473 - if cells.selected == Some(true) {
2474 - classes.push("table-row-selected");
2475 - }
2476 -
2477 - out.push_str("<div role=\"row\"");
2567 + let classes = row_classes(cells, RowLook::Table);
2568 + out.push_str(RowLook::Table.open_tag());
2478 2569 class_attr(&classes, opts, out);
2479 - // `Replaces::Enclosing`'s target, matching the list row's.
2480 - out.push_str(" data-row");
2481 - // What the app calls this row. `Cells::identified` is documented as the
2482 - // identity a renderer answers with when a host asks which row it is
2483 - // looking at, and this emitter only wrote it as a checkbox value, so a row
2484 - // that carried an identity and no tick reached the document carrying
2485 - // nothing -- an identity the description had said and no host could read
2486 - // back. MNW's file view found it: its rows are lines, and a line's identity
2487 - // is what `#L42` addresses.
2488 - //
2489 - // `data-value` rather than `id`, because a row identity is unique within
2490 - // its table and an id is unique in a document: two tables of the same
2491 - // things on one screen is an ordinary description and two elements under
2492 - // one id is markup a browser resolves by picking the first.
2493 - if let Some(value) = &cells.value {
2494 - out.push_str(" data-value=\"");
2495 - escape_into(value, out);
2496 - out.push('"');
2497 - }
2498 - // And the address, which is the case that comment names. A value is the
2499 - // app's name for the row and an id is the document's, so the one member
2500 - // that promises document uniqueness is the one that gets an id.
2501 - if let Some(address) = &cells.address {
2502 - out.push_str(" id=\"");
2503 - escape_into(address, out);
2504 - out.push('"');
2505 - }
2506 - // Which side of a change this line is on, when the table is a diff.
2507 - // `19d7602d`. An attribute rather than a class, for `tone_attr`'s reason:
2508 - // makeover keys every intent off a data attribute, and the value is the
2509 - // token `Change`'s `Intent` impl names, so a host that has never heard of a
2510 - // diff still gets the success and danger it already styles.
2511 - //
2512 - // `None` writes nothing at all, which is what keeps every ordinary table in
2513 - // the tree from reading as a diff whose lines are all context.
2514 - if let Some(change) = cells.change {
2515 - out.push_str(" data-change=\"");
2516 - out.push_str(match change {
2517 - layout::Change::Added => "added",
2518 - layout::Change::Removed => "removed",
2519 - // Including a kind added to this `#[non_exhaustive]` axis that this
2520 - // renderer has not learned: an unknown side reads as an unchanged
2521 - // line, which draws the text and loses only the tint.
2522 - _ => "context",
2523 - });
2524 - out.push_str("\" data-tone=\"");
2525 - out.push_str(layout::Intent::token(change));
2526 - out.push('"');
2527 - }
2528 - // The indent and the fold, in the list row's spelling and for its reasons.
2529 - // The indent lands on the row rather than on its first cell: `display:
2530 - // table` gives the cell no box of its own to pad without moving the column
2531 - // edge for every row under it.
2532 - if cells.depth.is_nested() {
2533 - let _ = write!(
2534 - out,
2535 - " data-depth=\"{depth}\" style=\"--row-depth:{depth}\" aria-level=\"{level}\"",
2536 - depth = cells.depth.level,
2537 - level = cells.depth.level + 1
2538 - );
2539 - }
2540 - if folded {
2541 - out.push_str(" hidden");
2542 - }
2543 - if cells.current {
2544 - out.push_str(" aria-current=\"true\"");
2545 - }
2546 - if let Some(chosen) = cells.chosen {
2547 - out.push_str(if chosen {
2548 - " aria-selected=\"true\""
2549 - } else {
2550 - " aria-selected=\"false\""
2551 - });
2552 - }
2570 + row_attrs_html(cells, RowLook::Table, folded, out);
2571 + // The activate route, which only a table row carries in its opening tag: a
2572 + // list row's is written by its run instead. Kept here rather than moved
2573 + // into the shared attrs because the two really do differ.
2553 2574 if let Some(action) = &cells.activate {
2554 - // Any control in any cell, which is acts and links plus the chips that
2555 - // answer a click. A badge is not one and does not earn the filter. The
2556 - // walk moved onto `Cell` with the run, so this reads the description's
2557 - // answer rather than recomputing it from members.
2558 2575 let carries_control = cells.cells.iter().any(Cell::carries_control);
2559 2576 let fires = if carries_control || cells.selected.is_some() {
2560 2577 Fires::ClickBeside
@@ -2677,7 +2694,7 @@
2677 2694 out.push_str("</div>");
2678 2695 }
2679 2696
2680 - out.push_str("</div>");
2697 + out.push_str(RowLook::Table.close_tag());
2681 2698 }
2682 2699
2683 2700 /// The class saying when a region member drops.