Skip to main content

max / goingson

Onboarding points at the notification kinds that ship off Max, 2026-08-17: notifications ship off and onboarding points that out. The pointer is a fourth member of the welcomed/hint_shortcuts/hint_dayplan family: Local posture, shown once, dismissible. It counts rather than enumerates, because the registry knows the kinds and a list in the copy would go stale the first time one is added. What it counts is kinds that ship off and that nobody has answered yet: a kind the user switched off themselves is also off, and pointing at that would be telling someone about a choice they made. That distinction is why the command carries `chosen` alongside `enabled`, which cannot tell the two apart. Today all three declared kinds are grandfathered on, so it stays quiet and does not burn its flag; the first kind added without shipping_on is the one it fires for. The pointer needed somewhere to point. The generated pane is in the described settings screen, behind the quasi feature, which is off, so the shipped screen had no per-kind control at all. It has one per kind now, rendered from the same registry over one command, writing the same generated keys. That is the rarer direction, a described screen's shape ported back, and it deletes at the flip along with commands::notifs and the JS module.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-19 17:30 UTC
Signed with PGP, not checked
Commit: b154625481c3136048b4ed40659a849377223619
Parent: 5d107f8
12 files changed, +379 insertions, -8 deletions
@@ -803,6 +803,7 @@
803 803 <script src="js/keyboard.js"></script>
804 804
805 805 <!-- UI -->
806 + <script src="js/notifs.js"></script>
806 807 <script src="js/settings.js"></script>
807 808 <script src="js/settings-sync.js"></script>
808 809 <script src="js/settings-sharing.js"></script>
@@ -39,6 +39,10 @@
39 39 /// dismissal) and `hint_dayplan` (the first-visit day-plan walkthrough) are
40 40 /// per-device: the gestures those teach differ between a desktop and a tablet,
41 41 /// so having seen them on one device says nothing about the next.
42 + /// `hint_notifications` (the pointer at the kinds that ship off) is the fourth
43 + /// of that family and is `Local` for a sharper reason than the other three:
44 + /// delivery is per device, so a phone that has never been told notifications
45 + /// exist has not been told, whatever the desktop was shown.
42 46 const APP_KEYS: ConfigSpec = ConfigSpec::new(
43 47 TABLE,
44 48 &[
@@ -54,6 +58,7 @@
54 58 ("welcomed", Posture::Local),
55 59 ("hint_shortcuts", Posture::Local),
56 60 ("hint_dayplan", Posture::Local),
61 + ("hint_notifications", Posture::Local),
57 62 // The wam endpoint: the reachable address for a tailnet service differs
58 63 // per machine, so syncing it would hand one device another's endpoint.
59 64 // Its bearer token is a secret and lives in the OS keychain, not here.
@@ -116,7 +121,13 @@
116 121 "{key} is a preference and should sync"
117 122 );
118 123 }
119 - for key in ["ui_mode", "welcomed", "hint_shortcuts", "hint_dayplan"] {
124 + for key in [
125 + "ui_mode",
126 + "welcomed",
127 + "hint_shortcuts",
128 + "hint_dayplan",
129 + "hint_notifications",
130 + ] {
120 131 assert!(
121 132 !CONFIG.is_synced(key),
122 133 "{key} is per-device and must not sync"
@@ -69,6 +69,8 @@
69 69 $crate::commands::get_all_config,
70 70 $crate::commands::set_config,
71 71 $crate::commands::delete_config,
72 + // Notifications (the declared kinds, for the shipped settings screen)
73 + $crate::commands::list_notification_kinds,
72 74 // Projects
73 75 $crate::commands::list_projects,
74 76 $crate::commands::get_project,
@@ -16,8 +16,11 @@
16 16 //!
17 17 //! Each fires today and each declares [`shipping_on`](Kind::shipping_on), so
18 18 //! adoption does not silently stop a notification somebody depends on. New
19 - //! kinds ship off, per Max 2026-08-17, and onboarding points at them
20 - //! (`b6c634fb`).
19 + //! kinds ship off, per Max 2026-08-17, and onboarding points at them: the
20 + //! pointer counts the kinds that ship off and that nobody has answered yet
21 + //! (`b6c634fb`, in `app.js` over [`crate::commands::list_notification_kinds`]),
22 + //! so it stays quiet for as long as all three of these are the whole registry
23 + //! and fires for the first kind added without `shipping_on`.
21 24 //!
22 25 //! No kind has a [`Knob`](quasi_notifs::Knob). That is measured rather than
23 26 //! deferred: the one lead time in this app belongs to the *event*
@@ -79,6 +79,8 @@
79 79 } else if (!GoingsOn.config.get('go-hint-shortcuts')) {
80 80 // One-time hint after first session
81 81 setTimeout(() => showHint('go-hint-shortcuts', 'Press ? anytime to see keyboard shortcuts'), 2000);
82 + } else {
83 + pointAtNotifications();
82 84 }
83 85
84 86 // After an OTA update, surface this version's changelog once. No-ops on a
@@ -380,6 +382,43 @@
380 382 GoingsOn.ui.openModal('Welcome to GoingsOn', content);
381 383 }
382 384
385 + /**
386 + * Point at the notification kinds that ship off, once.
387 + *
388 + * Max, 2026-08-17: "We will have them off by default but onboarding will point
389 + * that out." Off-by-default without a pointer is a feature nobody finds, and
390 + * this is the pointer.
391 + *
392 + * It counts rather than enumerates. The registry knows the kinds, so a list in
393 + * this copy would go stale the first time one is added, and the count comes out
394 + * of the same declaration the settings pane renders.
395 + *
396 + * What it counts is kinds that ship off and that nobody has answered yet. A
397 + * kind the user switched off themselves is also off, and telling someone about
398 + * a choice they made is not a pointer. So it stays quiet when every kind is on
399 + * or already chosen, and does not burn its flag while waiting. That is the
400 + * state today: all three shipped kinds fired before the registry existed and
401 + * are grandfathered on. The first kind added without `shipping_on` is the one
402 + * this fires for.
403 + */
404 + async function pointAtNotifications() {
405 + if (GoingsOn.config.get('go-hint-notifications')) return;
406 + if (!GoingsOn.notifs) return;
407 + const unanswered = (await GoingsOn.notifs.list()).filter(k => !k.shipsOn && !k.chosen);
408 + if (unanswered.length === 0) return;
409 +
410 + GoingsOn.config.set('go-hint-notifications', '1');
411 + const subject = unanswered.length === 1 ? 'notification' : 'notifications';
412 + GoingsOn.ui.showToast(
413 + `GoingsOn has ${unanswered.length} ${subject} it can send you. They are off until you turn them on.`,
414 + 'info',
415 + {
416 + duration: 12000,
417 + action: { label: 'Settings', fn: () => GoingsOn.settings.openAt('notifications') },
418 + },
419 + );
420 + }
421 +
383 422 /**
384 423 * Show a one-time dismissible hint toast. Sets a config flag so it only shows once.
385 424 */
@@ -395,6 +434,7 @@
395 434 refreshCurrentViewData,
396 435 showWelcome,
397 436 showHint,
437 + pointAtNotifications,
398 438 };
399 439
400 440 // Background/Foreground Transitions
@@ -29,6 +29,7 @@
29 29 'go-welcomed': 'welcomed',
30 30 'go-hint-shortcuts': 'hint_shortcuts',
31 31 'go-hint-dayplan': 'hint_dayplan',
32 + 'go-hint-notifications': 'hint_notifications',
32 33 'goingson-event-lead-minutes': 'event_lead_minutes',
33 34 'goingson-plan-nudges': 'plan_nudges',
34 35 'goingson-review-nudges': 'review_nudges',
@@ -31,6 +31,16 @@
31 31 await showSection(currentSection);
32 32 }
33 33
34 + /**
35 + * Open settings straight onto one section. What a pointer elsewhere in the
36 + * app uses to land somewhere specific, rather than on whichever section was
37 + * open last.
38 + */
39 + async function openAt(section) {
40 + currentSection = section;
41 + await openSettings();
42 + }
43 +
34 44 /**
35 45 * Called by navigation.loadViewData when settings view becomes active.
36 46 * Renders the last-active section. Retained for back-compat with any
@@ -78,7 +88,7 @@
78 88
79 89 switch (section) {
80 90 case 'appearance': await renderAppearance(container); break;
81 - case 'notifications': renderNotifications(container); break;
91 + case 'notifications': await renderNotifications(container); break;
82 92 case 'email': await GoingsOn.emails.renderAccountsSection(container); break;
83 93 case 'planning': renderPlanning(container); break;
84 94 case 'sync': await renderSync(container); break;
@@ -127,10 +137,55 @@
127 137 `;
128 138 }
129 139
130 - function renderNotifications(container) {
140 + /**
141 + * Two halves, and the point of the section is that they are two.
142 + *
143 + * The first is one switch per declared kind, rendered from the registry
144 + * (`GoingsOn.notifs`) rather than from a list here, so adding a kind adds
145 + * its switch. This mirrors what `quasi_notifs::pane` generates for the
146 + * described settings screen, down to the config keys it writes, so the flip
147 + * to that screen deletes this half rather than migrating it.
148 + *
149 + * The second is the event indicator lead time, which stays hand-written
150 + * because it is not a delivery setting at all: it colours a dot on the
151 + * Events tab. It sat alone under this heading before the kinds arrived, and
152 + * the sub-heading is what keeps the two from reading alike.
153 + */
154 + async function renderNotifications(container) {
155 + const kinds = await GoingsOn.notifs.list();
156 + // Grouped by the category each kind declares, in declaration order.
157 + // GoingsOn declares one today; reading it off the kinds rather than
158 + // assuming that is what keeps a second one from rendering under the
159 + // first one's heading.
160 + const categories = [];
161 + for (const k of kinds) {
162 + let group = categories.find(g => g.name === k.category);
163 + if (!group) categories.push((group = { name: k.category, kinds: [] }));
164 + group.kinds.push(k);
165 + }
166 + const declared = categories.map(group => `
167 + <h4 class="settings-subheading">${esc(group.name)}</h4>
168 + ${group.kinds.map(k => `
169 + <div class="settings-toggle-row">
170 + <div>
171 + <p class="settings-subheading">${esc(k.title)}</p>
172 + <p class="settings-desc">${esc(k.summary)}</p>
173 + </div>
174 + <label class="toggle-switch">
175 + <input type="checkbox" ${k.enabled ? 'checked' : ''}
176 + data-change="settings.onNotificationKindChange"
177 + data-a1="${escAttr(k.id)}" data-a2="@checked">
178 + <span class="toggle-slider"></span>
179 + </label>
180 + </div>
181 + `).join('')}
182 + `).join('');
183 +
131 184 container.innerHTML = `
132 185 <div class="settings-section">
133 186 <h3 class="settings-heading">Notifications</h3>
187 + ${declared}
188 + <h4 class="settings-subheading">Events tab</h4>
134 189 <div class="form-group">
135 190 <label class="form-label">Event indicator lead time</label>
136 191 <select id="event-lead-time-selector" class="field" data-change="settings.onEventLeadTimeChange" data-a1="@value">
@@ -422,6 +477,11 @@
422 477
423 478 // Helpers
424 479
480 + /** Flip one declared notification kind. The registry owns the key. */
481 + function onNotificationKindChange(id, checked) {
482 + GoingsOn.notifs.setEnabled(id, checked === true || checked === 'true');
483 + }
484 +
425 485 function onEventLeadTimeChange(value) {
426 486 GoingsOn.config.set('goingson-event-lead-minutes', value);
427 487 if (GoingsOn.events && GoingsOn.events.updateEventStatusDot) {
@@ -463,11 +523,13 @@
463 523
464 524 GoingsOn.settings = {
465 525 open: openSettings,
526 + openAt,
466 527 load: loadSettings,
467 528 showSection,
468 529 goBack,
469 530 openGettingStarted,
470 531 openKeyboardShortcuts,
532 + onNotificationKindChange,
471 533 onEventLeadTimeChange,
472 534 onWorkHoursChange,
473 535 setUpdateCheckOnLaunch,
@@ -35,6 +35,7 @@
35 35 pub(crate) mod import_external;
36 36 mod milestone;
37 37 mod monthly_review;
38 + mod notifs;
38 39 mod oauth;
39 40 mod preferences;
40 41 mod problem;
@@ -135,6 +136,7 @@
135 136 pub use import_external::*;
136 137 pub use milestone::*;
137 138 pub use monthly_review::*;
139 + pub use notifs::*;
138 140 pub use oauth::*;
139 141 pub use preferences::load as load_preferences;
140 142 pub use preferences::*;
@@ -30,7 +30,8 @@
30 30 //!
31 31 //! # Which JS file belongs to which described module
32 32 //!
33 - //! Measured 2026-08-15: 48 files under `frontend/js/` carry 327 `esc()` call
33 + //! Measured 2026-08-15, re-counted 2026-08-19: 48 files under `frontend/js/`
34 + //! carry 330 `esc()` call
34 35 //! sites, plus 2 more in the `js/tests/run.js` gate. Every one of them is
35 36 //! accounted for below, in one of four categories.
36 37 //!
@@ -41,7 +42,7 @@
41 42 //! exist and both are counted. The count starts falling at the flip. Progress is
42 43 //! the first list, not the number.
43 44 //!
44 - //! ## Described, and retires at the flip (30 files, 225 sites)
45 + //! ## Described, and retires at the flip (30 files, 228 sites)
45 46 //!
46 47 //! | Module | JS counterpart | Sites |
47 48 //! |---|---|---|
@@ -53,7 +54,7 @@
53 54 //! | [`day_planning`] | `day-planning-render.js` 8, `day-planning-schedule.js` 4, `day-planning-paint.js` 1 | 13 |
54 55 //! | [`problems`] | `problems.js` | 10 |
55 56 //! | [`monthly_review`] | `monthly-review.js` 3, `monthly-review-render.js` 5 | 8 |
56 - //! | [`settings`] | `settings.js` | 6 |
57 + //! | [`settings`] | `settings.js` | 9 |
57 58 //! | [`board`] | `tasks-kanban.js` 3, `task-board.js` 1 | 4 |
58 59 //! | [`task_list`] | `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, `task-forms.js` 1, `saved-views.js` 1 | 14 |
59 60 //! | [`data`] | `import-external.js` 11, `import.js` 5, `export.js` 2 | 18 |
@@ -61,6 +62,13 @@
61 62 //! [`projects`] carries its dashboard as a submodule, which is the thirteenth
62 63 //! described screen against twelve modules here.
63 64 //!
65 + //! `settings.js` went 6 to 9 on 2026-08-19, and gained `notifs.js` beside it
66 + //! (no sites of its own). Both are the notification kinds reaching the shipped
67 + //! screen: [`settings`] generates that pane from the registry and the shipped
68 + //! screen could not, so it reads the same registry over one command. That is
69 + //! the rarer direction, a described screen's shape ported back, and it deletes
70 + //! at the flip like the rest of the row, along with `commands::notifs`.
71 + //!
64 72 //! ## Stays JavaScript, by decision (4 files, 50 sites)
65 73 //!
66 74 //! - `settings-sync.js` 17, `settings-sharing.js` 14, `email-accounts.js` 10.
@@ -243,6 +243,12 @@
243 243 /// is no list here to keep in step. That replaced a hand-built section, which
244 244 /// is what task `07830eb5` was for.
245 245 ///
246 + /// The shipped JavaScript screen renders the same half from the same registry,
247 + /// over [`crate::commands::list_notification_kinds`], because this screen is
248 + /// behind the `quasi` feature and a pane nobody can reach is not somewhere
249 + /// onboarding can point (`b6c634fb`). It writes the same generated keys, so the
250 + /// flip deletes it rather than migrating it.
251 + ///
246 252 /// The second is `event_lead_minutes`, which stays hand-written because it is
247 253 /// not a notification setting at all: it colours a dot on the Events tab. It
248 254 /// sat alone under this heading before the generated half arrived, and the risk
@@ -1,0 +1,65 @@
1 + /**
2 + * GoingsOn - The declared notification kinds, read from the backend registry.
3 + *
4 + * There is no list of kinds in this file, and there must not be one. The
5 + * registry is `src-tauri/src/notifs.rs`; a kind's title, its one-line summary
6 + * and whether it ships on are declared there once, and both the settings pane
7 + * and the first-run pointer render what this returns. A hardcoded list here
8 + * would be a list that goes stale the first time a kind is added, which is the
9 + * failure the registry exists to make impossible.
10 + *
11 + * The read is cached for the session: the set of kinds is fixed at compile
12 + * time, and only the on/off moves. Writes update the cached entry rather than
13 + * re-fetching, so a toggle does not cost a round trip to learn what it just
14 + * did.
15 + *
16 + * This module retires when the described settings screen becomes the shipped
17 + * one; see `src-tauri/src/commands/notifs.rs`.
18 + */
19 + (function () {
20 + 'use strict';
21 +
22 + const invoke = window.__TAURI__.core.invoke;
23 +
24 + let cached = null;
25 +
26 + /**
27 + * Every declared kind: `{ id, title, summary, category, key, enabled,
28 + * shipsOn }`. Empty on failure, so a surface that cannot reach the registry
29 + * renders nothing rather than inventing a kind.
30 + */
31 + async function list() {
32 + if (cached) return cached;
33 + try {
34 + cached = await invoke('list_notification_kinds');
35 + } catch (e) {
36 + console.error('Failed to load notification kinds:', e);
37 + cached = [];
38 + }
39 + return cached;
40 + }
41 +
42 + /**
43 + * Turn one kind on or off, through the config key the registry generated.
44 + *
45 + * Written straight to the backend rather than through `GoingsOn.config`:
46 + * that cache knows a fixed set of hand-written keys and passes anything
47 + * else to localStorage, and a generated key is neither. Nothing reads these
48 + * synchronously, so the cache buys nothing here; the pane and the outbox
49 + * both resolve them through the registry.
50 + */
51 + function setEnabled(id, on) {
52 + const kind = (cached || []).find((k) => k.id === id);
53 + if (!kind) return;
54 + kind.enabled = !!on;
55 + invoke('set_config', { key: kind.key, value: on ? 'true' : 'false' })
56 + .catch((e) => console.error('set_config failed for', kind.key, e));
57 + }
58 +
59 + /** The kinds that are off right now. What the pointer counts. */
60 + async function off() {
61 + return (await list()).filter((k) => !k.enabled);
62 + }
63 +
64 + GoingsOn.notifs = { list, setEnabled, off };
65 + })();
@@ -1,0 +1,170 @@
1 + //! What the frontend is allowed to know about [`crate::notifs::NOTIFS`].
2 + //!
3 + //! The described settings screen reads the registry directly
4 + //! ([`quasi_notifs::pane`] in [`crate::quasi::settings`]), because it is Rust
5 + //! and the registry is a `static`. The shipped screens are JavaScript and
6 + //! cannot be, so this is the one place the declaration crosses the IPC
7 + //! boundary.
8 + //!
9 + //! It carries no copy of its own. Titles, summaries and categories are the
10 + //! [`Kind`](quasi_notifs::Kind)'s, and the on/off is resolved through
11 + //! [`Registry::is_on`](quasi_notifs::Registry::is_on) against the same
12 + //! `user_config` rows the pane reads, so the two surfaces cannot disagree about
13 + //! what is on. Writes go back through `set_config` under
14 + //! [`enabled_key`](quasi_notifs::config::enabled_key), which
15 + //! [`crate::config_key::CONFIG`] already declares.
16 + //!
17 + //! When the described settings screen becomes the shipped one, this module and
18 + //! its JavaScript callers delete together: the generated pane will be reading
19 + //! the registry in process.
20 +
21 + use std::collections::HashMap;
22 + use std::sync::Arc;
23 +
24 + use serde::Serialize;
25 + use tauri::State;
26 + use tracing::instrument;
27 +
28 + use super::ApiError;
29 + use super::config::all_config;
30 + use crate::notifs::NOTIFS;
31 + use crate::state::AppState;
32 +
33 + /// One declared notification kind, as the frontend sees it.
34 + #[derive(Debug, Clone, Serialize)]
35 + #[serde(rename_all = "camelCase")]
36 + pub struct NotificationKind {
37 + /// The kind's stable id, and the stem of its config key.
38 + pub id: String,
39 + /// What the settings pane calls it.
40 + pub title: String,
41 + /// The one line under the title.
42 + pub summary: String,
43 + /// The heading it groups under.
44 + pub category: String,
45 + /// The `user_config` key its on/off is stored under.
46 + pub key: String,
47 + /// Whether it is on right now, defaults resolved.
48 + pub enabled: bool,
49 + /// Whether it fires for someone who has never touched it.
50 + pub ships_on: bool,
51 + /// Whether anyone has ever set this kind's on/off.
52 + ///
53 + /// The field onboarding needs and [`enabled`](Self::enabled) cannot give
54 + /// it: a kind that is off because it ships off is one nobody has been told
55 + /// about, and a kind that is off because the user switched it off is one
56 + /// they have already answered. Both read `enabled: false`, and pointing at
57 + /// the second would be telling someone about a choice they made.
58 + pub chosen: bool,
59 + }
60 +
61 + /// Every declared kind, resolved against a set of stored config rows.
62 + ///
63 + /// Split from the command so it is testable: a `#[tauri::command]` takes
64 + /// `State` and a test has none, and what is worth testing here is the
65 + /// resolution rather than the IPC.
66 + fn kinds_from(config: &HashMap<String, String>) -> Vec<NotificationKind> {
67 + let stored = |key: &str| config.get(key).cloned();
68 +
69 + NOTIFS
70 + .kinds()
71 + .iter()
72 + .map(|kind| {
73 + let key = quasi_notifs::config::enabled_key(kind.id);
74 + NotificationKind {
75 + id: kind.id.to_owned(),
76 + title: kind.title.to_owned(),
77 + summary: kind.summary.to_owned(),
78 + category: kind.category.to_owned(),
79 + enabled: NOTIFS.is_on(kind.id, &stored),
80 + ships_on: NOTIFS.is_on(kind.id, &quasi_notifs::config::Unset),
81 + chosen: config.contains_key(&key),
82 + key,
83 + }
84 + })
85 + .collect()
86 + }
87 +
88 + /// Every declared kind, with its current on/off resolved.
89 + #[tauri::command]
90 + #[instrument(skip_all)]
91 + pub async fn list_notification_kinds(
92 + state: State<'_, Arc<AppState>>,
93 + ) -> Result<Vec<NotificationKind>, ApiError> {
94 + Ok(kinds_from(&all_config(&state)?))
95 + }
96 +
97 + #[cfg(test)]
98 + mod tests {
99 + use super::*;
100 +
101 + /// The registry is the list. Nothing here filters or reorders it, so a kind
102 + /// cannot be declared and then not offered.
103 + #[test]
104 + fn every_declared_kind_is_offered() {
105 + let offered = kinds_from(&HashMap::new());
106 + assert_eq!(offered.len(), NOTIFS.kinds().len());
107 + for (offered, declared) in offered.iter().zip(NOTIFS.kinds()) {
108 + assert_eq!(offered.id, declared.id);
109 + assert_eq!(offered.title, declared.title);
110 + assert_eq!(offered.summary, declared.summary);
111 + assert_eq!(offered.category, declared.category);
112 + }
113 + }
114 +
115 + /// Nobody has chosen anything, so every kind reads as it ships. This is what
116 + /// onboarding sees on a fresh install.
117 + #[test]
118 + fn unset_reads_as_shipped() {
119 + for kind in kinds_from(&HashMap::new()) {
120 + assert!(!kind.chosen, "{} has been chosen by nobody", kind.id);
121 + assert_eq!(
122 + kind.enabled, kind.ships_on,
123 + "{} should read as it ships",
124 + kind.id
125 + );
126 + }
127 + }
128 +
129 + /// Off-because-chosen and off-because-shipped are distinguishable, which is
130 + /// the whole reason `chosen` exists: onboarding points at the second and
131 + /// must stay quiet about the first.
132 + #[test]
133 + fn a_stored_choice_is_marked_chosen() {
134 + let declared = NOTIFS.kinds()[0];
135 + let key = quasi_notifs::config::enabled_key(declared.id);
136 + let config = HashMap::from([(key.clone(), "false".to_owned())]);
137 +
138 + let offered = kinds_from(&config);
139 + let switched_off = offered
140 + .iter()
141 + .find(|k| k.id == declared.id)
142 + .expect("the kind is offered");
143 + assert!(!switched_off.enabled, "the stored choice is honoured");
144 + assert!(switched_off.chosen, "and it reads as a choice");
145 + assert_eq!(switched_off.key, key, "under the generated key");
146 +
147 + for other in offered.iter().filter(|k| k.id != declared.id) {
148 + assert!(
149 + !other.chosen,
150 + "{} was not written and is unchosen",
151 + other.id
152 + );
153 + }
154 + }
155 +
156 + /// The key the frontend writes back to is one `config_key::CONFIG` declares,
157 + /// or `set_config` would refuse the write and the switch would do nothing.
158 + #[test]
159 + fn every_offered_key_is_a_declared_config_key() {
160 + for kind in kinds_from(&HashMap::new()) {
161 + assert!(
162 + crate::config_key::CONFIG
163 + .keys()
164 + .any(|(known, _)| known == kind.key),
165 + "{} is offered but not a declared config key",
166 + kind.key
167 + );
168 + }
169 + }
170 + }