Skip to main content

max / quasi

Declare a notification kind, and the registry that holds them quasi-notifs, first half: what a notification says and what it is for, apart from how it reaches a person. The same three questions -- is this kind on, what are its knobs, when does it fire -- have to be answered on a webview, a terminal and egui, which is the argument every other quasi member rests on. A Kind carries a config key stem, a title and a one-line summary, a category, whether it ships on, and its knobs. A Knob's type and its default are one value rather than two members, so a knob cannot carry a default of the wrong type. Everything is constructible in a const, so an app's set is a static it points at rather than a builder it runs at startup: a registry that has to be built is one that can be built twice, differently, in two places. Off is the framework default, and the kinds goingson already fires declare themselves on so adopting this does not silently stop a notification somebody depends on today. Registry::check is everything a const cannot check -- duplicate ids, an id that cannot be a config key, a choice defaulting to something it does not offer -- and it is for a test rather than for startup, since a check that only fires in production ships broken. It generates nothing and delivers nothing. Those are the next two.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-18 01:54 UTC
Signed with PGP, not checked
Commit: 11aef1d93b3fdcb0840c203b34b34365c50064f8
Parent: 0278f03
4 files changed, +528 insertions, -8 deletions
M Cargo.lock +12 -8
@@ -3160,6 +3160,10 @@
3160 3160 "quasi-router",
3161 3161 ]
3162 3162
3163 + [[package]]
3164 + name = "quasi-notifs"
3165 + version = "0.26.0"
3166 +
3163 3167 [[package]]
3164 3168 name = "quasi-router"
3165 3169 version = "0.26.0"
@@ -5632,6 +5636,14 @@
5632 5636 source = "registry+https://github.com/rust-lang/crates.io-index"
5633 5637 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5634 5638
5639 + [[patch.unused]]
5640 + name = "synckit-client"
5641 + version = "0.8.0"
5642 +
5643 + [[patch.unused]]
5644 + name = "synckit-config"
5645 + version = "0.2.0"
5646 +
5635 5647 [[patch.unused]]
5636 5648 name = "kberg"
5637 5649 version = "0.1.0"
@@ -5651,11 +5663,3 @@
5651 5663 [[patch.unused]]
5652 5664 name = "quasi-type"
5653 5665 version = "0.1.0"
5654 -
5655 - [[patch.unused]]
5656 - name = "synckit-client"
5657 - version = "0.8.0"
5658 -
5659 - [[patch.unused]]
5660 - name = "synckit-config"
5661 - version = "0.2.0"
M Cargo.toml +1
@@ -11,6 +11,7 @@
11 11 "crates/quasi-tauri",
12 12 "crates/quasi-tui",
13 13 "crates/quasi-immediate",
14 + "crates/quasi-notifs",
14 15 "crates/quasi-webview",
15 16 ]
16 17 # The scaffolder's template is real Rust and real manifests rather than liquid,
@@ -1,0 +1,15 @@
1 + [package]
2 + name = "quasi-notifs"
3 + version = "0.26.0"
4 + description = "Declared notification kinds and the registry that holds them: what a notification says and what it is for, apart from how it reaches a person"
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + repository.workspace = true
9 + license.workspace = true
10 + publish = false
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
@@ -1,0 +1,502 @@
1 + //! What a notification says and what it is for, apart from how it reaches a
2 + //! person.
3 + //!
4 + //! The same three questions — is this kind on, what are its knobs, when does it
5 + //! fire — have to be answered on a webview, a terminal and egui. That is the
6 + //! argument every other quasi member rests on, and it is why this is a crate
7 + //! rather than a module inside one app: a notification is a described artifact,
8 + //! and the delivery is the renderer's business.
9 + //!
10 + //! # This crate is the declaration only
11 + //!
12 + //! A [`Kind`] and the [`Registry`] that holds them. It generates no
13 + //! configuration and delivers nothing; those are two separate pieces of work,
14 + //! and keeping them apart is what stops the declaration from growing a
15 + //! dependency on a host.
16 + //!
17 + //! # Const-friendly on purpose
18 + //!
19 + //! Every type here is constructible in a `const`, so an app's notification set
20 + //! is a `static` it can point at rather than a builder it has to run at
21 + //! startup. A registry that has to be built is a registry that can be built
22 + //! twice, differently, in two places.
23 + //!
24 + //! ```
25 + //! use quasi_notifs::{Kind, Knob, Registry, Setting};
26 + //!
27 + //! static KINDS: &[Kind] = &[
28 + //! Kind::new("snooze-expiry", "Snoozed items resurface", "When something you snoozed comes back.", "Reminders")
29 + //! .shipping_on(),
30 + //! Kind::new("event-reminder", "Event reminders", "Before an event starts.", "Reminders")
31 + //! .shipping_on()
32 + //! .with(&[Knob::new("lead", "How long before", Setting::Seconds(900))]),
33 + //! ];
34 + //!
35 + //! static NOTIFS: Registry = Registry::new(KINDS);
36 + //! assert!(NOTIFS.check().is_ok());
37 + //! ```
38 +
39 + /// Whether a kind fires for someone who has never touched its settings.
40 + ///
41 + /// The framework default is [`Off`](Self::Off), decided 2026-08-17: a
42 + /// notification nobody asked for is an interruption nobody asked for, and
43 + /// onboarding is what points at the ones that ship quiet.
44 + ///
45 + /// A kind that already fires in a shipped app declares [`On`](Self::On), so
46 + /// adopting the framework does not silently stop a notification somebody
47 + /// depends on today. That is a migration fact rather than a preference, and it
48 + /// is one line per kind either way.
49 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50 + pub enum Posture {
51 + /// Fires unless the reader turns it off.
52 + On,
53 + /// Silent unless the reader turns it on.
54 + Off,
55 + }
56 +
57 + /// What a knob holds, and what it holds before anyone touches it.
58 + ///
59 + /// The type and the default are one value rather than two members, so a knob
60 + /// cannot carry a default of the wrong type. That is the same reason
61 + /// [`Kind::ships`] is a [`Posture`] and not a `bool` beside a comment.
62 + ///
63 + /// # What is deliberately not here
64 + ///
65 + /// A list-valued knob. goingson's event reminders take several lead times, and
66 + /// they are a property of the *event* (`event.reminder_offsets_seconds`) rather
67 + /// than of the kind, so no measured site wants one here. Adding a variant for a
68 + /// shape nothing has asked for is how a vocabulary drifts, and it is one
69 + /// variant whenever something does.
70 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71 + pub enum Setting {
72 + /// On or off, and which it starts as.
73 + Toggle(bool),
74 + /// A span, in seconds. A lead time.
75 + Seconds(i64),
76 + /// A plain number. A threshold, a count.
77 + Count(i64),
78 + /// One of a fixed set.
79 + ///
80 + /// `default` has to be one of `options`; [`Registry::check`] is what says
81 + /// so, because a `const` cannot.
82 + Choice {
83 + /// What may be chosen, in the order they are offered.
84 + options: &'static [&'static str],
85 + /// What is chosen before anyone chooses.
86 + default: &'static str,
87 + },
88 + }
89 +
90 + /// One granular knob belonging to one kind.
91 + ///
92 + /// The half of "automatically generating granular configuration" that carries
93 + /// the weight: a kind with no knobs generates a single on/off, and a kind with
94 + /// knobs generates a section.
95 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
96 + pub struct Knob {
97 + /// The config key stem, under the kind's own.
98 + ///
99 + /// Same shape rule as [`Kind::id`]: unprefixed, because the kind it belongs
100 + /// to is already the prefix.
101 + pub id: &'static str,
102 + /// What the settings pane calls it.
103 + pub label: &'static str,
104 + /// What it holds, and what it holds by default.
105 + pub value: Setting,
106 + }
107 +
108 + impl Knob {
109 + /// A knob of the given kind of value.
110 + #[must_use]
111 + pub const fn new(id: &'static str, label: &'static str, value: Setting) -> Self {
112 + Self { id, label, value }
113 + }
114 + }
115 +
116 + /// One kind of notification, declared once.
117 + ///
118 + /// A kind is not an instance. "Snoozed items resurface" is a kind; the
119 + /// notification about the one task that came back at 4pm is not, and nothing
120 + /// here describes it. What this carries is everything a settings pane, an
121 + /// onboarding pointer and a config file need, which is exactly the set that
122 + /// three renderers would otherwise each answer differently.
123 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124 + pub struct Kind {
125 + /// The stable name, and the config key stem.
126 + ///
127 + /// Unprefixed by the app: `snooze-expiry`, never `goingson-snooze-expiry`.
128 + /// The registry belongs to one app already, and a key that repeats the app
129 + /// name is a key that has to be rewritten when the app is renamed.
130 + /// [`Registry::check`] enforces the shape.
131 + pub id: &'static str,
132 + /// What the settings pane calls it.
133 + pub title: &'static str,
134 + /// One line saying what it is for.
135 + ///
136 + /// What the pane renders under the title, and what onboarding points at.
137 + /// One line because two is a paragraph nobody reads in a list of twenty.
138 + pub summary: &'static str,
139 + /// What it groups under in the generated pane.
140 + ///
141 + /// A plain string rather than an enum: the categories are the app's, and a
142 + /// closed set here would mean this crate deciding what kinds of
143 + /// notification exist.
144 + pub category: &'static str,
145 + /// Whether it fires for someone who has never touched it.
146 + pub ships: Posture,
147 + /// The knobs this kind has, in the order they are offered.
148 + pub options: &'static [Knob],
149 + }
150 +
151 + impl Kind {
152 + /// A kind that ships off and has no knobs.
153 + ///
154 + /// Off because that is the framework default and the thing a declaration
155 + /// should have to say out loud is the interruption, not the silence.
156 + #[must_use]
157 + pub const fn new(
158 + id: &'static str,
159 + title: &'static str,
160 + summary: &'static str,
161 + category: &'static str,
162 + ) -> Self {
163 + Self {
164 + id,
165 + title,
166 + summary,
167 + category,
168 + ships: Posture::Off,
169 + options: &[],
170 + }
171 + }
172 +
173 + /// This one fires unless it is turned off, chaining.
174 + #[must_use]
175 + pub const fn shipping_on(mut self) -> Self {
176 + self.ships = Posture::On;
177 + self
178 + }
179 +
180 + /// The knobs this kind offers, chaining.
181 + #[must_use]
182 + pub const fn with(mut self, options: &'static [Knob]) -> Self {
183 + self.options = options;
184 + self
185 + }
186 +
187 + /// The knob under this kind with the given id.
188 + #[must_use]
189 + pub fn knob(&self, id: &str) -> Option<&Knob> {
190 + self.options.iter().find(|knob| knob.id == id)
191 + }
192 + }
193 +
194 + /// An app's whole set of notification kinds.
195 + ///
196 + /// Declared once and handed to the host. One per app: a second registry is two
197 + /// answers to "what can this app notify me about", and the settings pane can
198 + /// only render one of them.
199 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
200 + pub struct Registry {
201 + kinds: &'static [Kind],
202 + }
203 +
204 + impl Registry {
205 + /// The registry holding these kinds.
206 + #[must_use]
207 + pub const fn new(kinds: &'static [Kind]) -> Self {
208 + Self { kinds }
209 + }
210 +
211 + /// Every kind, in declaration order.
212 + #[must_use]
213 + pub const fn kinds(&self) -> &'static [Kind] {
214 + self.kinds
215 + }
216 +
217 + /// The kind with the given id.
218 + #[must_use]
219 + pub fn kind(&self, id: &str) -> Option<&Kind> {
220 + self.kinds.iter().find(|kind| kind.id == id)
221 + }
222 +
223 + /// Every category present, in the order its first kind declared it.
224 + ///
225 + /// Declaration order rather than alphabetical, because the app grouped
226 + /// these deliberately and sorting would replace its judgment with the
227 + /// alphabet's.
228 + #[must_use]
229 + pub fn categories(&self) -> Vec<&'static str> {
230 + let mut seen: Vec<&'static str> = Vec::new();
231 + for kind in self.kinds {
232 + if !seen.contains(&kind.category) {
233 + seen.push(kind.category);
234 + }
235 + }
236 + seen
237 + }
238 +
239 + /// What is wrong with this registry, if anything is.
240 + ///
241 + /// Everything a `const` cannot check. Call it from a test rather than at
242 + /// startup: a registry is a compile-time constant, so a failure here is a
243 + /// failure of the source and not of the run, and a check that only fires in
244 + /// production is a check that ships broken.
245 + ///
246 + /// # Errors
247 + ///
248 + /// The first [`Fault`] found, in the order the kinds were declared.
249 + pub fn check(&self) -> Result<(), Fault> {
250 + let mut seen: Vec<&'static str> = Vec::new();
251 + for kind in self.kinds {
252 + if !is_key(kind.id) {
253 + return Err(Fault::BadId { id: kind.id });
254 + }
255 + if seen.contains(&kind.id) {
256 + return Err(Fault::TwoKinds { id: kind.id });
257 + }
258 + seen.push(kind.id);
259 +
260 + let mut knobs: Vec<&'static str> = Vec::new();
261 + for knob in kind.options {
262 + if !is_key(knob.id) {
263 + return Err(Fault::BadId { id: knob.id });
264 + }
265 + if knobs.contains(&knob.id) {
266 + return Err(Fault::TwoKnobs {
267 + kind: kind.id,
268 + id: knob.id,
269 + });
270 + }
271 + knobs.push(knob.id);
272 +
273 + if let Setting::Choice { options, default } = knob.value
274 + && !options.contains(&default)
275 + {
276 + return Err(Fault::DefaultNotOffered {
277 + kind: kind.id,
278 + id: knob.id,
279 + });
280 + }
281 + }
282 + }
283 + Ok(())
284 + }
285 + }
286 +
287 + /// Whether a string can be a config key stem.
288 + ///
289 + /// Lowercase, digits and hyphens. No dots, because a dot is what separates a
290 + /// kind's key from a knob's and an id containing one would make the generated
291 + /// key ambiguous about which is which.
292 + fn is_key(id: &str) -> bool {
293 + !id.is_empty()
294 + && id
295 + .chars()
296 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
297 + }
298 +
299 + /// Something wrong with a registry that a `const` could not catch.
300 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
301 + pub enum Fault {
302 + /// An id that cannot be a config key stem.
303 + BadId {
304 + /// The id as declared.
305 + id: &'static str,
306 + },
307 + /// Two kinds under one id, so their settings would share a key.
308 + TwoKinds {
309 + /// The repeated id.
310 + id: &'static str,
311 + },
312 + /// Two knobs under one id within a kind.
313 + TwoKnobs {
314 + /// The kind holding both.
315 + kind: &'static str,
316 + /// The repeated id.
317 + id: &'static str,
318 + },
319 + /// A [`Setting::Choice`] whose default is not one of its options.
320 + DefaultNotOffered {
321 + /// The kind holding the knob.
322 + kind: &'static str,
323 + /// The knob.
324 + id: &'static str,
325 + },
326 + }
327 +
328 + impl std::fmt::Display for Fault {
329 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 + match self {
331 + Self::BadId { id } => {
332 + write!(
333 + f,
334 + "`{id}` cannot be a config key: lowercase, digits and hyphens only"
335 + )
336 + }
337 + Self::TwoKinds { id } => write!(f, "two kinds are declared as `{id}`"),
338 + Self::TwoKnobs { kind, id } => {
339 + write!(f, "`{kind}` declares two knobs as `{id}`")
340 + }
341 + Self::DefaultNotOffered { kind, id } => {
342 + write!(f, "`{kind}.{id}` defaults to something it does not offer")
343 + }
344 + }
345 + }
346 + }
347 +
348 + impl std::error::Error for Fault {}
349 +
350 + #[cfg(test)]
351 + mod tests {
352 + use super::*;
353 +
354 + /// A registry of two kinds, which is the smallest one that can be wrong in
355 + /// an interesting way: one kind cannot collide, and one knob cannot either.
356 + static KINDS: &[Kind] = &[
357 + Kind::new(
358 + "snooze-expiry",
359 + "Snoozed items resurface",
360 + "When something you snoozed comes back.",
361 + "Reminders",
362 + )
363 + .shipping_on(),
364 + Kind::new(
365 + "digest",
366 + "Daily digest",
367 + "One summary of the day, at a time you pick.",
368 + "Summaries",
369 + )
370 + .with(&[
371 + Knob::new("at", "When", Setting::Seconds(9 * 3600)),
372 + Knob::new(
373 + "include",
374 + "What to include",
375 + Setting::Choice {
376 + options: &["everything", "overdue only"],
377 + default: "overdue only",
378 + },
379 + ),
380 + ]),
381 + ];
382 +
383 + static NOTIFS: Registry = Registry::new(KINDS);
384 +
385 + #[test]
386 + fn a_registry_is_a_constant_and_a_valid_one() {
387 + // The whole point of const-friendly data: this is a `static`, not
388 + // something a startup path built. If that stops being true the
389 + // declaration can differ between two places that build it.
390 + assert!(NOTIFS.check().is_ok());
391 + assert_eq!(NOTIFS.kinds().len(), 2);
392 + }
393 +
394 + #[test]
395 + fn a_kind_ships_off_unless_it_says_otherwise() {
396 + // Framework default, decided 2026-08-17. A declaration should have to
397 + // say the interruption out loud, not the silence.
398 + assert_eq!(NOTIFS.kind("digest").unwrap().ships, Posture::Off);
399 + assert_eq!(NOTIFS.kind("snooze-expiry").unwrap().ships, Posture::On);
400 + }
401 +
402 + #[test]
403 + fn a_knob_carries_its_type_and_its_default_as_one_value() {
404 + let digest = NOTIFS.kind("digest").unwrap();
405 + assert_eq!(digest.knob("at").unwrap().value, Setting::Seconds(9 * 3600));
406 + assert_eq!(digest.options.len(), 2);
407 + // A kind with no knobs generates a single on/off rather than a section.
408 + assert!(NOTIFS.kind("snooze-expiry").unwrap().options.is_empty());
409 + }
410 +
411 + #[test]
412 + fn categories_keep_the_order_the_app_declared_them_in() {
413 + // Not sorted. The app grouped these deliberately and the alphabet has
414 + // no opinion worth substituting for that.
415 + assert_eq!(NOTIFS.categories(), vec!["Reminders", "Summaries"]);
416 + }
417 +
418 + #[test]
419 + fn an_unknown_id_is_absent_rather_than_a_panic() {
420 + assert!(NOTIFS.kind("nope").is_none());
421 + assert!(NOTIFS.kind("digest").unwrap().knob("nope").is_none());
422 + }
423 +
424 + #[test]
425 + fn two_kinds_under_one_id_would_share_a_config_key() {
426 + static CLASH: &[Kind] = &[
427 + Kind::new("digest", "One", "First.", "A"),
428 + Kind::new("digest", "Two", "Second.", "B"),
429 + ];
430 + assert_eq!(
431 + Registry::new(CLASH).check(),
432 + Err(Fault::TwoKinds { id: "digest" })
433 + );
434 + }
435 +
436 + #[test]
437 + fn two_knobs_under_one_id_would_too() {
438 + static CLASH: &[Kind] = &[Kind::new("digest", "Digest", "Daily.", "A").with(&[
439 + Knob::new("at", "When", Setting::Seconds(0)),
440 + Knob::new("at", "Also when", Setting::Count(0)),
441 + ])];
442 + assert_eq!(
443 + Registry::new(CLASH).check(),
444 + Err(Fault::TwoKnobs {
445 + kind: "digest",
446 + id: "at"
447 + })
448 + );
449 + }
450 +
451 + #[test]
452 + fn an_id_that_cannot_be_a_config_key_is_refused() {
453 + // The family convention: `theme`, not `goingson-theme`, and never a
454 + // dot -- a dot is what separates a kind's key from a knob's.
455 + for bad in ["Digest", "daily.digest", "daily digest", ""] {
456 + let kinds: &'static [Kind] = Box::leak(Box::new([Kind::new(bad, "T", "S", "C")]));
457 + assert_eq!(
458 + Registry::new(kinds).check(),
459 + Err(Fault::BadId { id: bad }),
460 + "{bad:?} should be refused"
461 + );
462 + }
463 + // A hyphenated stem is the shape the shipped kinds already use.
464 + static GOOD: &[Kind] = &[Kind::new("snooze-expiry", "T", "S", "C")];
465 + assert!(Registry::new(GOOD).check().is_ok());
466 + }
467 +
468 + #[test]
469 + fn a_choice_cannot_default_to_something_it_does_not_offer() {
470 + static BAD: &[Kind] = &[
471 + Kind::new("digest", "Digest", "Daily.", "A").with(&[Knob::new(
472 + "include",
473 + "What",
474 + Setting::Choice {
475 + options: &["everything"],
476 + default: "overdue only",
477 + },
478 + )]),
479 + ];
480 + assert_eq!(
481 + Registry::new(BAD).check(),
482 + Err(Fault::DefaultNotOffered {
483 + kind: "digest",
484 + id: "include"
485 + })
486 + );
487 + }
488 +
489 + #[test]
490 + fn a_fault_says_which_declaration_is_wrong() {
491 + // The message is read by whoever wrote the registry, so it names the
492 + // ids rather than the position in a slice.
493 + assert!(
494 + Fault::TwoKnobs {
495 + kind: "digest",
496 + id: "at"
497 + }
498 + .to_string()
499 + .contains("`digest` declares two knobs as `at`")
500 + );
Lines truncated