Skip to main content

max / makeover-layout

19.0 KB · 465 lines History Blame Raw
1 use crate::{Depth, Fill, Intent, Priority};
2
3 // Names this module's prose links to, resolved for rustdoc.
4 #[allow(unused_imports)]
5 use crate::{Awaiting, Meter, Region};
6
7 /// What a region is saying, when it is saying something.
8 ///
9 /// The one intent family shared by badges, notices and nothing else. Kept
10 /// separate from [`Fill`] because a surface is where a thing sits and a tone is
11 /// what it means, and the three apps agree on the four statuses:
12 /// `info_banner` / `warning_banner` in audiofiles, `.toast-info` /
13 /// `.toast-success` / `.toast-error` in goingson, `.toast.success` /
14 /// `.toast.error` in Balanced Breakfast.
15 ///
16 /// The per-tag palette (`category-one` through `category-six`) is deliberately
17 /// not here. Which colour a *particular* tag takes is app domain, and both
18 /// webview apps already carry it as a `data-color` attribute.
19 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20 pub enum Tone {
21 /// No status.
22 ///
23 /// Ordinary content, at full weight. It does not also mean muted: a
24 /// badge reads quiet because [`Token::Badge`] answers no click, which is
25 /// the renderer's knowledge and not this axis's. A renderer wanting a
26 /// muted badge reaches for [`Token::interactive`] itself rather than
27 /// expecting `Neutral` to have muted it.
28 Neutral,
29 /// Something worth knowing and nothing to do about it.
30 Info,
31 /// Something finished and it worked.
32 Success,
33 /// Something the user should look at before continuing.
34 Warning,
35 /// Something broken, or something about to be destroyed.
36 Danger,
37 }
38
39 impl Intent for Tone {
40 fn token(self) -> &'static str {
41 match self {
42 // Neutral has no status token of its own, so it takes the plain
43 // content intent. It used to answer `content-muted`, which read
44 // "no status" as "de-emphasised" and muted every figure value in
45 // the webview. Muting is a renderer's call about a particular
46 // token, not something the status axis knows.
47 Self::Neutral => "content",
48 Self::Info => "info",
49 Self::Success => "success",
50 Self::Warning => "warning",
51 Self::Danger => "danger",
52 }
53 }
54 }
55
56 /// A small labelled thing that sits inside something else.
57 ///
58 /// Two members, because the three apps drew three taxonomies and only one line
59 /// runs through all of them: does it answer a click. audiofiles has
60 /// `classification_badge` (a label) against `tag_chip`, `tag_chip_removable`
61 /// and `selectable_tag` (all of which do). Balanced Breakfast has `.tag` and
62 /// `.badge` against `.tag-chip`. goingson is the one that has to move: its
63 /// `.tag` and `.badge` are a single CSS rule, so every call site has to be read
64 /// to decide which of the two it always was.
65 ///
66 /// The evidence that a chip is a real concept rather than a badge with a
67 /// cursor: audiofiles inverts its bevel on press and Balanced Breakfast latches
68 /// `.tag-chip.active` with the inset bevel. Two independent arrivals at "a chip
69 /// holds itself down", which is exactly what [`Depth::pressed`] already says.
70 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71 pub enum Token {
72 /// Non-interactive status or count. Answers no click.
73 Badge,
74 /// An interactive or removable token. Answers a click, and latches if it
75 /// stands for a filter that is either on or off.
76 Chip {
77 /// Whether it carries its own remove affordance.
78 removable: bool,
79 },
80 }
81
82 impl Token {
83 /// Whether this answers a click.
84 ///
85 /// The whole difference between the two members, and the reason a renderer
86 /// with no hover (a touch surface, a terminal) can still tell them apart.
87 #[must_use]
88 pub const fn interactive(self) -> bool {
89 matches!(self, Self::Chip { .. })
90 }
91
92 /// How it sits, given whether it is currently latched down.
93 ///
94 /// A badge is flat: it is a label, and giving it an edge would say it can
95 /// be pressed. A chip is raised, and inset while latched.
96 #[must_use]
97 pub const fn depth(self, latched: bool) -> Depth {
98 match self {
99 Self::Badge => Depth::Flat,
100 Self::Chip { .. } if latched => Depth::Well,
101 Self::Chip { .. } => Depth::Raised,
102 }
103 }
104 }
105
106 /// Something the app is telling the user, unprompted.
107 ///
108 /// Two concepts, not one with a placement. They differ in more than where they
109 /// sit: a toast is transient, stacked and self-dismissing, and a banner is
110 /// persistent, in flow, one per region, and dismissed by fixing the condition
111 /// it reports. Folding them into one member with a placement parameter would
112 /// make lifetime, stacking and dismissal all placement-dependent, which is the
113 /// description leaking renderer policy.
114 ///
115 /// All three apps have banners: `info_banner` and `warning_banner` in
116 /// audiofiles, five of them in goingson (sync, sync-result, vacation-day,
117 /// timer-active, past-review), `.update-banner` in Balanced Breakfast. The two
118 /// webview apps also have toasts. So neither member is speculative, and no app
119 /// gains a concept it lacks except audiofiles, whose renderer may legitimately
120 /// decline to draw a toast at all.
121 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
122 pub enum Notice {
123 /// Transient, stacked, dismisses itself.
124 Toast,
125 /// Persistent, in flow, one per region, dismissed by fixing the cause.
126 Banner,
127 }
128
129 impl Notice {
130 /// Whether it goes away on its own.
131 #[must_use]
132 pub const fn transient(self) -> bool {
133 matches!(self, Self::Toast)
134 }
135
136 /// How it sits.
137 ///
138 /// A toast floats above the page rather than resting on it, which is
139 /// [`Fill::Overlay`]'s whole reason to exist. A banner is a card in the
140 /// flow. Both are raised, and they are raised off different things.
141 #[must_use]
142 pub const fn fill(self) -> Fill {
143 match self {
144 Self::Toast => Fill::Overlay,
145 Self::Banner => Fill::Raised,
146 }
147 }
148 }
149
150 /// The parts of a list row.
151 ///
152 /// `#[non_exhaustive]`: a renderer carries a wildcard arm, so a new part is not
153 /// a lockstep event across three renderers.
154 ///
155 /// # Meta against Tokens
156 ///
157 /// The line is whether the thing has its own standing. `Meta` is one short
158 /// trailing fact about the row, written as text: a count, a size, a date.
159 /// `Tokens` is a set of small labelled things, each of which can be toned and
160 /// can answer a click. "3 files" is meta. A status badge that is amber, and a
161 /// tag you can click to filter by, are tokens.
162 ///
163 /// Keeping them apart is what a single widened slot would have foreclosed. A
164 /// renderer can right-align one string and cannot usefully do the same to a
165 /// strip of chips, and a fact that is not clickable should not be drawn as
166 /// though it were.
167 /// How much vertical room a part's text may take.
168 ///
169 /// A row is an inline run and every part in it is a leaf, so a part's text has
170 /// always been drawn on one line and no description could say otherwise. Two
171 /// apps say otherwise in their own stylesheets, both to the same number and
172 /// both with a comment explaining it: Balanced Breakfast clamps a feed row's
173 /// title to two lines (`.row--article .row-primary`, whose comment reads
174 /// "overrides .row-primary's single flex line"), and goingson clamps a
175 /// problem's body to two ("two lines is enough to recognize one, and the full
176 /// text is in the task once promoted").
177 ///
178 /// Two named tiers rather than a line count, and the count is what the measured
179 /// demand argues against. Both sites want exactly one tier past the default,
180 /// and a number invites a row whose primary is a paragraph, which is a block
181 /// and has no business in a run. A third tier is a decision, made here, rather
182 /// than something a call site can reach for.
183 ///
184 /// What a renderer owes it: `Tight` is what a run already does and needs no
185 /// answer. `Relaxed` is at most two lines and then truncation, however that
186 /// renderer truncates -- a webview clamps, a terminal wraps into two rows of
187 /// cells, an immediate-mode renderer caps the galley. A renderer that cannot
188 /// give two lines may draw one; what it may not do is grow without bound,
189 /// because the run is a line and the row's neighbours are relying on that.
190 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
191 #[non_exhaustive]
192 pub enum Flow {
193 /// One line. What every part did before this type existed.
194 #[default]
195 Tight,
196 /// Up to two lines, then truncated.
197 Relaxed,
198 }
199
200 impl Flow {
201 /// How many lines the part may take.
202 ///
203 /// A number here rather than in the enum, because a renderer needs one and
204 /// a call site does not. That asymmetry is the whole argument for the
205 /// tiers: the description says how much room the thing deserves and this
206 /// says what that costs, so a third tier changes one line rather than every
207 /// consumer's arithmetic.
208 #[must_use]
209 pub const fn lines(self) -> u8 {
210 match self {
211 Self::Relaxed => 2,
212 // Including any tier added later: one line is the safe reading of
213 // an unknown flow, since it is what the run guaranteed before flows
214 // existed.
215 _ => 1,
216 }
217 }
218 }
219
220 /// How deep a row sits inside a set: a tree, an outline, a threaded list.
221 ///
222 /// [`RowPart`] below already names what is *in* a row; nothing named where a
223 /// row sits relative to its siblings, so every consumer that had a hierarchy
224 /// carried a bare number and every renderer decided for itself what one was
225 /// worth.
226 ///
227 /// # Not `Depth`, and the collision is the reason
228 ///
229 /// [`Depth`] is taken and means something else entirely: surface bevel --
230 /// `Flat`, `Raised`, `Well`, `Sunken`, `Overlay` -- a fact about a surface
231 /// rather than a position in a hierarchy. Two meanings under one word in one
232 /// crate is the collision that costs a reader an hour, and the word this
233 /// concept wants is the one that would cause it.
234 ///
235 /// # The magnitude is the renderer's, and that is the precedent
236 ///
237 /// [`Awaiting`] is the shape: this crate names the fact and declines to name
238 /// what it is worth. makeover-webview writes the rule as a custom property with
239 /// a fallback -- the way it writes `margin-inline-start: var(--awaiting-gap,
240 /// 0.5ch)` -- so a level has one answer per renderer and an app can override
241 /// it. A terminal spends columns, a browser spends inline space, and neither
242 /// number belongs in a description.
243 ///
244 /// # Zero is a real answer
245 ///
246 /// [`top`](Self::top) is the default and is what a flat list says: every row is
247 /// at the top level, which is true and is the reading a renderer needs. An
248 /// `Option` here would make "not nested" and "nested at zero" two spellings of
249 /// one thing, the same argument `Discovery`'s `indexable` makes about defaults
250 /// that are meaningful.
251 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
252 #[non_exhaustive]
253 pub struct Nesting {
254 /// How many levels in, counting from zero.
255 ///
256 /// `u8` because a hierarchy a reader can follow is not 256 deep, and a
257 /// renderer indenting by a level has to multiply it by something -- a wider
258 /// integer here is a wider integer in every renderer's arithmetic for a
259 /// range nothing will use.
260 pub level: u8,
261 }
262
263 impl Nesting {
264 /// The top level: not nested. The default, and what a flat list says.
265 #[must_use]
266 pub const fn top() -> Self {
267 Self { level: 0 }
268 }
269
270 /// A row this many levels in.
271 #[must_use]
272 pub const fn at(level: u8) -> Self {
273 Self { level }
274 }
275
276 /// Whether this row sits under another.
277 ///
278 /// The question every renderer asks before it spends anything on indenting,
279 /// answered once here rather than by a `> 0` in each -- which is
280 /// [`Awaiting::is_determinate`]'s reason too.
281 #[must_use]
282 pub const fn is_nested(self) -> bool {
283 self.level > 0
284 }
285 }
286
287 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
288 #[non_exhaustive]
289 pub enum RowPart {
290 /// The thing itself. What the row is called.
291 Primary,
292 /// Supporting text under the primary.
293 Secondary,
294 /// A short trailing fact: a count, a size, a date.
295 Meta,
296 /// Controls that act on this row.
297 Actions,
298 /// Small labelled things belonging to the row: badges, chips, tags.
299 ///
300 /// Each carries its own [`Token`] kind and [`Tone`], so a renderer with no
301 /// colour still has the kind to work with, and one with no chips still has
302 /// the label. That is the constrained-consumer test this vocabulary exists
303 /// to pass, and it is why the tone lives on the token rather than on the
304 /// part.
305 Tokens,
306 /// How much of a set the row's thing has done: a [`Meter`] in the row.
307 ///
308 /// A row holds no nodes, by the rule that a row part may not carry an
309 /// arbitrary node, which is the door through which a description becomes a
310 /// templating language. So the part carries the *description of a bar* rather than a node, exactly as
311 /// `Tokens` carries tags rather than nodes.
312 ///
313 /// Without it a row flattens the proportion into [`Meta`](Self::Meta) as
314 /// "3/7 subtasks", which keeps both numbers and loses the reading, the same
315 /// way a toned status badge read as prose before `Tokens`.
316 Proportion,
317 }
318
319 impl RowPart {
320 /// What the part is worth when the run does not fit.
321 ///
322 /// The default only. A part may say otherwise, and a renderer reads the
323 /// part rather than the role; this is what a description that has never
324 /// heard of [`Priority`] means, which is every description written before
325 /// the field existed.
326 ///
327 /// Deriving it from the role is the thing this vocabulary has otherwise
328 /// been moving away from, and it is right here for one reason: the roles
329 /// already encode this ranking and every consumer already assumes it.
330 /// [`Primary`](Self::Primary) is what the row is called, and
331 /// [`Priority::Essential`]'s own doc was written about exactly that --
332 /// "without it the row does not identify itself".
333 ///
334 /// [`Actions`](Self::Actions) is `Essential` and it is the interesting one.
335 /// A control is not a fact, so dropping it does not cost the reader a
336 /// detail; it costs them the only way to act on the row, and in a terminal
337 /// it silently removes something focus had already been claimed for. A
338 /// renderer that needs room takes it from what the row *says*, never from
339 /// what it *offers*.
340 ///
341 /// An unknown member reads as [`Priority::Secondary`]: droppable, but not
342 /// first, since guessing `Optional` for something this crate has not been
343 /// taught would make a new member the first thing to vanish.
344 #[must_use]
345 pub const fn priority(self) -> Priority {
346 match self {
347 Self::Primary | Self::Actions => Priority::Essential,
348 Self::Meta | Self::Proportion => Priority::Optional,
349 _ => Priority::Secondary,
350 }
351 }
352
353 /// The content intent the part takes.
354 #[must_use]
355 pub const fn intent(self) -> &'static str {
356 match self {
357 Self::Primary => "content",
358 Self::Secondary => "content-secondary",
359 Self::Meta => "content-muted",
360 // Actions carry controls rather than text, so they inherit.
361 Self::Actions => "content",
362 // So do tokens: each one carries its own tone, and a part-level
363 // intent underneath it would fight the token that sits on it.
364 Self::Tokens => "content",
365 // And so does a proportion, for the same reason: the meter carries
366 // the tone, and it is about the ratio rather than about the row.
367 Self::Proportion => "content",
368 }
369 }
370 }
371
372 /// How far down the heading tree a title sits.
373 ///
374 /// Three, and only the three that are actually headings. The bands those used
375 /// to be filed with (goingson's `.page-header`, Balanced Breakfast's `.header`
376 /// and `.detail-header`) are arrangement, not type, and live at
377 /// [`Region::Band`]. One of them contains no text at all.
378 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
379 pub enum Heading {
380 /// Names the whole screen. One per screen.
381 Page,
382 /// Names a block within the screen.
383 Section,
384 /// Names a sub-block inside an already-named section.
385 Subsection,
386 }
387
388 impl Heading {
389 /// Whether a rule follows the heading.
390 ///
391 /// audiofiles' `section_header` draws a separator and its
392 /// `subsection_label` deliberately does not, which is the only thing
393 /// distinguishing the two once weight and colour are deferred.
394 #[must_use]
395 pub const fn separated(self) -> bool {
396 matches!(self, Self::Section)
397 }
398 }
399
400 /// A control that picks between things.
401 ///
402 /// Three, because three distinct behaviours are in play and collapsing any two
403 /// loses something. A segmented control picks a value; a tab picks a pane; a
404 /// toggle picks nothing and simply holds itself on or off.
405 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
406 pub enum Selector {
407 /// Exactly one of N, and the options abut.
408 Segmented,
409 /// Independent on or off, on its own.
410 Toggle,
411 /// Navigation between panes. The folder semantic.
412 Tabs,
413 }
414
415 impl Selector {
416 /// How the chosen option sits.
417 ///
418 /// Held in for a segmented control and a toggle, which is the same shape
419 /// pressing produces and the whole economy of the idiom: one appearance,
420 /// two reasons to wear it. A tab is the exception, because the selected
421 /// folder tab comes *forward* to join the pane it opens.
422 #[must_use]
423 pub const fn chosen(self) -> Depth {
424 match self {
425 Self::Segmented | Self::Toggle => Depth::Well,
426 Self::Tabs => Depth::Raised,
427 }
428 }
429
430 /// How the options that were *not* picked sit.
431 ///
432 /// Describing only [`Selector::chosen`] left the unchosen option falling
433 /// through to [`Depth::Flat`], which says it is level with the strip it
434 /// sits in, and no renderer emitted anything for it. That is wrong in both
435 /// directions and goingson proved it: its unchosen tabs are recessed by
436 /// hand, and being recessed is *why* the chosen one reads as coming
437 /// forward. Against a flat strip, a raised chosen tab is a bevel drawn on
438 /// the strip's own colour, which is a much weaker folder effect than the
439 /// contrast the idiom is named after.
440 ///
441 /// Each member is the inverse of its chosen state, which is the whole
442 /// content of "picked" once colour is deferred:
443 ///
444 /// - Tabs recede, so the chosen one comes forward.
445 /// - A segment and a toggle stand up, so the chosen one is held in.
446 #[must_use]
447 pub const fn unchosen(self) -> Depth {
448 match self {
449 Self::Tabs => Depth::Sunken,
450 Self::Segmented | Self::Toggle => Depth::Raised,
451 }
452 }
453
454 /// Whether the options touch.
455 ///
456 /// The gap is the entire difference between a segmented control and a row
457 /// of buttons that happen to sit near each other, which is what audiofiles'
458 /// `segmented_control` says in its own comment and why it zeroes the
459 /// spacing by hand.
460 #[must_use]
461 pub const fn abutting(self) -> bool {
462 matches!(self, Self::Segmented | Self::Tabs)
463 }
464 }
465