makeover-webview 0.87.0

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.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
//! The table rules: how every described table lays out, narrows and drops
//! columns, written once against the classes its cells carry.
//!
//! Split out of the crate root because it is one subject with its own
//! constants and the longest rule set in the sheet.

use crate::list::{self, cell_part_class};
use crate::{Emit, class, gated};
use makeover_geometry::{Density, Gap};
use makeover_layout::CellPart;
use std::fmt::Write as _;

/// One declared `ch` of a column's floor, in `rem`.
///
/// A figure at a table's text size, 0.6em of seven eighths of the base, is
/// 0.525rem, and a floor that sized a date to exactly its ten figures ended it
/// in an ellipsis with the rest of the row to spare. Nine sixteenths keeps it
/// whole. `rem` rather than the CSS `ch` because a container condition resolves
/// a font-relative unit against a different font than the cell's.
const CH_REM: f32 = 0.5625;

/// The size a table's rows are set in.
///
/// Named once because two rules say it: the table's own, and the sizer's, which
/// has to draw its copies in the rows' type from inside a heading that is set
/// smaller. Two literals would let a column be measured in one size and drawn
/// in another.
const TABLE_TYPE: &str = "var(--text-note)";

/// The frame a table sits in, and how it narrows.
///
/// # Nothing travels with the table
///
/// A table a description produced knows its columns at render time, so any rule
/// written per table would have to travel with the markup: a `<style>` element
/// beside it, which needs `style-src 'unsafe-inline'` and so blocks the MNW
/// server's standing plan to drop it, or the head, which a table swapped in by
/// htmx arrives without. So every rule here is written once, against classes a
/// cell carries.
///
/// # Flex rows, and priority as a shrink factor
///
/// Each row is a flex row and each column a flex item starting at its declared
/// floor, the `min-N` rung [`list::column_classes`] puts on it. The columns
/// line up across rows because every row's cells share one basis, grow and
/// shrink per column. When the table is short of room the browser takes the
/// shortfall in proportion to each column's shrink factor, and
/// [`Priority`](makeover_layout::Priority) is
/// that factor: optional columns give way first, secondary ones next, and an
/// essential column of a kind that cannot be cut short does not give way at all.
///
/// So a table narrows at the width its floors add up to, with no breakpoint and
/// no count of positions. That is the number `makeover-tui` counts in cells and
/// `makeover-immediate` budgets in points, which is what makes a description
/// drop a column at one declared width in every host.
///
/// A droppable column under its floor hides its contents, through a container
/// condition on the cell, so a column reads whole or blank and never as a
/// fragment. The condition cannot read a custom property, which is why the floor
/// is a class from a fixed ladder and the edges are written in per density.
pub(crate) fn table_rules(opts: &Emit) -> String {
    let table = class("table", opts);
    let head = class("table-head", opts);
    let row = class("table-row", opts);
    let heading = class("table-heading", opts);
    let cell = class("cell", opts);
    let mut css = String::new();

    // The table model, wiki `table-model`: specimen C with D's header strip, as
    // Max picked it off rendered specimens. The table paints its own raised
    // ground inside a hairline frame, so the row tones hold whatever it sits
    // on. Text is the note size, 14px at the default base.
    let bw = opts.border_width;
    let cell_in = class("cell-in", opts);
    let _ = writeln!(
        css,
        ".{table} {{\n    border: {bw} solid var(--row-rule);\n    \
         background: var(--surface-raised);\n    font-size: {TABLE_TYPE};\n}}"
    );
    // Flex rows, so priority can be a shrink factor and the browser narrows the
    // table itself: see "A table narrows itself" below. Every row's cells share
    // one basis, grow and shrink per column, which is what lines the columns up
    // across rows without a table layout doing it.
    let _ = writeln!(
        css,
        ".{table} .{head},\n.{table} .{row} {{\n    display: flex;\n}}"
    );
    // A minimum rather than a height, since a row grows to fit its tallest
    // cell: a cell holding more than a line grows the row.
    let _ = writeln!(
        css,
        ".{table} .{row} {{\n    min-height: var(--row-block);\n}}"
    );

    // One column, heading or cell: a flex item that starts at its floor
    // (`.min-N` below), grows if it fills, and gives room up by what it is
    // worth. `min-width: 0` lets it reach nothing, and it clips, so the padding
    // on `.cell-in` never lingers as a gap once the column has closed.
    //
    // A size container, so a droppable column can ask whether it is under its
    // own floor and show nothing rather than a fragment. Type goes on
    // `.cell-in`, so a heading and the cells under it are one box apart from
    // what they hold.
    //
    // A declared `ch` is drawn as `CH_REM`, in `rem`, in the basis and in the
    // container condition both. The CSS `ch` would be read off the cell by the
    // basis and off another font by the condition, so a column would hide at
    // one width while being sized at another: measured, three columns went
    // blank at 520px with room for all of them.
    let _ = writeln!(
        css,
        ".{table} .{heading},\n.{table} .{cell} {{\n    display: flex;\n    \
         align-items: center;\n    flex: 0 1 calc({}rem + 2 * var(--gap-group));\n    \
         min-width: 0;\n    overflow: hidden;\n    container-type: inline-size;\n}}",
        8.0 * CH_REM
    );
    let _ = writeln!(
        css,
        ".{table} .{cell_in} {{\n    flex: 1 1 auto;\n    min-width: 0;\n    \
         overflow: hidden;\n    text-overflow: ellipsis;\n}}"
    );

    // The header strip. Its ink goes on the heading row and is inherited,
    // because `state_rules` colours the sort caret on `.cell-in::after` and a
    // colour on the heading itself would override it. `content-secondary`
    // rather than muted: the strip separates the header now, so the label does
    // not have to stay quiet by being pale. The edge sits under the strip, as
    // in the specimen. The strip is on the row as well as on each heading, so a
    // table whose columns do not fill its width has no gap in it, and on each
    // heading so one that sticks while the rows scroll under it stays opaque.
    // Tracking is typography's to own and nothing in the family owns it yet, so
    // it is a property with the specimen's value behind it.
    let _ = writeln!(
        css,
        ".{table} .{head} {{\n    color: var(--content-secondary);\n    \
         background: var(--surface-sunken);\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{heading} {{\n    align-items: flex-end;\n    \
         background: var(--surface-sunken);\n    border-bottom: {bw} solid var(--bevel-dark);\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{heading} > .{cell_in} {{\n    padding: var(--gap-peer) var(--gap-group);\n    \
         font-size: var(--text-fine);\n    font-weight: bold;\n    \
         letter-spacing: var(--table-heading-tracking, 0.06em);\n    \
         text-transform: uppercase;\n    text-align: start;\n    white-space: nowrap;\n}}"
    );

    // Rows: a hairline between rows, a stripe on alternate rows, and a hover,
    // at every row count. Between rows and not above the first, where the
    // header's edge already draws the line.
    let _ = writeln!(
        css,
        ".{table} .{row} > .{cell} > .{cell_in} {{\n    padding: var(--gap-bound) var(--gap-group);\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{row} + .{row} {{\n    border-top: {bw} solid var(--row-rule);\n}}"
    );
    // Parity of rows, never position of columns: the stripe alternates by what
    // a row is, and `of .{row}` keeps a header row out of the count.
    let _ = writeln!(
        css,
        ".{table} .{row}:nth-child(even of .{row}) {{\n    background: var(--row-stripe);\n}}"
    );
    let _ = writeln!(
        css,
        "@media (hover: hover) and (pointer: fine) {{\n    \
         .{table} .{row}:hover {{\n        background: var(--row-hover);\n    }}\n}}"
    );

    let [
        kind_identifier,
        kind_date,
        kind_number,
        kind_status,
        kind_actions,
        kind_code,
    ] = [
        "kind-identifier",
        "kind-date",
        "kind-number",
        "kind-status",
        "kind-actions",
        "kind-code",
    ]
    .map(|name| class(name, opts));
    // Column kinds, wiki `table-model`. The class is on the heading and on
    // every cell. A heading takes the column's alignment and nowrap, so it lines
    // up with what is under it, and keeps the header's own type: the face and
    // figures a kind sets go on its cells. Prose has no class and no rule.
    let _ = writeln!(
        css,
        ".{table} .{cell}.{kind_identifier} > .{cell_in} {{\n    font-family: var(--font-mono);\n    \
         font-size: var(--text-fine);\n    overflow-wrap: anywhere;\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{kind_date} > .{cell_in} {{\n    white-space: nowrap;\n}}\n\
         .{table} .{cell}.{kind_date} > .{cell_in} {{\n    font-variant-numeric: tabular-nums;\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{kind_number} > .{cell_in} {{\n    white-space: nowrap;\n    text-align: end;\n}}\n\
         .{table} .{cell}.{kind_number} > .{cell_in} {{\n    font-variant-numeric: tabular-nums;\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{kind_status} > .{cell_in} {{\n    white-space: nowrap;\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{kind_actions} > .{cell_in} {{\n    white-space: nowrap;\n    text-align: end;\n}}"
    );
    let _ = writeln!(
        css,
        ".{table} .{cell}.{kind_code} > .{cell_in} {{\n    font-family: var(--font-mono);\n    \
         white-space: pre;\n}}"
    );
    // A table holding code is read as code: one row per source line, where a
    // stripe, a padded block and a 45px row each break the reading. So it
    // keeps the frame and the header and drops the record treatment. Keyed on
    // the kind being present, which the table's own markup already says.
    //
    // And it never clips a line. The code column is sized by what it holds,
    // takes no part in narrowing, and the table scrolls sideways instead: a
    // source line cut short is a different line.
    let code = format!(".{table}:has(.{kind_code})");
    let _ = writeln!(
        css,
        "{code} {{\n    overflow-x: auto;\n}}\n\
         {code} .{head},\n{code} .{row} {{\n    min-width: max-content;\n}}\n\
         {code} .{row} {{\n    min-height: auto;\n    align-items: baseline;\n}}\n\
         {code} .{row} + .{row} {{\n    border-top: 0;\n}}\n\
         {code} .{row} > .{cell} > .{cell_in} {{\n    padding-block: 0;\n}}\n\
         {code} .{cell}.{kind_code},\n{code} .{heading}.{kind_code} {{\n    \
         flex: 1 0 auto;\n    overflow: visible;\n    container-type: normal;\n}}\n\
         {code} .{row}:nth-child(even of .{row}) {{\n    background: none;\n}}"
    );
    let _ = writeln!(
        css,
        "@media (hover: hover) and (pointer: fine) {{\n    \
         {code} .{row}:hover {{\n        background: none;\n    }}\n}}"
    );
    // A fill column takes the slack; a content or fixed one stays at its floor
    // and keeps its value on one line rather than being given less by wrapping.
    // Scoped under `.{table}` for the reason every rule here is: the model is
    // what a cell takes by being in a table, and `.table .cell` at (0,2,0)
    // would outrank a bare class.
    let _ = writeln!(
        css,
        ".{table} .{} {{\n    flex-grow: 1;\n}}",
        class("cell-fill", opts)
    );
    // And a fill column's text wraps all the way down. It already wrapped at
    // spaces and hyphens, but a word longer than the column overflowed
    // `.cell-in` and was clipped to an ellipsis mid-word, which reads as a
    // different word: MNW's Media table at 360px showed `hydroph...` for
    // `covers/hydrophone-rig.png`. `anywhere` breaks only a word that cannot
    // fit whole, so every other line wraps exactly as it did.
    let _ = writeln!(
        css,
        ".{table} .{} > .{cell_in} {{\n    overflow-wrap: anywhere;\n}}",
        class("cell-fill", opts)
    );
    let _ = writeln!(
        css,
        ".{table} .{} > .{cell_in},\n.{table} .{} > .{cell_in} {{\n    white-space: nowrap;\n}}",
        class("cell-content", opts),
        class("cell-fixed", opts)
    );

    // A table narrows itself. Priority is how much room a column gives up when
    // the table runs out of it: the browser takes the shortfall from optional
    // columns until they reach nothing, then from secondary ones, and an
    // essential column only gives up what a thousandth of the others would.
    // No breakpoint and nothing per table, so a described table narrows at the
    // width its own floors add up to, which is the number the terminal counts
    // in cells and the immediate-mode painter budgets in points.
    //
    // An essential column of a kind that cannot be cut short does not shrink at
    // all: a control, a figure, a date or a state truncated is wrong rather than
    // short. `ColumnKind::holds_minimum` names them, so the list is the
    // description's rather than this sheet's.
    let [drops_first, drops_next, keeps] =
        ["cell-drops-first", "cell-drops-next", "cell-keeps"].map(|name| class(name, opts));
    let _ = writeln!(
        css,
        ".{table} .{drops_first} {{\n    flex-shrink: 1000000;\n}}\n\
         .{table} .{drops_next} {{\n    flex-shrink: 1000;\n}}"
    );
    let holding: Vec<String> = [
        makeover_layout::ColumnKind::Text,
        makeover_layout::ColumnKind::Identifier,
        makeover_layout::ColumnKind::Date,
        makeover_layout::ColumnKind::Number,
        makeover_layout::ColumnKind::Code,
        makeover_layout::ColumnKind::Status,
        makeover_layout::ColumnKind::Actions,
    ]
    .into_iter()
    .filter(|kind| kind.holds_minimum())
    .filter_map(list::kind_class)
    .map(|name| format!(".{}", class(name, opts)))
    .collect();
    let _ = writeln!(
        css,
        ".{table} .{keeps}:is({}) {{\n    flex-shrink: 0;\n}}",
        holding.join(", ")
    );

    // A kept column holding a sizer is as wide as its widest copy: its basis is
    // its content, which the copies fix at one width down the table, rather
    // than a floor counted in `ch` and drawn in another face. See
    // `list::CELL_SIZER` for what was measured.
    //
    // It stops being a size container to do it, since a size container's
    // content counts for nothing, and nothing asks a kept column its size: only
    // a droppable one blanks. Keyed on the sizer being there, so a table written
    // by hand without one keeps its floor. `:has` counts its argument, so this
    // outranks the rung that set the basis wherever the two are written.
    //
    // The copies take the row's type whatever the cell they sit in says: in a
    // heading they would inherit its capitals, weight and tracking, and the
    // head measured 6px wider than the rows under it.
    let sizer = class(list::CELL_SIZER, opts);
    let _ = writeln!(
        css,
        ".{table} .{keeps}:has(> .{cell_in} > .{sizer}) {{\n    flex-basis: auto;\n    \
         container-type: normal;\n}}\n\
         .{table} .{sizer} {{\n    display: block;\n    block-size: 0;\n    overflow: hidden;\n    \
         visibility: hidden;\n    white-space: nowrap;\n    font-size: {TABLE_TYPE};\n    \
         font-weight: normal;\n    letter-spacing: normal;\n    text-transform: none;\n}}\n\
         .{table} .{sizer} > * {{\n    display: flex;\n    inline-size: max-content;\n}}"
    );

    // The floors, as a ladder. `Column::floor` is even and at most
    // `MIN_CEILING`, so every floor a description can reach is one of these.
    let rungs = (2..=makeover_layout::MIN_CEILING).step_by(2);
    for n in rungs.clone() {
        let _ = writeln!(
            css,
            ".{table} .{} {{\n    flex-basis: calc({}rem + 2 * var(--gap-group));\n}}",
            class(&format!("min-{n}"), opts),
            f32::from(n) * CH_REM
        );
    }

    // Whole or blank, never a fragment. A droppable column that has lost more
    // than one gap of its floor shows nothing, and its blank then closes over
    // the width it held.
    //
    // Nothing means no height either. Hidden text still lays out, and in a
    // column narrowed toward nothing a fill cell's words wrap one to a line: a
    // two-sentence description stood a row nine lines tall at 420px (MNW's
    // membership tiers). So the blank also holds its block size at zero and
    // clips what it holds.
    //
    // Not `display: none`, which would say the same thing more shortly. Apps
    // set `display` on `.cell-in` for layouts of their own (MNW's feed stacks a
    // two-line name with it), app CSS outranks this layer, and such a rule would
    // quietly bring every dropped cell back; makeover-build's drift check
    // refuses the pairing for that reason. Nothing sets a block size here.
    //
    // One gap and not none. The shrink factors order the tiers only nearly: a
    // secondary column gives up a fraction of a pixel while the optional ones
    // close, and a condition at the floor itself blanked it the moment they
    // started (measured, at 520px). One gap is far more than that leak and no
    // more than the column's own end padding, so whatever it holds overflows
    // into padding it still has rather than being cut.
    //
    // A container condition cannot read a custom property, so the gap is
    // written in rather than taken from `--gap-group`: once for the pointer
    // gap, and again under the touch condition where the gap is wider.
    for density in [Density::Pointer, Density::Touch] {
        let step = Gap::Group.step_at(density).ratio();
        let edge = f32::from(step.numerator) / f32::from(step.denominator);
        let mut block = String::new();
        for n in rungs.clone() {
            let _ = writeln!(
                block,
                "@container (inline-size < {}rem) {{\n    \
                 .{table} :is(.{drops_first}, .{drops_next}).{} > .{cell_in} {{\n        \
                 visibility: hidden;\n        max-block-size: 0;\n        overflow: hidden;\n    }}\n}}",
                f32::from(n) * CH_REM + edge,
                class(&format!("min-{n}"), opts)
            );
            // A kept column that fills is never blanked, and under its floor it
            // truncates on one line rather than wrapping. Its words already
            // break anywhere so an overlong word is not clipped mid-way, and a
            // column squeezed toward nothing then broke at every letter: a
            // subject stood one character to a line at 420px, the whole row as
            // tall as the subject was long. Truncated and present is the
            // table model's rule for essentials that do not fit, and the
            // `cell-in` already clips with an ellipsis once the line is one.
            let _ = writeln!(
                block,
                "@container (inline-size < {}rem) {{\n    \
                 .{table} .{keeps}.{fill}.{} > .{cell_in} {{\n        \
                 white-space: nowrap;\n        overflow-wrap: normal;\n    }}\n}}",
                f32::from(n) * CH_REM + edge,
                class(&format!("min-{n}"), opts),
                fill = class("cell-fill", opts),
            );
        }
        match density {
            Density::Pointer => css.push_str(&block),
            Density::Touch => css.push_str(&gated(Some(density.media_condition()), &block)),
        }
    }

    // What is inside a cell, which the table side could not say until
    // makeover-layout 0.14.0. Every cell was one `.cell` and one content
    // colour, so a button in a cell was painted as text -- the drift
    // `RowPart::intent` has prevented for list rows since 0.2.0 and prevented
    // for nothing here.
    //
    // The colour goes on `.cell-value` rather than on `.cell`, and that
    // placement is the whole fix. On the container it would cascade into the
    // tokens and the controls sitting beside the text, which is the bug said
    // in one rule; on the part that is text, it reaches text and stops.
    for part in [
        CellPart::Value,
        CellPart::Tokens,
        CellPart::Actions,
        CellPart::Link,
    ] {
        // Two of the four inherit: a token carries its own tone and an action
        // is a control rather than text. A link is text, the title of its row,
        // so it takes its intent like a value does. Left to inherit, it took
        // nothing: the colour is on `.cell-value` rather than on `.cell`, and an
        // anchor with no colour of its own draws in the browser's link blue.
        //
        // Written as a skip-list rather than as a match on Value, so a member
        // added upstream gets its intent emitted rather than being silently
        // dropped. That is the same trade `part_class`'s fallback makes: land
        // plainly, never land as nothing.
        if !matches!(part, CellPart::Tokens | CellPart::Actions) {
            // The underline says "this goes somewhere" in running text. In a
            // table the row already says it, by being the thing that activates.
            let underline = if part == CellPart::Link {
                "\n    text-decoration: none;"
            } else {
                ""
            };
            let _ = writeln!(
                css,
                ".{} {{\n    color: var(--{});{underline}\n}}",
                class(cell_part_class(part), opts),
                part.intent()
            );
        }
    }

    css
}