Skip to main content

max / makeover-layout

0.2.0: components, schemas and structure Lands the nine decisions the depth cut deferred, each drawn from what the three apps already hand-write. Components: Token (a badge answers no click, a chip does and latches), Notice (transient against persistent, two concepts rather than one with a placement), RowPart, Heading, Selector, Readiness, Tone. Schemas: Field for forms, Column for lists. A column carries a drop priority, replacing the ordinal nth-child hiding both webview apps use, which silently hides the wrong column when one is inserted. Structure: Region and Arrangement. Three things previously filed as heading levels are bands and land as Region::Band instead. Validation and the bespoke widgets stay out, and say so.
Author: Max Johnson <me@maxj.phd> · 2026-07-29 13:54 UTC
Signed with PGP, not checked
Commit: 71e05fe9eed7239f2ce49589da3914c5d204c18d
Parent: ad4ff3a
3 files changed, +510 insertions, -26 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.1.0"
3 + version = "0.2.0"
4 4 edition = "2024"
5 5 description = "The renderer-agnostic half of the make-family design system: what a thing IS, named as intents and relationships and never as values. Colour defers to makeover, spacing to makeover-geometry; what is left is composition."
6 6 license = "MIT"
M README.md +32 -16
@@ -22,30 +22,46 @@
22 22
23 23 ## What is here
24 24
25 - Depth, and only depth: `Bevel`, `Edge`, `Fill`, `Depth`. Fill and bevel are named
26 - together, because naming them apart is what let them disagree. Every consumer had
27 - at least one region carrying a raised bevel over a recessed fill; audiofiles
28 - fixed it and wrote the bug down, Balanced Breakfast still had twelve of them a
29 - year later. `Depth` makes that unrepresentable.
25 + **Depth**: `Bevel`, `Edge`, `Fill`, `Depth`. Fill and bevel are named together,
26 + because naming them apart is what let them disagree. Every consumer had at least
27 + one region carrying a raised bevel over a recessed fill; audiofiles fixed it and
28 + wrote the bug down, Balanced Breakfast still had twelve of them a year later.
29 + `Depth` makes that unrepresentable.
30 +
31 + **Components**: `Token` (a badge does not answer a click, a chip does), `Notice`
32 + (a toast is transient, a banner is persistent), `RowPart`, `Heading`, `Selector`,
33 + `Readiness`, and `Tone`, the one intent family they share.
34 +
35 + **Schemas**: `Field` for forms, `Column` for lists and tables. A column carries a
36 + drop priority, which is what replaces addressing columns by position. Both
37 + webview apps hide narrow-screen columns with `nth-child`, so inserting a column
38 + silently hides the wrong one.
39 +
40 + **Structure**: `Region` for the parts of a screen, `Arrangement` for how a screen
41 + is put together. Two arrangements, because the two apps have two.
30 42
31 43 The vocabulary was read off audiofiles, the only implementation written by a
32 44 consumer with no CSS, then checked against both webview apps.
33 45
34 - ## What is not here yet
46 + ## What is not here, on purpose
35 47
36 - Badge versus chip, toast versus banner, the list row's parts, heading levels,
37 - segmented controls, and whether a description names loading state at all. Each is
38 - a naming decision rather than a transcription, and each is tracked as a subtask
39 - of the extraction task. Guessing at them now is how a description becomes a
40 - framework.
48 + Validation. `Field` describes fields, not constraints. Neither app has a shared
49 + story, and a schema that describes one without the other acquires a constraint
50 + layer per app, which is how the divergence this crate exists to end got started.
51 +
52 + The bespoke widgets: a day-plan timeline, a kanban board, a calendar. A
53 + description expressive enough to produce those is not a description any more, it
54 + is a widget library with one app on top. Generate the boring 80% so the bespoke
55 + 20% gets the attention.
41 56
42 57 ## Status
43 58
44 - On crates.io at 0.1.0, published 2026-07-28. The sequencing decision was not to
45 - publish until a second renderer had exercised the vocabulary; three did, and it
46 - cost the description exactly one deletion (`Fill::fallback`, which turned out to
47 - be renderer policy). Design and open questions live in the wiki note
48 - `makeover-layout`; the backlog is in GoingsOn under the project of the same name.
59 + 0.2.0. On crates.io since 0.1.0, published 2026-07-28. The sequencing decision
60 + was not to publish until a second renderer had exercised the vocabulary; three
61 + did, and it cost the description exactly one deletion (`Fill::fallback`, which
62 + turned out to be renderer policy). Design and open questions live in the wiki
63 + note `makeover-layout`; the backlog is in GoingsOn under the project of the same
64 + name.
49 65
50 66 ## Licence
51 67
M src/lib.rs +477 -9
@@ -22,20 +22,31 @@
22 22 //! *intent* — this region is a well — and each renderer chooses an expression
23 23 //! it can actually produce, including dropping half of one.
24 24 //!
25 - //! # Scope of this first cut
25 + //! # Scope
26 26 //!
27 - //! Depth only: the bevel and the surfaces it shapes. That much is settled,
28 - //! and settled the hard way — the vocabulary here was read off audiofiles'
27 + //! Depth came first: the bevel and the surfaces it shapes. That much was
28 + //! settled the hard way — the vocabulary here was read off audiofiles'
29 29 //! `ui::theme` and `ui::widgets`, which are the only implementation written
30 30 //! by a consumer with no CSS, then checked against both webview apps. All
31 31 //! three agreed once Balanced Breakfast's fills were corrected.
32 32 //!
33 - //! Deliberately absent, because each is a naming decision rather than a
34 - //! transcription: badge versus chip, toast versus banner, the list row's
35 - //! parts, heading levels, segmented controls, and whether a description names
36 - //! loading state at all. Those are tracked as subtasks of the extraction task
37 - //! and land as they are settled. Guessing at them now is how a description
38 - //! becomes a framework.
33 + //! 0.2.0 adds the rest of the description, each member drawn the same way,
34 + //! from what the three apps already hand-write rather than from a taxonomy:
35 + //!
36 + //! - Components. [`Token`] (badge against chip), [`Notice`] (toast against
37 + //! banner), [`RowPart`], [`Heading`], [`Selector`], [`Readiness`], and
38 + //! [`Tone`], which is the one intent family they share.
39 + //! - Schemas. [`Field`] for forms and [`Column`] for lists and tables.
40 + //! - Structure. [`Region`] for the parts of a screen, [`Arrangement`] for how
41 + //! a screen is put together.
42 + //!
43 + //! Two things are absent on purpose rather than pending. **Validation** is not
44 + //! part of [`Field`]: neither app has a shared story, and a schema describing
45 + //! fields but not constraints acquires a constraint layer per app, which is how
46 + //! the divergence this crate exists to end got started. **The bespoke widgets**
47 + //! (a day-plan timeline, a kanban board, a calendar) are not here and are not
48 + //! queued: a description expressive enough to produce them is not a description
49 + //! any more. Generate the boring 80% so the bespoke 20% gets the attention.
39 50
40 51 #![forbid(unsafe_code)]
41 52
@@ -195,6 +206,509 @@
195 206 }
196 207 }
197 208
209 + /// What a region is saying, when it is saying something.
210 + ///
211 + /// The one intent family shared by badges, notices and nothing else. Kept
212 + /// separate from [`Fill`] because a surface is where a thing sits and a tone is
213 + /// what it means, and the three apps agree on the four statuses:
214 + /// `info_banner` / `warning_banner` in audiofiles, `.toast-info` /
215 + /// `.toast-success` / `.toast-error` in goingson, `.toast.success` /
216 + /// `.toast.error` in Balanced Breakfast.
217 + ///
218 + /// The per-tag palette (`category-one` through `category-six`) is deliberately
219 + /// not here. Which colour a *particular* tag takes is app domain, and both
220 + /// webview apps already carry it as a `data-color` attribute.
221 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
222 + pub enum Tone {
223 + /// No status. Reads as ordinary de-emphasised content.
224 + Neutral,
225 + /// Something worth knowing and nothing to do about it.
226 + Info,
227 + /// Something finished and it worked.
228 + Success,
229 + /// Something the user should look at before continuing.
230 + Warning,
231 + /// Something broken, or something about to be destroyed.
232 + Danger,
233 + }
234 +
235 + impl Intent for Tone {
236 + fn token(self) -> &'static str {
237 + match self {
238 + // Neutral has no status token of its own. It takes the muted
239 + // content intent, which is what both webview apps already spell as
240 + // `data-color="muted"`.
241 + Self::Neutral => "content-muted",
242 + Self::Info => "info",
243 + Self::Success => "success",
244 + Self::Warning => "warning",
245 + Self::Danger => "danger",
246 + }
247 + }
248 + }
249 +
250 + /// A small labelled thing that sits inside something else.
251 + ///
252 + /// Two members, because the three apps drew three taxonomies and only one line
253 + /// runs through all of them: does it answer a click. audiofiles has
254 + /// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable`
255 + /// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and
256 + /// `.badge` against `.tag-chip`. goingson is the one that has to move: its
257 + /// `.tag` and `.badge` are a single CSS rule, so every call site has to be read
258 + /// to decide which of the two it always was.
259 + ///
260 + /// The evidence that a chip is a real concept rather than a badge with a
261 + /// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches
262 + /// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip
263 + /// holds itself down", which is exactly what [`Depth::pressed`] already says.
264 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
265 + pub enum Token {
266 + /// Non-interactive status or count. Answers no click.
267 + Badge,
268 + /// An interactive or removable token. Answers a click, and latches if it
269 + /// stands for a filter that is either on or off.
270 + Chip {
271 + /// Whether it carries its own remove affordance.
272 + removable: bool,
273 + },
274 + }
275 +
276 + impl Token {
277 + /// Whether this answers a click.
278 + ///
279 + /// The whole difference between the two members, and the reason a renderer
280 + /// with no hover (a touch surface, a terminal) can still tell them apart.
281 + #[must_use]
282 + pub const fn interactive(self) -> bool {
283 + matches!(self, Self::Chip { .. })
284 + }
285 +
286 + /// How it sits, given whether it is currently latched down.
287 + ///
288 + /// A badge is flat: it is a label, and giving it an edge would say it can
289 + /// be pressed. A chip is raised, and inset while latched.
290 + #[must_use]
291 + pub const fn depth(self, latched: bool) -> Depth {
292 + match self {
293 + Self::Badge => Depth::Flat,
294 + Self::Chip { .. } if latched => Depth::Well,
295 + Self::Chip { .. } => Depth::Raised,
296 + }
297 + }
298 + }
299 +
300 + /// Something the app is telling the user, unprompted.
301 + ///
302 + /// Two concepts, not one with a placement. They differ in more than where they
303 + /// sit: a toast is transient, stacked and self-dismissing, and a banner is
304 + /// persistent, in flow, one per region, and dismissed by fixing the condition
305 + /// it reports. Folding them into one member with a placement parameter would
306 + /// make lifetime, stacking and dismissal all placement-dependent, which is the
307 + /// description leaking renderer policy.
308 + ///
309 + /// All three apps have banners: `info_banner` and `warning_banner` in
310 + /// audiofiles, five of them in goingson (sync, sync-result, vacation-day,
311 + /// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two
312 + /// webview apps also have toasts. So neither member is speculative, and no app
313 + /// gains a concept it lacks except audiofiles, whose renderer may legitimately
314 + /// decline to draw a toast at all.
315 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
316 + pub enum Notice {
317 + /// Transient, stacked, dismisses itself.
318 + Toast,
319 + /// Persistent, in flow, one per region, dismissed by fixing the cause.
320 + Banner,
321 + }
322 +
323 + impl Notice {
324 + /// Whether it goes away on its own.
325 + #[must_use]
326 + pub const fn transient(self) -> bool {
327 + matches!(self, Self::Toast)
328 + }
329 +
330 + /// How it sits.
331 + ///
332 + /// A toast floats above the page rather than resting on it, which is
333 + /// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the
334 + /// flow. Both are raised, and they are raised off different things.
335 + #[must_use]
336 + pub const fn fill(self) -> Fill {
337 + match self {
338 + Self::Toast => Fill::Overlay,
339 + Self::Banner => Fill::Raised,
340 + }
341 + }
342 + }
343 +
344 + /// The parts of a list row.
345 + ///
346 + /// Four, taken from Balanced Breakfast, which is the only consumer that had all
347 + /// of them (`row-primary`, `row-secondary`, `row-meta`, `row-actions`).
348 + /// audiofiles has two and no slot structure at all, so it gains meta and
349 + /// actions as real work rather than a rename; goingson moves off
350 + /// `task-row` / `task-cell`.
351 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
352 + pub enum RowPart {
353 + /// The thing itself. What the row is called.
354 + Primary,
355 + /// Supporting text under the primary.
356 + Secondary,
357 + /// A short trailing fact: a count, a size, a date.
358 + Meta,
359 + /// Controls that act on this row.
360 + Actions,
361 + }
362 +
363 + impl RowPart {
364 + /// Whether the part stays hidden until the row is hovered or focused.
365 + ///
366 + /// Behaviour of the part, not app policy: Balanced Breakfast and goingson
367 + /// grew the same hover-reveal on their actions independently and neither
368 + /// applies it to anything else.
369 + ///
370 + /// A renderer with no hover shows it always. That is a renderer decision
371 + /// and this returning `true` does not forbid it.
372 + #[must_use]
373 + pub const fn revealed_on_hover(self) -> bool {
374 + matches!(self, Self::Actions)
375 + }
376 +
377 + /// The content intent the part takes.
378 + #[must_use]
379 + pub const fn intent(self) -> &'static str {
380 + match self {
381 + Self::Primary => "content",
382 + Self::Secondary => "content-secondary",
383 + Self::Meta => "content-muted",
384 + // Actions carry controls rather than text, so they inherit.
385 + Self::Actions => "content",
386 + }
387 + }
388 + }
389 +
390 + /// How far down the heading tree a title sits.
391 + ///
392 + /// Three, and only the three that are actually headings. The bands those used
393 + /// to be filed with (goingson's `.page-header`, Balanced Breakfast's `.header`
394 + /// and `.detail-header`) are arrangement, not type, and live at
395 + /// [`Region::Band`]. One of them contains no text at all.
396 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
397 + pub enum Heading {
398 + /// Names the whole screen. One per screen.
399 + Page,
400 + /// Names a block within the screen.
401 + Section,
402 + /// Names a sub-block inside an already-named section.
403 + Subsection,
404 + }
405 +
406 + impl Heading {
407 + /// Whether a rule follows the heading.
408 + ///
409 + /// audiofiles' `section_header` draws a separator and its
410 + /// `subsection_label` deliberately does not, which is the only thing
411 + /// distinguishing the two once weight and colour are deferred.
412 + #[must_use]
413 + pub const fn separated(self) -> bool {
414 + matches!(self, Self::Section)
415 + }
416 + }
417 +
418 + /// A control that picks between things.
419 + ///
420 + /// Three, because three distinct behaviours are in play and collapsing any two
421 + /// loses something. A segmented control picks a value; a tab picks a pane; a
422 + /// toggle picks nothing and simply holds itself on or off.
423 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
424 + pub enum Selector {
425 + /// Exactly one of N, and the options abut.
426 + Segmented,
427 + /// Independent on or off, on its own.
428 + Toggle,
429 + /// Navigation between panes. The folder semantic.
430 + Tabs,
431 + }
432 +
433 + impl Selector {
434 + /// How the chosen option sits.
435 + ///
436 + /// Held in for a segmented control and a toggle, which is the same shape
437 + /// pressing produces and the whole economy of the idiom: one appearance,
438 + /// two reasons to wear it. A tab is the exception, because the selected
439 + /// folder tab comes *forward* to join the pane it opens.
440 + #[must_use]
441 + pub const fn chosen(self) -> Depth {
442 + match self {
443 + Self::Segmented | Self::Toggle => Depth::Well,
444 + Self::Tabs => Depth::Raised,
445 + }
446 + }
447 +
448 + /// Whether the options touch.
449 + ///
450 + /// The gap is the entire difference between a segmented control and a row
451 + /// of buttons that happen to sit near each other, which is what audiofiles'
452 + /// `segmented_control` says in its own comment and why it zeroes the
453 + /// spacing by hand.
454 + #[must_use]
455 + pub const fn abutting(self) -> bool {
456 + matches!(self, Self::Segmented | Self::Tabs)
457 + }
458 + }
459 +
460 + /// Whether the content of a region has arrived.
461 + ///
462 + /// The state, not the shimmer. Whether pending paints a skeleton, a spinner or
463 + /// nothing at all is renderer policy, the same class of decision that got
464 + /// `Fill::fallback` deleted from this crate. goingson and Balanced Breakfast
465 + /// each grew a skeleton with differently-named parts; both keep them, as the
466 + /// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none
467 + /// and needs none, because an immediate-mode renderer simply repaints.
468 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
469 + pub enum Readiness {
470 + /// The content is here.
471 + Ready,
472 + /// The content is on its way.
473 + Pending,
474 + }
475 +
476 + /// A named part of a screen.
477 + ///
478 + /// The thing `makeover-geometry` deliberately does not name: it names the space
479 + /// *between* things by relationship, and nothing named the things. Six members,
480 + /// taken from what the two webview apps actually use. Both apps' `layout.css`
481 + /// currently names exactly two things, `.raised` and `.well`, so this layer is
482 + /// absent rather than divergent, which makes it the cheapest of the schemas to
483 + /// add and the easiest to over-build.
484 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
485 + pub enum Region {
486 + /// A full-width strip with a title slot and an actions cluster, either of
487 + /// which may be empty. goingson's `.page-header`, Balanced Breakfast's
488 + /// `.header` and `.detail-header` are all this, differing only in which
489 + /// slots they fill.
490 + Band,
491 + /// A persistent column beside the content, holding navigation.
492 + Sidebar,
493 + /// A region of content with its own scroll.
494 + Pane,
495 + /// Two panes side by side, where the left chooses what the right shows.
496 + Split,
497 + /// A set of panes, one visible at a time, with a [`Selector::Tabs`] above.
498 + TabGroup,
499 + /// Content over a scrim, taking input until dismissed.
500 + Modal,
501 + }
502 +
503 + impl Region {
504 + /// How the region sits on what is behind it.
505 + #[must_use]
506 + pub const fn depth(self) -> Depth {
507 + match self {
508 + Self::Band | Self::Sidebar | Self::Split | Self::TabGroup => Depth::Flat,
509 + // A pane is looked into, the same as a table body or a tag tree.
510 + Self::Pane => Depth::Well,
511 + Self::Modal => Depth::Raised,
512 + }
513 + }
514 + }
515 +
516 + /// How a screen is laid out.
517 + ///
518 + /// Two, and the second is not a variant of the first. goingson is list-detail,
519 + /// Balanced Breakfast is sidebar plus content, and neither app has a third.
520 + /// The tab group is a modifier rather than a member, because goingson uses it
521 + /// *inside* the same content region rather than instead of one.
522 + ///
523 + /// This exists at all because the router has to be able to express a screen
524 + /// rather than only a control. Discovering the arrangement layer missing after
525 + /// the renderers exist is a redesign; naming two now is a morning.
526 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
527 + pub enum Arrangement {
528 + /// A list that chooses what the detail beside it shows.
529 + ListDetail {
530 + /// Whether the detail side is a [`Region::TabGroup`].
531 + tabbed: bool,
532 + },
533 + /// Navigation down the side, content filling the rest.
534 + SidebarContent,
535 + }
536 +
537 + /// What kind of value a form field takes.
538 + ///
539 + /// The union of the two vocabularies that diverged, which is what triggered
540 + /// this crate. They have since converged on their own: both apps now have a
541 + /// `renderFormField` emitting the same anatomy, and what is left differing is
542 + /// the kind set, the error shape, and whether the return is a string or a node.
543 + ///
544 + /// Validation is deliberately absent. Neither app has a shared story (goingson
545 + /// validates after collecting the form data, with per-field transform hooks;
546 + /// Balanced Breakfast has `required` and nothing else), and a schema that
547 + /// describes fields but not constraints acquires a constraint layer per app,
548 + /// which is exactly how the current divergence started. Naming it absent is a
549 + /// decision; leaving it unmentioned would not be.
550 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
551 + pub enum FieldKind {
552 + /// A single line of text.
553 + Text,
554 + /// A single line of text that must never be echoed, logged or round-tripped
555 + /// through anything that might persist it.
556 + Secret,
557 + /// A number.
558 + Number,
559 + /// Several lines of text.
560 + Textarea,
561 + /// One of a fixed set.
562 + Select,
563 + /// On or off.
564 + Checkbox,
565 + /// Carried through the form and never shown.
566 + Hidden,
567 + }
568 +
569 + impl FieldKind {
570 + /// Whether the field is drawn at all.
571 + #[must_use]
572 + pub const fn visible(self) -> bool {
573 + !matches!(self, Self::Hidden)
574 + }
575 +
576 + /// Whether the value must be kept out of logs and diagnostics.
577 + #[must_use]
578 + pub const fn confidential(self) -> bool {
579 + matches!(self, Self::Secret)
580 + }
581 +
582 + /// Where the field's own label sits.
583 + ///
584 + /// A checkbox labels itself on the right of the box; everything else takes
585 + /// a label above. Both webview apps already do this and both special-case
586 + /// it inline, which is the tell that it belongs in the description.
587 + #[must_use]
588 + pub const fn labels_itself(self) -> bool {
589 + matches!(self, Self::Checkbox)
590 + }
591 + }
592 +
593 + /// One field of a form.
594 + ///
595 + /// Borrowed rather than owned: a description is built, read once by a renderer,
596 + /// and dropped. Nothing here outlives the screen it describes.
597 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
598 + pub struct Field<'a> {
599 + /// What kind of value it takes.
600 + pub kind: FieldKind,
601 + /// The name the value is submitted under.
602 + pub name: &'a str,
603 + /// What the user is asked for.
604 + pub label: &'a str,
605 + /// Standing help, shown whether or not anything is wrong.
606 + pub hint: Option<&'a str>,
607 + /// What is currently wrong with the value.
608 + pub error: Option<&'a str>,
609 + /// Whether the form refuses to submit without it.
610 + pub required: bool,
611 + /// Whether the field lives behind a "more options" disclosure.
612 + pub extended: bool,
613 + }
614 +
615 + impl<'a> Field<'a> {
616 + /// A plain required-nothing field of the given kind.
617 + #[must_use]
618 + pub const fn new(kind: FieldKind, name: &'a str, label: &'a str) -> Self {
619 + Self {
620 + kind,
621 + name,
622 + label,
623 + hint: None,
624 + error: None,
625 + required: false,
626 + extended: false,
627 + }
628 + }
629 +
630 + /// Whether the field is currently reporting a problem.
631 + ///
632 + /// Read this rather than testing `error.is_some()` at each renderer: the
633 + /// error state has to mark the field's whole group and not only the
634 + /// message, because a renderer with no descendant selectors (egui, a
635 + /// terminal) cannot find the group from the message. goingson already marks
636 + /// the group and Balanced Breakfast does not, so goingson's shape is the
637 + /// one taken here.
638 + #[must_use]
639 + pub const fn invalid(&self) -> bool {
640 + self.error.is_some()
641 + }
642 + }
643 +
644 + /// How much room a column asks for.
645 + ///
646 + /// An intent, so the actual floor stays with `makeover-geometry`. goingson's
647 + /// task table spells these as `minmax(200px, 1fr)`, `140px` and content-sized;
648 + /// only the first three words of that survive deferral.
649 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
650 + pub enum Width {
651 + /// Takes what it needs and no more.
652 + Content,
653 + /// A fixed share, the same at every width.
654 + Fixed,
655 + /// Absorbs whatever is left over.
656 + Fill,
657 + }
658 +
659 + /// What a column is worth when there is not room for all of them.
660 + ///
661 + /// Ordered: [`Priority::Optional`] drops first, [`Priority::Essential`] never
662 + /// drops. This replaces addressing columns by position, which is what both
663 + /// webview apps do today and is a live bug rather than only verbosity. goingson
664 + /// hides mobile columns with `nth-child(n+5)` against a seven-column table, so
665 + /// inserting a column silently hides the wrong one.
Lines truncated