Skip to main content

max / makeover-layout

L6: bespoke widgets stay app-side, and the description says where Decided against putting the day-plan timeline, kanban, calendar and the paint interaction into the description. Nothing changes about that argument: a description expressive enough to produce a timeline is a widget library wearing a description's name. What shipping L2 through L5 changed is the other half. A screen holding a timeline still has to be a screen, or the four goingson screens worth using need a second path beside the router, and two paths is how the vocabulary drifts from the app again. So Region gains one opaque member, Bespoke { name }, and Region lifetime. The description names the place, the app owns the contents, and described() is how a renderer tells the two apart. One escape hatch, and a test asserting it stays one. Folded into the unpublished 0.2.0 rather than minting 0.3.0 for a breaking change nothing has adopted.
Author: Max Johnson <me@maxj.phd> · 2026-07-29 14:15 UTC
Signed with PGP, not checked
Commit: b80c8631ebb64f87bd6a514a9193e92406275e91
Parent: 71e05fe
2 files changed, +113 insertions, -14 deletions
M README.md +9
@@ -49,11 +49,20 @@
49 49 story, and a schema that describes one without the other acquires a constraint
50 50 layer per app, which is how the divergence this crate exists to end got started.
51 51
52 + ## Where the description stops
53 +
52 54 The bespoke widgets: a day-plan timeline, a kanban board, a calendar. A
53 55 description expressive enough to produce those is not a description any more, it
54 56 is a widget library with one app on top. Generate the boring 80% so the bespoke
55 57 20% gets the attention.
56 58
59 + `Region::Bespoke` states that limit instead of hiding it. The description names
60 + the place, the app owns the contents, and a screen with a timeline in it is still
61 + a whole screen. Without that member the description would cover only the boring
62 + screens, and the interesting ones would need a second path beside the router.
63 + One opaque member, and `Region::described()` is how a renderer tells it from the
64 + rest.
65 +
57 66 ## Status
58 67
59 68 0.2.0. On crates.io since 0.1.0, published 2026-07-28. The sequencing decision
M src/lib.rs +104 -14
@@ -40,13 +40,25 @@
40 40 //! - Structure. [`Region`] for the parts of a screen, [`Arrangement`] for how
41 41 //! a screen is put together.
42 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.
43 + //! **Validation** is absent on purpose rather than pending: neither app has a
44 + //! shared story, and a schema describing fields but not constraints acquires a
45 + //! constraint layer per app, which is how the divergence this crate exists to
46 + //! end got started.
47 + //!
48 + //! # Where the description stops
49 + //!
50 + //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
51 + //! are not describable here and will not become describable. A description
52 + //! expressive enough to produce a timeline is a widget library wearing a
53 + //! description's name. Generate the boring 80% so the bespoke 20% gets the
54 + //! attention.
55 + //!
56 + //! [`Region::Bespoke`] is how that limit is stated rather than hidden. The
57 + //! description names the *place* and the app owns the contents, so a screen
58 + //! containing a timeline is still a whole screen and still routable. Without
59 + //! it, the four goingson screens that make the app worth using would need a
60 + //! second, undescribed path beside the router, and two paths is how a
61 + //! vocabulary starts drifting from its app again.
50 62
51 63 #![forbid(unsafe_code)]
52 64
@@ -476,13 +488,14 @@
476 488 /// A named part of a screen.
477 489 ///
478 490 /// 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.
491 + /// *between* things by relationship, and nothing named the things. Six named
492 + /// members, taken from what the two webview apps actually use, plus
493 + /// [`Region::Bespoke`] for the parts no description should reach. Both apps'
494 + /// `layout.css` currently names exactly two things, `.raised` and `.well`, so
495 + /// this layer is absent rather than divergent, which makes it the cheapest of
496 + /// the schemas to add and the easiest to over-build.
484 497 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
485 - pub enum Region {
498 + pub enum Region<'a> {
486 499 /// A full-width strip with a title slot and an actions cluster, either of
487 500 /// which may be empty. goingson's `.page-header`, Balanced Breakfast's
488 501 /// `.header` and `.detail-header` are all this, differing only in which
@@ -498,9 +511,31 @@
498 511 TabGroup,
499 512 /// Content over a scrim, taking input until dismissed.
500 513 Modal,
514 + /// A region this crate names the *place* of and nothing else. The app owns
515 + /// what goes in it.
516 + ///
517 + /// The escape hatch, and the thing that keeps the description honest about
518 + /// its own limits. A day-plan timeline, a kanban board, a calendar and the
519 + /// paint interaction over the timeline are not describable here and are not
520 + /// going to become describable: a description expressive enough to produce
521 + /// a timeline is a widget library wearing a description's name.
522 + ///
523 + /// But a screen containing one still has to be a screen. Without this
524 + /// member the description covers only the boring screens, and the four that
525 + /// make goingson worth using would need a second, undescribed path beside
526 + /// the router. Two paths is how the vocabulary starts drifting from the app
527 + /// again, which is the exact failure this crate exists to end.
528 + ///
529 + /// So the description says "a thing called `day-plan` goes here" and stops.
530 + /// The name is opaque: this crate never interprets it, and no renderer is
531 + /// expected to know what it means beyond handing the space over.
532 + Bespoke {
533 + /// What the app calls it. Never interpreted here.
534 + name: &'a str,
535 + },
501 536 }
502 537
503 - impl Region {
538 + impl Region<'_> {
504 539 /// How the region sits on what is behind it.
505 540 #[must_use]
506 541 pub const fn depth(self) -> Depth {
@@ -509,8 +544,23 @@
509 544 // A pane is looked into, the same as a table body or a tag tree.
510 545 Self::Pane => Depth::Well,
511 546 Self::Modal => Depth::Raised,
547 + // Flat because it inherits: a bespoke region takes the depth of
548 + // whatever frames it. An app that wants its timeline in a well puts
549 + // it in a `Pane`, which composes rather than adding a knob here.
550 + Self::Bespoke { .. } => Depth::Flat,
512 551 }
513 552 }
553 +
554 + /// Whether this crate can say anything about the region's contents.
555 + ///
556 + /// A renderer walks the description and hands every region it understands
557 + /// to the right drawing code. This is how it tells the two apart, and the
558 + /// reason it is a method rather than a `matches!` at each renderer: there
559 + /// is exactly one opaque member and there should stay exactly one.
560 + #[must_use]
561 + pub const fn described(self) -> bool {
562 + !matches!(self, Self::Bespoke { .. })
563 + }
514 564 }
515 565
516 566 /// How a screen is laid out.
@@ -854,6 +904,46 @@
854 904 }
855 905 }
856 906
907 + #[test]
908 + fn exactly_one_region_is_opaque() {
909 + // The escape hatch is one member and stays one member. If a second
910 + // undescribed region ever appears, the description has started
911 + // conceding rather than deferring.
912 + for r in [
913 + Region::Band,
914 + Region::Sidebar,
915 + Region::Pane,
916 + Region::Split,
917 + Region::TabGroup,
918 + Region::Modal,
919 + ] {
920 + assert!(r.described(), "{r:?} should be describable");
921 + }
922 + assert!(!Region::Bespoke { name: "day-plan" }.described());
923 + }
924 +
925 + #[test]
926 + fn a_bespoke_region_inherits_its_depth_rather_than_choosing_one() {
927 + // The app owns the contents, not the placement. An app that wants its
928 + // timeline in a well frames it in a Pane.
929 + assert_eq!(Region::Bespoke { name: "day-plan" }.depth(), Depth::Flat);
930 + assert_eq!(Region::Bespoke { name: "kanban" }.depth(), Depth::Flat);
931 + }
932 +
933 + #[test]
934 + fn a_screen_with_a_bespoke_region_is_still_a_whole_screen() {
935 + // The argument the member exists for: goingson's day-plan has to be
936 + // routable, or the description covers only the boring screens and the
937 + // interesting four need a second path beside the router.
938 + let day_plan = [
939 + Region::Band,
940 + Region::Bespoke { name: "day-plan" },
941 + Region::Sidebar,
942 + ];
943 + assert_eq!(day_plan.iter().filter(|r| r.described()).count(), 2);
944 + assert_eq!(day_plan.iter().filter(|r| !r.described()).count(), 1);
945 + }
946 +
857 947 #[test]
858 948 fn a_secret_field_is_marked_as_one_and_a_hidden_field_is_not_drawn() {
859 949 let secret = Field::new(FieldKind::Secret, "password", "Password");