Skip to main content

max / audiofiles

Describe the license key, and split its errors by what they are about The last obvious form, and the one the task had left as wanting a decision rather than a conversion. Two questions were in the way; both are settled here by the rule the sync panel already set, that a message belongs to the thing it is about. The error. `activation_error` rendered below the Activate button with a recovery affordance attached, and moving it wholesale onto the field would have orphaned the affordance. It does not move wholesale. `InvalidKey` is a fact about the five words in the field and goes there; the network, the server and an exhausted machine slot are facts about the attempt and stay with the button they are the outcome of. `error_scope` is the routing, separate from `recovery_affordance` because where a message goes and what it offers as a way out are different questions. The affordance follows its message, so a rejected key gets "Get a new license key" under the field rather than under a button that is no longer saying anything. `MachineLimit` is deliberately a submission error. The key is real and correctly typed; reporting it on the field would tell the user they mistyped it. Not required. `field()`'s marker would say the screen refuses to move on without a key, and this screen is built on it not doing that -- the disabled Activate button already carries "an empty key cannot be submitted". The lead-in copy became the field's hint, since it is standing help about the question. The 360pt centered column was the other worry and is not one: the field stacks inside whatever width it is allocated. Label placement is still unreviewed, and this is now the fifth screen riding on it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 16:14 UTC
Signed with PGP, not checked
Commit: c43f6aa8d0ea91ca7d5dc2211c4d0c774919b2c0
Parent: 8404a80
2 files changed, +166 insertions, -31 deletions
@@ -8,6 +8,8 @@
8 8 audiofiles-core = { workspace = true }
9 9 audiofiles-browser = { workspace = true }
10 10 audiofiles-sync = { workspace = true }
11 + makeover-layout = { workspace = true }
12 + makeover-immediate = { workspace = true }
11 13 eframe = { workspace = true }
12 14 cpal = { workspace = true }
13 15 parking_lot = { workspace = true }
@@ -6,7 +6,7 @@
6 6 //! entirely, optionally ticking "don't show again" to skip it on every future
7 7 //! launch too.
8 8
9 - use audiofiles_browser::ui::theme;
9 + use audiofiles_browser::ui::{theme, widgets};
10 10 use eframe::egui;
11 11
12 12 use super::{AppScreen, AudioFilesApp, SYNC_SERVER_URL};
@@ -73,19 +73,39 @@
73 73 ui.add_space(theme::space::section());
74 74
75 75 // ── License key entry (optional) ──
76 - ui.label(
77 - egui::RichText::new("Have a key? Paste it here.")
78 - .color(theme::content_secondary()),
79 - );
80 - ui.add_space(theme::space::bound());
76 + //
77 + // A described field: a labelled question with standing help,
78 + // ghost text and — when the key itself is what is wrong — an
79 + // error. Not `required`, deliberately: the marker would say
80 + // the screen refuses to move on without a key, and the whole
81 + // screen is built on it not doing that. The disabled Activate
82 + // button is what says an empty key cannot be submitted.
83 + let field_error = self
84 + .activation_error
85 + .as_ref()
86 + .filter(|err| error_scope(err) == ErrorScope::Field)
87 + .map(ToString::to_string);
88 + let key_field = makeover_layout::Field {
89 + hint: Some("Have a key? Paste it here."),
90 + error: field_error.as_deref(),
91 + placeholder: Some("five-word-license-key-example"),
92 + ..makeover_layout::Field::new(
93 + makeover_layout::FieldKind::Text,
94 + "license_key",
95 + "License key",
96 + )
97 + };
81 98
82 99 let input_width = 360.0_f32.min(available.x - 40.0);
83 - ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| {
84 - let response = ui.add_sized(
85 - ui.available_size(),
86 - egui::TextEdit::singleline(&mut self.license_key_input)
87 - .hint_text("five-word-license-key-example"),
88 - );
100 + ui.allocate_ui(egui::vec2(input_width, 0.0), |ui| {
101 + let Some(response) = widgets::field(
102 + ui,
103 + &key_field,
104 + makeover_immediate::Filling::Text(&mut self.license_key_input),
105 + None,
106 + ) else {
107 + return;
108 + };
89 109 // Clear stale activation error as soon as the user edits the field.
90 110 if response.changed() {
91 111 self.activation_error = None;
@@ -100,6 +120,21 @@
100 120 }
101 121 });
102 122
123 + // The way out follows the message it belongs to. A key the
124 + // server did not recognise is reported on the field, so the
125 + // link that replaces the key goes with it rather than staying
126 + // behind under a button whose error is now blank.
127 + if let Some(err) = self
128 + .activation_error
129 + .clone()
130 + .filter(|err| error_scope(err) == ErrorScope::Field)
131 + {
132 + ui.add_space(theme::space::bound());
133 + if draw_recovery_affordance(ui, &err) && !self.activating {
134 + self.start_activation();
135 + }
136 + }
137 +
103 138 ui.add_space(theme::space::peer());
104 139
105 140 let can_activate =
@@ -123,28 +158,20 @@
123 158 ui.spinner();
124 159 }
125 160
126 - if let Some(err) = self.activation_error.clone() {
161 + // What went wrong with the attempt rather than with the key:
162 + // the network, the server, this machine's slot. It is the
163 + // outcome of pressing Activate, so it is reported where
164 + // Activate is.
165 + if let Some(err) = self
166 + .activation_error
167 + .clone()
168 + .filter(|err| error_scope(err) == ErrorScope::Submission)
169 + {
127 170 ui.add_space(theme::space::peer());
128 171 ui.colored_label(theme::danger(), err.to_string());
129 172 ui.add_space(theme::space::bound());
130 - match recovery_affordance(&err) {
131 - RecoveryAffordance::Retry => {
132 - if ui.button("Try again").clicked() && !self.activating {
133 - self.start_activation();
134 - }
135 - }
136 - RecoveryAffordance::GetNewKey => {
137 - ui.hyperlink_to(
138 - "Get a new license key",
139 - "https://makenot.work/store/audiofiles",
140 - );
141 - }
142 - RecoveryAffordance::ContactSupport => {
143 - ui.hyperlink_to(
144 - "Contact support",
145 - "mailto:info@makenot.work?subject=License%20activation%20issue",
146 - );
147 - }
173 + if draw_recovery_affordance(ui, &err) && !self.activating {
174 + self.start_activation();
148 175 }
149 176 }
150 177
@@ -285,6 +312,64 @@
285 312 }
286 313 }
287 314
315 + /// Where an activation failure is reported.
316 + ///
317 + /// The split the described field made necessary, and the same one
318 + /// `encryption_submit_state` draws in the sync panel: a message belongs to the
319 + /// thing it is about. A key the server rejected is a fact about the value in
320 + /// the field; a server that did not answer is a fact about the attempt, and
321 + /// putting it on the field would say the user typed it wrong.
322 + #[derive(Debug, PartialEq, Eq, Clone, Copy)]
323 + pub(crate) enum ErrorScope {
324 + /// Wrong with the key that was typed.
325 + Field,
326 + /// Wrong with the attempt: the network, the server, or this machine's slot.
327 + Submission,
328 + }
329 +
330 + /// Route a classified activation error to where it is shown.
331 + ///
332 + /// Pure variant routing, like [`recovery_affordance`], and deliberately a
333 + /// separate function from it: where a message goes and what it offers as a way
334 + /// out are different questions, and only one of them is about the key.
335 + ///
336 + /// `MachineLimit` is a submission error, not a field one. The key is real and
337 + /// correctly typed; what is exhausted is its activation slots, which is a fact
338 + /// about the account rather than about the value in the field.
339 + pub(crate) fn error_scope(err: &super::license::ActivationError) -> ErrorScope {
340 + use super::license::ActivationError as E;
341 + match err {
342 + E::InvalidKey => ErrorScope::Field,
343 + E::Network | E::Server(_) | E::Other(_) | E::MachineLimit => ErrorScope::Submission,
344 + }
345 + }
346 +
347 + /// Draw the recovery action for an activation error. Returns true when the user
348 + /// asked to retry, which is the one affordance the caller has to act on; the
349 + /// other two are links and handle themselves.
350 + ///
351 + /// Shared by both scopes so the way out looks the same wherever the message it
352 + /// belongs to ended up.
353 + fn draw_recovery_affordance(ui: &mut egui::Ui, err: &super::license::ActivationError) -> bool {
354 + match recovery_affordance(err) {
355 + RecoveryAffordance::Retry => ui.button("Try again").clicked(),
356 + RecoveryAffordance::GetNewKey => {
357 + ui.hyperlink_to(
358 + "Get a new license key",
359 + "https://makenot.work/store/audiofiles",
360 + );
361 + false
362 + }
363 + RecoveryAffordance::ContactSupport => {
364 + ui.hyperlink_to(
365 + "Contact support",
366 + "mailto:info@makenot.work?subject=License%20activation%20issue",
367 + );
368 + false
369 + }
370 + }
371 + }
372 +
288 373 /// The audiofiles Pro benefit list, in display order. Kept as a plain constant
289 374 /// so the tongue-in-cheek copy is easy to spot and edit in one place.
290 375 const PRO_BENEFITS: &[(&str, &str)] = &[
@@ -372,6 +457,54 @@
372 457 );
373 458 }
374 459
460 + // ── error_scope: which error is about the key, and which is not ──
461 +
462 + #[test]
463 + fn invalid_key_is_reported_on_the_field() {
464 + assert_eq!(error_scope(&ActivationError::InvalidKey), ErrorScope::Field);
465 + }
466 +
467 + #[test]
468 + fn machine_limit_is_reported_with_the_submission() {
469 + // The key is real and correctly typed; its slots are used up. Putting
470 + // that on the field would tell the user they mistyped it.
471 + assert_eq!(
472 + error_scope(&ActivationError::MachineLimit),
473 + ErrorScope::Submission
474 + );
475 + }
476 +
477 + #[test]
478 + fn transport_errors_are_reported_with_the_submission() {
479 + for err in [
480 + ActivationError::Network,
481 + ActivationError::Server(503),
482 + ActivationError::Other("parse blew up".to_string()),
483 + ] {
484 + assert_eq!(error_scope(&err), ErrorScope::Submission);
485 + }
486 + }
487 +
488 + #[test]
489 + fn only_the_field_scoped_error_carries_a_key_replacing_affordance() {
490 + // The pairing the two-scope split rests on: the one error that lands on
491 + // the field is the one whose way out is a new key. If a submission-scoped
492 + // error ever routes to GetNewKey, the link would sit under a message
493 + // that is not about the key.
494 + for err in [
495 + ActivationError::Network,
496 + ActivationError::Server(503),
497 + ActivationError::Other("parse blew up".to_string()),
498 + ActivationError::MachineLimit,
499 + ] {
500 + assert_ne!(recovery_affordance(&err), RecoveryAffordance::GetNewKey);
501 + }
502 + assert_eq!(
503 + recovery_affordance(&ActivationError::InvalidKey),
504 + RecoveryAffordance::GetNewKey
505 + );
506 + }
507 +
375 508 // ── Pro benefits copy ──
376 509
377 510 #[test]