//! The renderer-agnostic half of the make-family design system. //! //! //! //! `makeover` answers *what colour*, and varies by theme. `makeover-geometry` //! answers *how much space*, and varies by density and surface. This crate //! answers *what the thing is*, and varies by nothing. //! //! # The deferral rule //! //! A description names intents and relationships, never values. Say //! [`Fill::Raised`], never `#D9DDF4`. Say `Gap::Peer`, never `6px`. What is //! left once colour and spacing are deferred is **composition**: which edges //! are lit, what inverts on press, what nests in what. //! //! The constraint that shapes all of it: a renderer that can only paint //! rectangles has to be able to express the result. egui has no //! `box-shadow: inset` and one stroke per widget with no per-side control; a //! terminal has box-drawing characters and one cell of resolution, and cannot //! draw a two-tone lit edge at all. A description that assumes per-side edges //! is a CSS description wearing a neutral name. So this crate names the //! *intent* — this region is a well — and each renderer chooses an expression //! it can actually produce, including dropping half of one. //! //! # Scope //! //! Depth came first: the bevel and the surfaces it shapes. That much was //! settled the hard way — the vocabulary here was read off audiofiles' //! `ui::theme` and `ui::widgets`, which are the only implementation written //! by a consumer with no CSS, then checked against both webview apps. All //! three agreed once Balanced Breakfast's fills were corrected. //! //! 0.2.0 adds the rest of the description, each member drawn the same way, //! from what the three apps already hand-write rather than from a taxonomy: //! //! - Components. [`Token`] (badge against chip), [`Notice`] (toast against //! banner), [`RowPart`], [`Heading`], [`Selector`], [`Readiness`], and //! [`Tone`], which is the one intent family they share. //! - Schemas. [`Field`] for forms and [`Column`] for lists and tables. //! - Structure. [`Region`] for the parts of a screen, [`Arrangement`] for how //! a screen is put together. //! //! **Validation** was absent on purpose here, on the grounds that neither app //! had a shared story. That reasoning is retired — see 0.11.0 below, which is //! where the constraints arrived and why the argument did not survive contact //! with what the apps were measured to do. //! //! 0.3.0 closes a gap the first real adoption found, which is what adopting //! against goingson first was for. [`Selector`] described only the *chosen* //! option, so an unchosen one fell through to [`Depth::Flat`] and no renderer //! drew it; goingson's tab strip recesses its unchosen tabs by hand and could //! not delete the line, because being recessed is *why* the chosen tab reads as //! coming forward. So [`Selector::unchosen`] joins `chosen`, and saying it //! needed [`Fill::Sunken`] and [`Depth::Sunken`]: a surface set back by colour //! with no edge, which is neither a well nor level-with. //! //! 0.7.0 adds [`State`], the interaction axis, closing the gap that adopting //! against three apps rather than one made visible. The description named //! rest and, through [`Depth::pressed`], pressed. It named neither focus nor //! disabled, so `makeover-webview` emitted a hover rule and stopped, and each //! consumer completed the primitive from outside by out-specifying a rule it //! did not own: 19 such rules in goingson, 21 in the MNW server, a further set //! in Balanced Breakfast, and three focus rings that do not match. The axis is //! deliberately two members wide, because hover and pressed belong where they //! already are. [`State`]'s own docs carry that argument. //! //! 0.8.0 finishes [`Field`], which described a field well enough to label it and //! not well enough to draw it. Writing `makeover-webview`'s form emitter found //! three things missing and the renderer supplied all three from outside: the //! current value, a select's options, and the placeholder. Two of those move //! here and one does not. //! //! - [`Field::placeholder`] is user-facing text sitting beside `label` and //! `hint`. There was never a reading on which it was renderer state; it was //! outside only because adding a field to a published struct is breaking. //! - [`Field::options`] moves because every renderer needs them and each was //! going to invent its own shape. [`Choice`] is the shape `makeover-webview` //! already arrived at, taken as-is rather than redesigned. //! - The current value stays renderer-side and is not coming here. It is the //! one of the three that is genuinely state: a webview reads it out of the //! DOM, an immediate-mode renderer holds a `&mut` to the app's own field, and //! a description that carried it would be a form model. //! //! 0.9.0 opens [`RowPart`], which was the last closed enum in the vocabulary, //! and adds [`RowPart::Tokens`]. Both halves come from the same finding, made //! by the first two real screens described through the router rather than by //! reading a stylesheet. //! //! A goingson project card carries two trailing badges, a type and a toned //! status; a contact card carries a primary email *and* a strip of tags. `Meta` //! is one slot and one string, so both ports joined their facts with a //! separator and lost what the second one was: a status reads as text where it //! used to read as colour. [`Token`] already says exactly the right thing — a //! small labelled thing with a kind, a tone and an optional action — and could //! only ever be a node in its own right, never inside a row. //! //! So the missing thing was permission rather than a concept. `Tokens` is that //! permission, and `#[non_exhaustive]` arrives with it so the next member is not //! a lockstep event across three renderers. The pairing is the point: this //! enum's own consumer in `makeover-webview` carried a comment predicting it //! would stop compiling one day, which is a lockstep break written down and //! waited for rather than prevented. //! //! Balanced Breakfast was checked before the member was added, because one //! consumer wanting something is not evidence. It packs a count and two icon //! buttons into the same single `Meta` slot while leaving `Actions` empty, so //! the slot was already straining under a second consumer for a different //! reason. //! //! 0.10.0 adds [`Meter`], a proportion carried as a pair rather than as a //! percentage. Its own docs carry the argument; the short form is that the //! percentage shape had already been tried in goingson and had already needed a //! companion flag to recover what rounding and clamping threw away. //! //! 0.11.0 is four members from the quasi proving ground, batched into one //! release because pre-1.0 a minor is breaking and a cascade is nine repos. //! Three findings that arrived with them turned out not to belong here at all: //! this crate has no notion of an action, a route or a destination, so anything //! asking what a control *calls* was never the vocabulary's to say. //! //! - [`Figure`], a value with a caption. goingson had five of them across five //! screens with five class vocabularies for the one shape, which is the //! divergence this crate exists to end, sitting in plain sight and counted for //! the first time. //! - [`RowPart::Proportion`], so a [`Meter`] can sit in a row. `Meter` reached //! two of its seven sites at 0.10.0 and the other five are row-shaped. Exactly //! [`RowPart::Tokens`]'s problem with a different payload, and it takes //! `Tokens`' answer: the part carries the description of a bar, not a node. //! - [`Field::max_length`], [`Field::min`] and [`Field::max`], joining //! [`Field::required`], which had been sitting here as the sole constraint //! while the header above claimed there were none. The set stops before //! `pattern`, which fails the renderer test and is one site in one app. //! - [`FieldKind::File`]. Every host has an honest answer — a native picker, an //! ``, a path prompt, an argument — and it carries no //! accepted-types list because `accept` appears at zero sites in either app. //! //! The evidence rule changed under these, and it is worth recording because four //! earlier decisions were made under the old one. The two-app test said a shape //! earns a word once a second app wants it. It is backwards: a rule that //! withholds a word until a second app has duplicated the code guarantees the //! duplication, and app three writes it a third time. The bar is now generic //! against bespoke — is this furniture any app would have, or is it this app's //! own? Bespoke keeps [`Region::Bespoke`], which already carries a completion //! heatmap and is the right answer for a calendar nobody will build twice. //! //! # Where the description stops //! //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar, //! are not describable here and will not become describable. A description //! expressive enough to produce a timeline is a widget library wearing a //! description's name. Generate the boring 80% so the bespoke 20% gets the //! attention. //! //! [`Region::Bespoke`] is how that limit is stated rather than hidden. The //! description names the *place* and the app owns the contents, so a screen //! containing a timeline is still a whole screen and still routable. Without //! it, the four goingson screens that make the app worth using would need a //! second, undescribed path beside the router, and two paths is how a //! vocabulary starts drifting from its app again. #![forbid(unsafe_code)] /// A colour intent this crate refers to but never resolves. /// /// The string is the token name `makeover` publishes, so a renderer can look /// it up without this crate knowing what colour came back. pub trait Intent { /// The `makeover` intent token this resolves against. fn token(self) -> &'static str; } /// Which way the light falls across a two-tone edge. /// /// The whole content of a bevel, once colour and thickness are deferred. The /// light is always assumed to come from the top left: every consumer measured /// agreed on that and none of them ever varied it, so it is an invariant here /// rather than a parameter. /// /// # The two corners that belong to both edges /// /// Top-right and bottom-left are where the lit run meets the shaded one, and /// the description's claim is that they belong to *both*. How a renderer says /// that is its own business, because the answer is bounded by resolution and /// not by taste: /// /// - A terminal cell is roughly 8x17 device pixels, so giving the whole corner /// to one tone thickens that edge by a cell and reads as one run overrunning /// the other. A half-cell glyph divides the cell already, so `makeover-tui` /// splits it and recovers real information. Its box-drawing fallback cannot: /// a single stroke has no half to give, so there both corners go to dark. /// - A pixel bevel is a one-point stroke by default, which makes the corner a /// one-point square. There is nothing to divide — a diagonal seam across one /// point is sub-pixel, and antialiasing renders it as the blend a mitred join /// already produces. So `makeover-immediate` mitres and is *not* diverging; /// it is the same rule at a resolution where the split degenerates. /// /// Stated here so the difference reads as a decision rather than as drift. A /// renderer with room to divide the corner should; one without should mitre or /// pick the shaded tone, and neither is a bug. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Bevel { /// Lit from the top left: light on top and left, dark on bottom and right. Raised, /// The same edge inverted, which is also the pressed state of anything /// that draws itself [`Bevel::Raised`]. Inset, } impl Bevel { /// The edge intents, as `(top_left, bottom_right)`. /// /// Split out from any painting because the inversion *is* the idea, and /// it is the one part every renderer implements identically. #[must_use] pub const fn edges(self) -> (Edge, Edge) { match self { Self::Raised => (Edge::Light, Edge::Dark), Self::Inset => (Edge::Dark, Edge::Light), } } /// Pressing inverts. A raised control reads as inset while held. /// /// Stated here rather than left to each consumer because a cascade can /// carry a pressed state and an immediate-mode renderer cannot: audiofiles /// resolves this per call site, eighteen times. #[must_use] pub const fn pressed(self) -> Self { match self { Self::Raised => Self::Inset, Self::Inset => Self::Raised, } } } /// One side of a bevel, named by the intent it takes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Edge { /// The lit side. Light, /// The shadowed side. Dark, } impl Intent for Edge { fn token(self) -> &'static str { match self { Self::Light => "bevel-light", Self::Dark => "bevel-dark", } } } /// A surface intent a region is filled with. /// /// `#[non_exhaustive]`, so a renderer must carry a wildcard arm and a new /// member is additive rather than breaking. Added 0.4.0, after [`Sunken`] /// (an additive member, 0.3.0) hard-broke `makeover-tui` and /// `makeover-immediate` at compile time and left neither able to move until /// both published. The vocabulary exists to grow and the renderers exist to /// disagree about how much of it they answer, so growth must not be a /// lockstep event. The renderer's wildcard is not a hole: [`Fill`] is /// resolved through a fallible lookup, and a missing intent is answered with /// structure rather than with a substituted colour. /// /// [`Sunken`]: Fill::Sunken #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Fill { /// The page behind everything. Page, /// A surface lifted off the page: cards, controls, menus, toasts. Raised, /// A surface floating above the page rather than resting on it. Overlay, /// The inside of a well. Well, /// A surface set back from the one it sits on, by colour and nothing else. /// /// Not a well. A well is a hole with an edge, and the two are authored in /// opposite directions: `makeover` derives `surface-well` by inverting /// against the theme's own content colour, while `surface-sunken` is /// authored and free to sit darker than raised (goingson's does). Naming /// only the well left the recessed-with-no-edge surface unsayable, which is /// what an unchosen tab is: it recedes so the chosen one can come forward, /// and it carries no bevel of its own. /// /// Added 0.3.0, from goingson's tab strip, which hand-writes exactly this /// and could not delete the line because no member described it. Sunken, } // No `fallback` here, deliberately. An earlier cut had `Fill::Well` fall back // to `Fill::Page` so a consumer on makeover 2.2.0, which has no `surface-well`, // had something to paint. makeover-tui found that wrong within a day: page is // the surface a well is usually cut into, so on a terminal that substitution // produces exactly the invisibility it was meant to prevent, and the right // answer there is a drawn edge rather than a different colour. // // Substituting one intent for another is renderer policy. The description says // what the region is and stops. impl Intent for Fill { fn token(self) -> &'static str { match self { Self::Page => "surface-page", Self::Raised => "surface-raised", Self::Overlay => "surface-overlay", Self::Well => "surface-well", Self::Sunken => "surface-sunken", } } } /// How a region sits relative to the surface behind it. /// /// Fill and bevel are named together because naming them apart is what let /// them disagree. Every consumer measured had at least one region carrying a /// raised bevel over a recessed fill: audiofiles fixed it in `raised_frame` /// and recorded the bug in its doc comment, and Balanced Breakfast still had /// twelve of them a year later. A single name for the pair makes that /// unrepresentable. /// `#[non_exhaustive]` for the same reason as [`Fill`], and in the same /// release: a depth this renderer has no drawing for should cost it a /// wildcard arm, not a compile error and a wait on someone else's publish. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Depth { /// Level with its surroundings. No edge. Flat, /// A card laid on the panel it sits in. Raised, /// A hole in the panel, with content down inside it. For anything the /// user looks *into*: a table body, a tag tree, a text field. Well, /// Set back from what it sits on, by colour alone. No edge. /// /// The one member carrying a fill without a bevel, so a renderer cannot /// assume the two arrive together. That is deliberate and it is still the /// pairing rule: both halves come off the same `Depth`, so they cannot /// disagree, and here one half is legitimately absent. /// /// Distinct from [`Depth::Flat`], which has no fill either and inherits. /// Recessed and level-with are different claims, and only one of them /// needs a colour. Sunken, } impl Depth { /// The edge this depth is drawn with, if it has one. #[must_use] pub const fn bevel(self) -> Option { match self { // Sunken joins Flat here, for the opposite reason: Flat has no edge // because nothing separates it from its surroundings, and Sunken has // none because its colour is already doing the separating. Self::Flat | Self::Sunken => None, Self::Raised => Some(Bevel::Raised), Self::Well => Some(Bevel::Inset), } } /// The surface this depth is filled with. /// /// [`Depth::Flat`] has no fill of its own: it inherits whatever it sits on, /// which is the difference between level-with and painted-the-same-colour. #[must_use] pub const fn fill(self) -> Option { match self { Self::Flat => None, Self::Raised => Some(Fill::Raised), Self::Well => Some(Fill::Well), Self::Sunken => Some(Fill::Sunken), } } /// Pressing a raised region reads as a well, and nothing else moves. #[must_use] pub const fn pressed(self) -> Self { match self { Self::Raised => Self::Well, other => other, } } } /// An interaction state a region can be in, beside whatever [`Depth`] it is. /// /// Orthogonal to depth on purpose. A disabled button is still [`Depth::Raised`] /// and a disabled field is still a [`Depth::Well`], so folding either member /// into `Depth` would make [`Depth::bevel`] and [`Depth::fill`] answer for /// something that is not a depth, and would leave disabled-button and /// disabled-field sharing one variant that cannot tell them apart. /// /// # Why hover and pressed are not members /// /// The line is whether every renderer has the state to express, not whether CSS /// does. Hover is renderer policy and `makeover-webview` says so in its own /// header: a terminal and an immediate-mode painter have no pointer hovering /// over anything, and pressed already arrives through [`Bevel::pressed`] and /// [`Depth::pressed`], where it belongs, because pressing is a depth inversion /// rather than a separate condition. /// /// Focus and disabled are different in kind. A TUI has a focused widget and a /// greyed-out one; so does egui. Both were unsayable here, so all three webview /// consumers supplied them from outside the primitive by out-specifying rules /// they did not own: goingson alone carries 19 of them, and the MNW server /// another 21. That is the divergence this crate exists to end, arriving one /// layer down. /// /// # The principle this encodes /// /// A primitive owns every state it implies. A renderer that emits a hover rule /// for a thing owes disabled, focus and the capability answer for that same /// thing, because anything less exports the completion work to N consumers who /// will each do it differently. /// /// `#[non_exhaustive]` for the reason [`Fill`] and [`Depth`] carry it: growth /// must not be a lockstep event across the three renderers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum State { /// Keyboard focus, as distinct from the pointer having landed on something. /// /// One ring, not one per primitive. Where the ring sits is [`Depth`]'s /// question and not a per-component choice: a well takes it inside its own /// edge and a raised surface takes it outside. That is one decision with /// two renderings rather than one decision per component, which is how the /// three apps ended up with three rings. Focus, /// Present, visible, and not answering. /// /// Not the same as absent, and deliberately not a [`Fill`]: a disabled /// control keeps the surface it always had and stops responding, so what /// changes is its content and its interactivity rather than what it is. Disabled, } impl State { /// Whether a region in this state stops answering the pointer. /// /// Stated in the description rather than left to each renderer, on the same /// reasoning as [`Bevel::pressed`]: a cascade carries it for free and an /// immediate-mode renderer resolves it per call site, so leaving it unsaid /// means resolving it once per consumer and disagreeing. #[must_use] pub const fn suppresses_interaction(self) -> bool { match self { Self::Disabled => true, Self::Focus => false, } } } impl Intent for State { fn token(self) -> &'static str { match self { // Already derived by `makeover` from `action.primary`, and unused // until now for the same reason `hover-surface` was: nothing // emitted the rule that would consume it. Self::Focus => "focus-ring", // Reusing the muted content intent rather than minting a // `disabled` colour. Disabled is a reduction and not a status, and // `makeover-webview`'s progress rules already record the reading // that `content-muted` is what disabled looks like. Self::Disabled => "content-muted", } } } /// What a region is saying, when it is saying something. /// /// The one intent family shared by badges, notices and nothing else. Kept /// separate from [`Fill`] because a surface is where a thing sits and a tone is /// what it means, and the three apps agree on the four statuses: /// `info_banner` / `warning_banner` in audiofiles, `.toast-info` / /// `.toast-success` / `.toast-error` in goingson, `.toast.success` / /// `.toast.error` in Balanced Breakfast. /// /// The per-tag palette (`category-one` through `category-six`) is deliberately /// not here. Which colour a *particular* tag takes is app domain, and both /// webview apps already carry it as a `data-color` attribute. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tone { /// No status. Reads as ordinary de-emphasised content. Neutral, /// Something worth knowing and nothing to do about it. Info, /// Something finished and it worked. Success, /// Something the user should look at before continuing. Warning, /// Something broken, or something about to be destroyed. Danger, } impl Intent for Tone { fn token(self) -> &'static str { match self { // Neutral has no status token of its own. It takes the muted // content intent, which is what both webview apps already spell as // `data-color="muted"`. Self::Neutral => "content-muted", Self::Info => "info", Self::Success => "success", Self::Warning => "warning", Self::Danger => "danger", } } } /// A small labelled thing that sits inside something else. /// /// Two members, because the three apps drew three taxonomies and only one line /// runs through all of them: does it answer a click. audiofiles has /// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable` /// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and /// `.badge` against `.tag-chip`. goingson is the one that has to move: its /// `.tag` and `.badge` are a single CSS rule, so every call site has to be read /// to decide which of the two it always was. /// /// The evidence that a chip is a real concept rather than a badge with a /// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches /// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip /// holds itself down", which is exactly what [`Depth::pressed`] already says. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Token { /// Non-interactive status or count. Answers no click. Badge, /// An interactive or removable token. Answers a click, and latches if it /// stands for a filter that is either on or off. Chip { /// Whether it carries its own remove affordance. removable: bool, }, } impl Token { /// Whether this answers a click. /// /// The whole difference between the two members, and the reason a renderer /// with no hover (a touch surface, a terminal) can still tell them apart. #[must_use] pub const fn interactive(self) -> bool { matches!(self, Self::Chip { .. }) } /// How it sits, given whether it is currently latched down. /// /// A badge is flat: it is a label, and giving it an edge would say it can /// be pressed. A chip is raised, and inset while latched. #[must_use] pub const fn depth(self, latched: bool) -> Depth { match self { Self::Badge => Depth::Flat, Self::Chip { .. } if latched => Depth::Well, Self::Chip { .. } => Depth::Raised, } } } /// Something the app is telling the user, unprompted. /// /// Two concepts, not one with a placement. They differ in more than where they /// sit: a toast is transient, stacked and self-dismissing, and a banner is /// persistent, in flow, one per region, and dismissed by fixing the condition /// it reports. Folding them into one member with a placement parameter would /// make lifetime, stacking and dismissal all placement-dependent, which is the /// description leaking renderer policy. /// /// All three apps have banners: `info_banner` and `warning_banner` in /// audiofiles, five of them in goingson (sync, sync-result, vacation-day, /// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two /// webview apps also have toasts. So neither member is speculative, and no app /// gains a concept it lacks except audiofiles, whose renderer may legitimately /// decline to draw a toast at all. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Notice { /// Transient, stacked, dismisses itself. Toast, /// Persistent, in flow, one per region, dismissed by fixing the cause. Banner, } impl Notice { /// Whether it goes away on its own. #[must_use] pub const fn transient(self) -> bool { matches!(self, Self::Toast) } /// How it sits. /// /// A toast floats above the page rather than resting on it, which is /// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the /// flow. Both are raised, and they are raised off different things. #[must_use] pub const fn fill(self) -> Fill { match self { Self::Toast => Fill::Overlay, Self::Banner => Fill::Raised, } } } /// The parts of a list row. /// /// Four to begin with, taken from Balanced Breakfast, which was the only /// consumer that had all of them (`row-primary`, `row-secondary`, `row-meta`, /// `row-actions`). audiofiles has two and no slot structure at all, so it gains /// meta and actions as real work rather than a rename; goingson moves off /// `task-row` / `task-cell`. /// /// [`Tokens`](Self::Tokens) joined at 0.9.0, and `#[non_exhaustive]` with it. /// See the crate header for why the two arrived together. /// /// # Meta against Tokens /// /// The line is whether the thing has its own standing. `Meta` is one short /// trailing fact about the row, written as text: a count, a size, a date. /// `Tokens` is a set of small labelled things, each of which can be toned and /// can answer a click. "3 files" is meta. A status badge that is amber, and a /// tag you can click to filter by, are tokens. /// /// Keeping them apart is what a single widened slot would have foreclosed. A /// renderer can right-align one string and cannot usefully do the same to a /// strip of chips, and a fact that is not clickable should not be drawn as /// though it were. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum RowPart { /// The thing itself. What the row is called. Primary, /// Supporting text under the primary. Secondary, /// A short trailing fact: a count, a size, a date. Meta, /// Controls that act on this row. Actions, /// Small labelled things belonging to the row: badges, chips, tags. /// /// Each carries its own [`Token`] kind and [`Tone`], so a renderer with no /// colour still has the kind to work with, and one with no chips still has /// the label. That is the constrained-consumer test this vocabulary exists /// to pass, and it is why the tone lives on the token rather than on the /// part. Tokens, /// How much of a set the row's thing has done: a [`Meter`] in the row. /// /// Added 0.11.0, `da5666ae`, and it is [`Tokens`](Self::Tokens)'s problem /// again with a different payload. [`Meter`] arrived at 0.10.0 and closed /// two of the seven sites that asked for it; the other five sit in rows, and /// a row holds no nodes by the ruling that a row part may not carry an /// arbitrary node — the door through which a description becomes a /// templating language. So the part carries the *description of a bar* /// rather than a node, exactly as `Tokens` carries tags rather than nodes. /// /// Without it a row flattens the proportion into [`Meta`](Self::Meta) as /// "3/7 subtasks", which keeps both numbers and loses the reading, the same /// way a toned status badge read as prose before `Tokens`. Proportion, } impl RowPart { /// Whether the part stays hidden until the row is hovered or focused. /// /// Behaviour of the part, not app policy: Balanced Breakfast and goingson /// grew the same hover-reveal on their actions independently and neither /// applies it to anything else. /// /// A renderer with no hover shows it always. That is a renderer decision /// and this returning `true` does not forbid it. #[must_use] pub const fn revealed_on_hover(self) -> bool { matches!(self, Self::Actions) } /// The content intent the part takes. #[must_use] pub const fn intent(self) -> &'static str { match self { Self::Primary => "content", Self::Secondary => "content-secondary", Self::Meta => "content-muted", // Actions carry controls rather than text, so they inherit. Self::Actions => "content", // So do tokens: each one carries its own tone, and a part-level // intent underneath it would fight the token that sits on it. Self::Tokens => "content", // And so does a proportion, for the same reason: the meter carries // the tone, and it is about the ratio rather than about the row. Self::Proportion => "content", } } } /// How far down the heading tree a title sits. /// /// Three, and only the three that are actually headings. The bands those used /// to be filed with (goingson's `.page-header`, Balanced Breakfast's `.header` /// and `.detail-header`) are arrangement, not type, and live at /// [`Region::Band`]. One of them contains no text at all. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Heading { /// Names the whole screen. One per screen. Page, /// Names a block within the screen. Section, /// Names a sub-block inside an already-named section. Subsection, } impl Heading { /// Whether a rule follows the heading. /// /// audiofiles' `section_header` draws a separator and its /// `subsection_label` deliberately does not, which is the only thing /// distinguishing the two once weight and colour are deferred. #[must_use] pub const fn separated(self) -> bool { matches!(self, Self::Section) } } /// A control that picks between things. /// /// Three, because three distinct behaviours are in play and collapsing any two /// loses something. A segmented control picks a value; a tab picks a pane; a /// toggle picks nothing and simply holds itself on or off. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Selector { /// Exactly one of N, and the options abut. Segmented, /// Independent on or off, on its own. Toggle, /// Navigation between panes. The folder semantic. Tabs, } impl Selector { /// How the chosen option sits. /// /// Held in for a segmented control and a toggle, which is the same shape /// pressing produces and the whole economy of the idiom: one appearance, /// two reasons to wear it. A tab is the exception, because the selected /// folder tab comes *forward* to join the pane it opens. #[must_use] pub const fn chosen(self) -> Depth { match self { Self::Segmented | Self::Toggle => Depth::Well, Self::Tabs => Depth::Raised, } } /// How the options that were *not* picked sit. /// /// Added 0.3.0. Describing only [`Selector::chosen`] left the unchosen /// option falling through to [`Depth::Flat`], which says it is level with /// the strip it sits in, and no renderer emitted anything for it. That is /// wrong in both directions and goingson proved it: its unchosen tabs are /// recessed by hand, and being recessed is *why* the chosen one reads as /// coming forward. Against a flat strip, a raised chosen tab is a bevel /// drawn on the strip's own colour, which is a much weaker folder effect /// than the contrast the idiom is named after. /// /// Each member is the inverse of its chosen state, which is the whole /// content of "picked" once colour is deferred: /// /// - Tabs recede, so the chosen one comes forward. /// - A segment and a toggle stand up, so the chosen one is held in. #[must_use] pub const fn unchosen(self) -> Depth { match self { Self::Tabs => Depth::Sunken, Self::Segmented | Self::Toggle => Depth::Raised, } } /// Whether the options touch. /// /// The gap is the entire difference between a segmented control and a row /// of buttons that happen to sit near each other, which is what audiofiles' /// `segmented_control` says in its own comment and why it zeroes the /// spacing by hand. #[must_use] pub const fn abutting(self) -> bool { matches!(self, Self::Segmented | Self::Tabs) } } /// Whether the content of a region has arrived. /// /// The state, not the shimmer. Whether pending paints a skeleton, a spinner or /// nothing at all is renderer policy, the same class of decision that got /// `Fill::fallback` deleted from this crate. goingson and Balanced Breakfast /// each grew a skeleton with differently-named parts; both keep them, as the /// webview renderer's expression of [`Readiness::Pending`]. audiofiles has none /// and needs none, because an immediate-mode renderer simply repaints. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Readiness { /// The content is here. Ready, /// The content is on its way. Pending, } /// How much of a set is done. /// /// Added 0.10.0. Nine sites across the two webview apps drew a bar and nothing /// here named one, so every described screen concatenated the two numbers into /// its heading text instead: "Subtasks 3/7", "Time Tracking 45m tracked / 30m /// est, over". Every fact survives that and the reading does not, which is the /// same loss `RowPart::Tokens` closed when a toned status badge became prose. /// /// # Why a pair and not a percentage /// /// Both numbers, not the percentage the apps compute from them. The percentage /// was the obvious shape and it had already been tried: goingson's /// `Task::time_progress` divides, rounds, and then clamps to 100, which throws /// away the one case the bar exists to show — 45 minutes tracked against a /// 30-minute estimate. It carries a separate `is_over_estimate` boolean beside /// it to recover the fact the clamp dropped. A pair keeps the over-run without a /// companion flag, and [`percent`](Meter::percent) is still one call away for a /// renderer that wants it. /// /// The pair is also what the apps already have at every site. All seven /// determinate bars write the ratio into the accessible layer and never the /// percentage: `title="3/7 subtasks"`, `aria-label="3 of 7 subtasks completed"`, /// a milestone's own `3/7` span. Given 43 nothing can recover "3 of 7", so a /// percentage member would have made [`label`](Meter::label) mandatory at every /// call site, which is the concatenated text this member removes, moved one /// layer down. /// /// # What this is not /// /// The progress of an *operation*. Two of the nine sites are that — goingson's /// focus timer, Balanced Breakfast's feed fetch — and they get nothing here, on /// purpose. Both are imperative controllers over a live handle, driven by a tick /// or an event stream, and a description is built once and dropped. Holding one /// would mean growing a way to update a description between renders, which is a /// different feature. [`Readiness::Pending`] and a [`Notice::Toast`] carry the /// honest part. /// /// The two cases are distinguishable in the markup rather than by taste: every /// determinate bar in both apps carries a tone, and neither operation bar /// carries one. Two codebases drew that line the same way without coordinating. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Meter<'a> { /// How much is done. May exceed [`total`](Self::total), and that is the /// case worth drawing. pub done: u32, /// How much there is to do. Zero means there is no set, not that the set is /// complete. pub total: u32, /// What the proportion means right now. /// /// Carried rather than derived, because no renderer can work it out. The /// same 90% is [`Tone::Success`] on a subtask rollup and [`Tone::Danger`] on /// a time estimate, and goingson picks between them from `is_over_estimate`, /// a fact about the data and not about the number. pub tone: Tone, /// What is being counted, if the bar says so: "subtasks", "tasks". /// /// The noun, not the ratio. A renderer builds "3 of 7 subtasks" from this /// and the two numbers; handing it the assembled string would put the /// sentence order in the description, where a terminal at one line and a /// tooltip want different ones. pub label: Option<&'a str>, } impl<'a> Meter<'a> { /// A proportion with no tone and no label. #[must_use] pub const fn new(done: u32, total: u32) -> Self { Self { done, total, tone: Tone::Neutral, label: None, } } /// What the proportion means. #[must_use] pub const fn tone(mut self, tone: Tone) -> Self { self.tone = tone; self } /// What is being counted. #[must_use] pub const fn label(mut self, label: &'a str) -> Self { self.label = Some(label); self } /// How full the bar is, 0 to 100, clamped. /// /// For drawing, which is the only thing a clamped number is good for. Ask /// [`overflowing`](Self::overflowing) before reporting it as a fact, or this /// is `time_progress`'s bug again with the clamp moved. /// /// An empty set reads as 0. Nothing is done, because there is nothing to do /// and no bar to fill; the apps guard on the count before drawing at all. #[must_use] pub const fn percent(&self) -> u8 { if self.total == 0 { return 0; } let scaled = (self.done as u64 * 100) / self.total as u64; if scaled > 100 { 100 } else { scaled as u8 } } /// Whether more is done than there was to do. /// /// The fact [`percent`](Self::percent) destroys, kept reachable so a /// renderer can mark the over-run rather than drawing a full bar and /// implying it landed exactly. #[must_use] pub const fn overflowing(&self) -> bool { self.done > self.total } /// Whether there is a set at all. /// /// A meter over nothing is sayable on purpose, for the same reason a field /// with no options is: it is what an app with an unloaded count actually /// has, and a renderer that shows an empty bar says so on screen rather than /// dividing by zero. #[must_use] pub const fn is_empty(&self) -> bool { self.total == 0 } } /// One figure with a caption: a number and what it counts. /// /// The dashboard shape. A large value over a small caption, several of them in a /// strip: a current streak, a completion rate, a total. Added 0.11.0, /// `93c6a174`, after goingson turned out to have five of them across five /// screens with five class vocabularies for the one shape — `task-overview-stat`, /// `stat-box`, `month-stat-item`, `contact-summary-stat`, `sync-stat`. Four put /// the value above the caption and one inverts it, which is drift inside the /// shape rather than a second shape. /// /// # Why the value is text /// /// "17", "84%", "12/30", "3d". A figure is whatever the app computed, already /// formatted, and the formatting is the app's because only it knows whether the /// number is a percentage, a duration or a ratio. This carries none of the /// arithmetic [`Meter`] carries, and that is the difference between them: a /// meter is a proportion a renderer draws, and a figure is a fact a renderer /// sets in type. /// /// # Tone is carried, for [`Meter`]'s reason /// /// Three of the five sites tone the figure by their own means — `red`/`blue` on /// the weekly review, a `${type}` class on the monthly one, `sync-stat-warn` on /// sync. So tone is carried at every site that needs it and derived at none, and /// no renderer can work out that a streak of zero is worth colouring. /// /// # What is not here /// /// Whether the figure answers a click. One of the five is a control — sync's /// "Not Applied: 3" opens the list — and an action is not something this crate /// can name: nothing here knows what a route is. That belongs beside the figure /// in whatever layer holds the actions, the same way a row's activation sits /// beside its parts rather than inside them. /// /// The arrangement is not here either. Several figures in a strip is a set, and /// a renderer given them one at a time cannot tell it is looking at one; the /// layer that holds the tree is where the set gets said. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Figure<'a> { /// The number, formatted the way the app means it to read. pub value: &'a str, /// What it counts. The caption under the value. pub caption: &'a str, /// What the figure means right now. [`Tone::Neutral`] is an ordinary fact. pub tone: Tone, } impl<'a> Figure<'a> { /// A figure that is an ordinary fact. #[must_use] pub const fn new(value: &'a str, caption: &'a str) -> Self { Self { value, caption, tone: Tone::Neutral, } } /// What the figure means. #[must_use] pub const fn tone(mut self, tone: Tone) -> Self { self.tone = tone; self } } /// A named part of a screen. /// /// The thing `makeover-geometry` deliberately does not name: it names the space /// *between* things by relationship, and nothing named the things. Six named /// members, taken from what the two webview apps actually use, plus /// [`Region::Bespoke`] for the parts no description should reach. Both apps' /// `layout.css` currently names exactly two things, `.raised` and `.well`, so /// this layer is absent rather than divergent, which makes it the cheapest of /// the schemas to add and the easiest to over-build. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Region<'a> { /// A full-width strip with a title slot and an actions cluster, either of /// which may be empty. goingson's `.page-header`, Balanced Breakfast's /// `.header` and `.detail-header` are all this, differing only in which /// slots they fill. Band, /// A persistent column beside the content, holding navigation. Sidebar, /// A region of content with its own scroll. Pane, /// Two panes side by side, where the left chooses what the right shows. Split, /// A set of panes, one visible at a time, with a [`Selector::Tabs`] above. TabGroup, /// Content over a scrim, taking input until dismissed. Modal, /// A region this crate names the *place* of and nothing else. The app owns /// what goes in it. /// /// The escape hatch, and the thing that keeps the description honest about /// its own limits. A day-plan timeline, a kanban board, a calendar and the /// paint interaction over the timeline are not describable here and are not /// going to become describable: a description expressive enough to produce /// a timeline is a widget library wearing a description's name. /// /// But a screen containing one still has to be a screen. Without this /// member the description covers only the boring screens, and the four that /// make goingson worth using would need a second, undescribed path beside /// the router. Two paths is how the vocabulary starts drifting from the app /// again, which is the exact failure this crate exists to end. /// /// So the description says "a thing called `day-plan` goes here" and stops. /// The name is opaque: this crate never interprets it, and no renderer is /// expected to know what it means beyond handing the space over. Bespoke { /// What the app calls it. Never interpreted here. name: &'a str, }, } impl Region<'_> { /// How the region sits on what is behind it. #[must_use] pub const fn depth(self) -> Depth { match self { Self::Band | Self::Sidebar | Self::Split | Self::TabGroup => Depth::Flat, // A pane is looked into, the same as a table body or a tag tree. Self::Pane => Depth::Well, Self::Modal => Depth::Raised, // Flat because it inherits: a bespoke region takes the depth of // whatever frames it. An app that wants its timeline in a well puts // it in a `Pane`, which composes rather than adding a knob here. Self::Bespoke { .. } => Depth::Flat, } } /// Whether this crate can say anything about the region's contents. /// /// A renderer walks the description and hands every region it understands /// to the right drawing code. This is how it tells the two apart, and the /// reason it is a method rather than a `matches!` at each renderer: there /// is exactly one opaque member and there should stay exactly one. #[must_use] pub const fn described(self) -> bool { !matches!(self, Self::Bespoke { .. }) } } /// How a screen is laid out. /// /// Two, and the second is not a variant of the first. goingson is list-detail, /// Balanced Breakfast is sidebar plus content, and neither app has a third. /// The tab group is a modifier rather than a member, because goingson uses it /// *inside* the same content region rather than instead of one. /// /// This exists at all because the router has to be able to express a screen /// rather than only a control. Discovering the arrangement layer missing after /// the renderers exist is a redesign; naming two now is a morning. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Arrangement { /// A list that chooses what the detail beside it shows. ListDetail { /// Whether the detail side is a [`Region::TabGroup`]. tabbed: bool, }, /// Navigation down the side, content filling the rest. SidebarContent, } /// What kind of value a form field takes. /// /// The union of the two vocabularies that diverged, which is what triggered /// this crate. They have since converged on their own: both apps now have a /// `renderFormField` emitting the same anatomy, and what is left differing is /// the kind set, the error shape, and whether the return is a string or a node. /// /// Validation is deliberately absent. Neither app has a shared story (goingson /// validates after collecting the form data, with per-field transform hooks; /// Balanced Breakfast has `required` and nothing else), and a schema that /// describes fields but not constraints acquires a constraint layer per app, /// which is exactly how the current divergence started. Naming it absent is a /// decision; leaving it unmentioned would not be. /// `#[non_exhaustive]` for the reason [`Fill`] is: renderers match on this and /// the set keeps growing, so growth must not be a lockstep event. Email, Url /// and Tel arriving in 0.5.0 is the second growth in two releases. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum FieldKind { /// A single line of text. Text, /// A single line of text that must never be echoed, logged or round-tripped /// through anything that might persist it. Secret, /// A number. Number, /// An email address. /// /// Distinct from [`Text`](Self::Text) because the distinction is not /// decoration: a webview renderer emits `type="email"`, which on a touch /// device changes the keyboard that appears and turns on the platform's own /// validation. goingson ships to iOS, so collapsing this into text costs a /// keyboard with no `@` on it. /// /// Added 0.5.0, from goingson's contact form. Email, /// A URL. Same reasoning as [`Email`](Self::Email). /// /// Added 0.5.0, from goingson's contact-social and contact-feed forms. Url, /// A telephone number. Same reasoning as [`Email`](Self::Email), and the /// clearest case of it: the keyboard is a numeric pad rather than letters. /// /// Added 0.5.0, from goingson's contact-phone form. Tel, /// Several lines of text. Textarea, /// One of a fixed set, offered behind a control that shows one at a time. Select, /// One of a fixed set, with every option on screen at once. /// /// Not a presentation of [`Select`](Self::Select), which is the reading to /// resist: what differs is a property of the *question*. A choice that is /// consequential or irreversible has to be readable without opening /// anything, because a closed control shows one option and hides the rest, /// and the one it shows is whichever was current before the user had read /// the alternatives. audiofiles asks whether a library copies samples into /// its store or references them where they lie — which cannot be changed /// afterwards — and had already promoted that out of a checkbox by hand, /// with a comment giving this reason, before the description could say it. /// /// It is also the one HTML input type this enum was missing. Everything /// else here is an ``, a `