Skip to main content

max / makeover-layout

0.24.0: name a time axis, withdrawing the timeline refusal Track, Span and Placement. Things placed by when they happen rather than flowed, which is the one thing a list cannot say and the reason goingson's day view has never been describable. The header used to say a day-plan timeline, a kanban board and a calendar are not describable here and will not become describable. That claim had spread to 12 files across three apps, three libraries and the wiki -- including audiofiles and the MNW server, neither of which has a timeline -- and no measurement anywhere behind it. The measurement, taken against goingson's day-planning-render.js: the members it needed and could not get were two integers, where a thing starts and how long it lasts. Labels, gridlines, item bodies and tones were all furniture this crate already named. The refusal priced a timeline as a component library's worth of vocabulary and it was Placement. What survives is the test the refusal was reaching for. Slot heights, gridline colour, how overlaps stack, which hour scrolls into view on open: presentation, the renderer's, and absent from Track. A test asserts Track stays three fields so a pixel measure has to come argue for itself. Overlap is computed from the times rather than declared on an entry. Whether an overlap is a conflict is the app's judgment and travels as a Tone; what a renderer needs to lay two things side by side it can derive. A flag would be a second source for a fact the times already carry. Span is minutes from midnight, not instants, so no timezone enters the vocabulary. is exclusive and may pass 1440, which is how a night shift is said without a second date. Kanban and calendar are untouched and now open questions rather than settled refusals. Whoever needs one counts the missing members first.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 15:37 UTC
Signed with PGP, not checked
Commit: 64581dc5ae1a1325e3fe5ff54f566bb3ed6ccb13
Parent: 4d636ee
2 files changed, +357 insertions, -12 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.23.0"
3 + version = "0.24.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +356 -11
@@ -294,12 +294,38 @@
294 294 //!
295 295 //! # Where the description stops
296 296 //!
297 - //! A day-plan timeline, a kanban board and a calendar are not describable here
298 - //! and will not become describable. A description expressive enough to produce
299 - //! a timeline is a component library wearing a description's name. Generate the
300 - //! boring 80% so the bespoke 20% gets the attention.
297 + //! The rule is that a member is added when an app needs a fact the vocabulary
298 + //! cannot state, and refused when what it wants is presentation it should be
299 + //! asking a renderer for. That is the whole test. It is not a quota, and the
300 + //! goal is every screen described.
301 301 //!
302 - //! [`Region::Bespoke`] is how that limit is stated rather than hidden. The
302 + //! ## What the timeline refusal got wrong, 2026-08-15
303 + //!
304 + //! This section used to read "a day-plan timeline, a kanban board and a
305 + //! calendar are not describable here and will not become describable", and it
306 + //! propagated: 12 files across three apps, three libraries and the design wiki
307 + //! cited it, including audiofiles and the MNW server, neither of which has a
308 + //! timeline. It is withdrawn, and [`Track`] is the member it was refusing.
309 + //!
310 + //! The error was pricing. The argument assumed a timeline needs a component
311 + //! library's worth of vocabulary, and nobody measured it. Held against
312 + //! goingson's `day-planning-render.js`, the members it actually needed and
313 + //! could not get were two integers: where a thing starts, and how long it
314 + //! lasts. Labels, gridlines, item bodies and tones were all furniture this
315 + //! crate already named. A refusal that expensive should have carried a
316 + //! measurement, and did not.
317 + //!
318 + //! The reasoning underneath it survives and is still the test: slot heights,
319 + //! gridline colour, how overlapping things stack, which hour scrolls into view.
320 + //! Those are presentation, they stay the renderer's, and [`Track`] carries none
321 + //! of them. What changed is the conclusion, not the principle.
322 + //!
323 + //! A kanban board and a calendar have not been measured the same way. They are
324 + //! open questions rather than settled refusals, and whoever needs one should
325 + //! count the missing members first.
326 + //!
327 + //! [`Region::Bespoke`] remains for the genuinely app-owned, and its
328 + //! justification does not depend on the withdrawn claim. The
303 329 //! description names the *place* and the app owns the contents, so a screen
304 330 //! containing a timeline is still a whole screen and still routable. Without
305 331 //! it, the four goingson screens that make the app worth using would need a
@@ -1488,6 +1514,239 @@
1488 1514 }
1489 1515 }
1490 1516
1517 + /// A window of time, in minutes from the start of a day.
1518 + ///
1519 + /// The axis a [`Track`] draws. Minutes rather than instants, and offsets rather
1520 + /// than wall-clock, because a description that carried a `DateTime` would carry
1521 + /// a timezone with it and the vocabulary has no business holding one. The app
1522 + /// knows which day this is; the description says how far along it a thing sits.
1523 + ///
1524 + /// `to` is exclusive and may exceed `1440`, which is how a span that runs past
1525 + /// midnight is said without a second date: `Span::new(1320, 1560)` is 22:00 to
1526 + /// 02:00.
1527 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1528 + pub struct Span {
1529 + from: u16,
1530 + to: u16,
1531 + }
1532 +
1533 + impl Span {
1534 + /// Midnight to midnight, the ordinary day.
1535 + pub const DAY: Self = Self { from: 0, to: 1440 };
1536 +
1537 + /// A span, clamped to a sane one.
1538 + ///
1539 + /// An empty or backwards span is a caller bug that should not cost a
1540 + /// renderer a division by zero, so `to` is forced at least one minute past
1541 + /// `from` rather than returning an error nobody can act on. Same reasoning
1542 + /// as [`Share::percent`], which clamps rather than refuses.
1543 + #[must_use]
1544 + pub const fn new(from: u16, to: u16) -> Self {
1545 + Self {
1546 + from,
1547 + to: if to > from { to } else { from + 1 },
1548 + }
1549 + }
1550 +
1551 + /// The first minute on the axis.
1552 + #[must_use]
1553 + pub const fn from(self) -> u16 {
1554 + self.from
1555 + }
1556 +
1557 + /// One past the last minute on the axis.
1558 + #[must_use]
1559 + pub const fn to(self) -> u16 {
1560 + self.to
1561 + }
1562 +
1563 + /// How many minutes the axis covers. Never zero.
1564 + #[must_use]
1565 + pub const fn minutes(self) -> u16 {
1566 + self.to - self.from
1567 + }
1568 +
1569 + /// Whether a minute falls on this axis.
1570 + #[must_use]
1571 + pub const fn holds(self, minute: u16) -> bool {
1572 + minute >= self.from && minute < self.to
1573 + }
1574 + }
1575 +
1576 + impl Default for Span {
1577 + fn default() -> Self {
1578 + Self::DAY
1579 + }
1580 + }
1581 +
1582 + /// Where a thing sits on a [`Track`], and for how long.
1583 + ///
1584 + /// The one fact a list cannot carry and the whole reason this primitive exists.
1585 + /// A list says what order things come in; a track says a thing starts 135
1586 + /// minutes along and lasts 45, which is a different claim and not derivable
1587 + /// from the first.
1588 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1589 + pub struct Placement {
1590 + at: u16,
1591 + minutes: u16,
1592 + }
1593 +
1594 + impl Placement {
1595 + /// A placement, clamped to a drawable one.
1596 + ///
1597 + /// Zero minutes becomes one for the same reason [`Span::new`] clamps: a
1598 + /// zero-height thing is invisible rather than expressive, and every
1599 + /// renderer would need its own guard.
1600 + #[must_use]
1601 + pub const fn new(at: u16, minutes: u16) -> Self {
1602 + Self {
1603 + at,
1604 + minutes: if minutes == 0 { 1 } else { minutes },
1605 + }
1606 + }
1607 +
1608 + /// Minutes from the start of the day, matching [`Span`]'s origin.
1609 + #[must_use]
1610 + pub const fn at(self) -> u16 {
1611 + self.at
1612 + }
1613 +
1614 + /// How long it lasts. Never zero.
1615 + #[must_use]
1616 + pub const fn minutes(self) -> u16 {
1617 + self.minutes
1618 + }
1619 +
1620 + /// One past its last minute.
1621 + #[must_use]
1622 + pub const fn end(self) -> u16 {
1623 + self.at + self.minutes
1624 + }
1625 +
1626 + /// Whether two placements cover any of the same time.
1627 + ///
1628 + /// Geometry, and deliberately not a described field. Whether an overlap is
1629 + /// a *conflict* is the app's judgment -- a meeting inside a block of free
1630 + /// time overlaps and is fine -- and that judgment travels the way every
1631 + /// other judgment does, as a [`Tone`] on the thing itself. What a renderer
1632 + /// needs in order to lay two things side by side instead of on top of each
1633 + /// other is this, and it can compute it.
1634 + ///
1635 + /// The alternative was a `conflicts: bool` on each entry, which is state
1636 + /// that can disagree with the times beside it. Two sources for one fact is
1637 + /// how a screen starts rendering a conflict badge on a thing that no longer
1638 + /// conflicts.
1639 + #[must_use]
1640 + pub const fn overlaps(self, other: Self) -> bool {
1641 + self.at < other.end() && other.at < self.end()
1642 + }
1643 + }
1644 +
1645 + /// A time axis: things placed by when they happen, rather than flowed.
1646 + ///
1647 + /// # Why this is a primitive
1648 + ///
1649 + /// This crate refused to name it until 2026-08-15, on the argument that a
1650 + /// description expressive enough to draw a timeline is a component library
1651 + /// wearing a description's name. The refusal is withdrawn, and it is worth
1652 + /// being precise about what was wrong with it, because the reasoning it used
1653 + /// applies to real cases and should not be discarded with it.
1654 + ///
1655 + /// What a timeline needs that a [`List`](Region::Pane) does not is **one**
1656 + /// thing: placement. Where a thing sits is a fact about the thing, the way a
1657 + /// row's primary text is, and it is not derivable from order. Everything else a
1658 + /// day view draws -- the labels, the gridlines, the item bodies, the tones --
1659 + /// is furniture this vocabulary already names. Measured against goingson's
1660 + /// `day-planning-render.js`, the only members it needed and could not get were
1661 + /// `at` and `minutes`.
1662 + ///
1663 + /// So the timeline was never a component library's worth of vocabulary. It was
1664 + /// two integers, and the refusal was priced as though it were the whole widget.
1665 + /// The test that matters is not "does this shape look complicated" but "how
1666 + /// many members does it actually add, and are they facts or presentation".
1667 + /// Slot heights, gridline colour, how overlaps stack and which hour scrolls
1668 + /// into view on open are all presentation and all stay the renderer's, which is
1669 + /// why they are absent here.
1670 + ///
1671 + /// # What it does not carry
1672 + ///
1673 + /// No pixel measure, no scroll offset, no drag affordance. A renderer draws the
1674 + /// span at whatever density its host uses; `makeover-geometry` owns that the
1675 + /// way it owns everything else measured in pixels.
1676 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1677 + pub struct Track {
1678 + /// The window the axis covers.
1679 + pub span: Span,
1680 + /// The granularity a thing can be placed on, in minutes.
1681 + ///
1682 + /// goingson's day view is 15, giving 96 slots across a day. A renderer uses
1683 + /// it to decide where gridlines fall and what a drop lands on; it does not
1684 + /// constrain [`Placement`], because data arriving from a calendar does not
1685 + /// respect anyone's grid.
1686 + pub slot: u16,
1687 + /// How often the axis labels itself, in minutes.
1688 + ///
1689 + /// 60 gives an hourly ruler over a 15-minute grid, which is the common
1690 + /// shape and the reason this is separate from `slot`. Zero means an
1691 + /// unlabelled axis.
1692 + pub tick: u16,
1693 + }
1694 +
1695 + impl Track {
1696 + /// An ordinary day: midnight to midnight, quarter-hour slots, hourly ticks.
1697 + pub const DAY: Self = Self {
1698 + span: Span::DAY,
1699 + slot: 15,
1700 + tick: 60,
1701 + };
1702 +
1703 + /// A track over `span`, with the day's usual granularity.
1704 + #[must_use]
1705 + pub const fn over(span: Span) -> Self {
1706 + Self {
1707 + span,
1708 + slot: 15,
1709 + tick: 60,
1710 + }
1711 + }
1712 +
1713 + /// How many slots the axis holds.
1714 + ///
1715 + /// Rounded up, so a span that does not divide evenly by `slot` still has a
1716 + /// slot covering its tail rather than dropping it. Never zero: `slot` of 0
1717 + /// reads as one slot spanning the whole axis rather than a division by
1718 + /// zero, since a renderer asking this question has already committed to
1719 + /// drawing something.
1720 + #[must_use]
1721 + pub const fn slots(self) -> u16 {
1722 + if self.slot == 0 {
1723 + 1
1724 + } else {
1725 + self.span.minutes().div_ceil(self.slot)
1726 + }
1727 + }
1728 +
1729 + /// Where a placement sits on the axis, as a fraction from 0.0 to 1.0.
1730 + ///
1731 + /// The one calculation every renderer would otherwise write itself, and the
1732 + /// place the three would drift apart. Clamped, so a placement outside the
1733 + /// span draws at the edge rather than off it -- an event running past
1734 + /// midnight is a real thing and truncating it is better than either
1735 + /// panicking or drawing it somewhere impossible.
1736 + #[must_use]
1737 + pub fn fraction(self, minute: u16) -> f32 {
1738 + let span = f32::from(self.span.minutes());
1739 + let offset = f32::from(minute.saturating_sub(self.span.from()));
1740 + (offset / span).clamp(0.0, 1.0)
1741 + }
1742 + }
1743 +
1744 + impl Default for Track {
1745 + fn default() -> Self {
1746 + Self::DAY
1747 + }
1748 + }
1749 +
1491 1750 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1492 1751 pub struct Act<'a> {
1493 1752 /// What the control says.
@@ -1644,12 +1903,17 @@
1644 1903 ///
1645 1904 /// # What it does not do
1646 1905 ///
1647 - /// It does not make a timeline describable, and the refusal in the crate
1648 - /// header stands unchanged. A widget is an assembly of things the
1649 - /// vocabulary *already* says; anything that needs a member the vocabulary
1650 - /// does not have is a finding about the vocabulary or it is
1651 - /// [`Bespoke`](Self::Bespoke). A widget is never the way a primitive gets
1652 - /// added by the back door.
1906 + /// A widget is an assembly of things the vocabulary *already* says, so it
1907 + /// buys no expressive power. Anything needing a member the vocabulary does
1908 + /// not have is a finding about the vocabulary, and the answer to a finding
1909 + /// is to add the member. A widget is never the way a primitive gets added
1910 + /// by the back door.
1911 + ///
1912 + /// This used to say "it does not make a timeline describable, and the
1913 + /// refusal in the crate header stands unchanged". The timeline is
1914 + /// describable as of 2026-08-15 -- see [`Track`] -- and it got there the
1915 + /// way the paragraph above says it should have: by adding the two members
1916 + /// that were missing, not by dressing the screen up as an assembly.
1653 1917 Widget {
1654 1918 /// What the assembly is called. This crate never interprets it, and a
1655 1919 /// renderer is free not to know it.
@@ -3157,6 +3421,87 @@
3157 3421 assert!(!Selector::Toggle.abutting());
3158 3422 }
3159 3423
3424 + #[test]
3425 + fn a_span_never_has_zero_minutes_however_it_is_asked_for() {
3426 + // Every renderer divides by this. A caller passing a backwards or empty
3427 + // span is a bug, but it is not a bug worth a panic three renderers deep.
3428 + assert_eq!(Span::new(600, 600).minutes(), 1);
3429 + assert_eq!(Span::new(600, 300).minutes(), 1);
3430 + assert_eq!(Span::DAY.minutes(), 1440);
3431 + }
3432 +
3433 + #[test]
3434 + fn a_span_can_run_past_midnight_without_a_second_date() {
3435 + // 22:00 to 02:00. The alternative was carrying a date, which drags a
3436 + // timezone into the vocabulary for the sake of one night shift.
3437 + let overnight = Span::new(1320, 1560);
3438 + assert_eq!(overnight.minutes(), 240);
3439 + assert!(overnight.holds(1500));
3440 + assert!(!overnight.holds(1200));
3441 + }
3442 +
3443 + #[test]
3444 + fn overlap_is_computed_rather_than_declared() {
3445 + // The reason Placement carries no `conflicts` flag: the times already
3446 + // say it, and a second source for one fact is how a stale conflict
3447 + // badge outlives the conflict.
3448 + let morning = Placement::new(540, 60); // 09:00-10:00
3449 + let overlapping = Placement::new(570, 60); // 09:30-10:30
3450 + let after = Placement::new(600, 60); // 10:00-11:00
3451 +
3452 + assert!(morning.overlaps(overlapping));
3453 + assert!(overlapping.overlaps(morning), "overlap is symmetric");
3454 + // Touching end to end is not overlapping: `to` is exclusive, so a
3455 + // 10:00 start does not collide with a 10:00 end.
3456 + assert!(!morning.overlaps(after));
3457 + assert!(!after.overlaps(morning));
3458 + }
3459 +
3460 + #[test]
3461 + fn a_placement_is_always_drawable() {
3462 + assert_eq!(Placement::new(540, 0).minutes(), 1);
3463 + assert_eq!(Placement::new(540, 30).end(), 570);
3464 + }
3465 +
3466 + #[test]
3467 + fn a_track_places_the_fraction_every_renderer_would_otherwise_compute() {
3468 + let day = Track::DAY;
3469 + assert!((day.fraction(0) - 0.0).abs() < f32::EPSILON);
3470 + assert!((day.fraction(720) - 0.5).abs() < f32::EPSILON);
3471 + // Clamped rather than off the end: an event running past the span's
3472 + // close draws at the edge, which beats panicking or drawing nowhere.
3473 + assert!((day.fraction(2000) - 1.0).abs() < f32::EPSILON);
3474 + }
3475 +
3476 + #[test]
3477 + fn a_track_counts_its_slots_and_never_divides_by_zero() {
3478 + assert_eq!(Track::DAY.slots(), 96);
3479 + assert_eq!(Track::over(Span::new(540, 1020)).slots(), 32);
3480 + // A span that does not divide evenly keeps a slot for its tail.
3481 + assert_eq!(Track::over(Span::new(0, 50)).slots(), 4);
3482 + // slot: 0 is a caller bug that reads as one slot, not a panic.
3483 + let degenerate = Track {
3484 + span: Span::DAY,
3485 + slot: 0,
3486 + tick: 60,
3487 + };
3488 + assert_eq!(degenerate.slots(), 1);
3489 + }
3490 +
3491 + #[test]
3492 + fn a_track_carries_facts_and_no_presentation() {
3493 + // The guard on the thing the withdrawn refusal was right about. If a
3494 + // pixel measure, a scroll offset or a colour ever lands on Track, the
3495 + // member has stopped being a fact about the data and the timeline
3496 + // really has become a component library wearing a description's name.
3497 + let day = Track::DAY;
3498 + assert_eq!(day.span, Span::DAY);
3499 + assert_eq!(day.slot, 15);
3500 + assert_eq!(day.tick, 60);
3501 + // Three fields. Adding a fourth should have to come here and say why.
3502 + assert_eq!(std::mem::size_of::<Track>(), 8);
3503 + }
3504 +
3160 3505 #[test]
3161 3506 fn a_pane_is_looked_into_and_a_band_is_not() {
3162 3507 assert_eq!(Region::Pane.depth(), Depth::Well);