Skip to main content

max / audiofiles

Drop trial; make license optional and activation screen a dismissible Pro pitch - Remove trial system (license.rs, trial_state, has_trial gating) - Activation screen becomes a friendly one-time Pro pitch; can be dismissed permanently via a preference (activation_prompt_dismissed); licensed users skip it implicitly. Browser is reachable without a key. - Retheme audiofiles as a Mac OS 8 Platinum tribute (matches Libraries/makeover themes/audiofiles.toml) - Add color.rs shim between makeover Rgb and egui Color32 - Point makeover at Libraries/makeover path dep
Author: Max Johnson <me@maxj.phd> · 2026-07-26 20:05 UTC
Signed with PGP, not checked
Commit: a03745b7453d3b9479ec4375c07ba41b32b6551b
Parent: 021c971
12 files changed, +531 insertions, -567 deletions
M Cargo.lock +1 -3
@@ -2970,9 +2970,7 @@
2970 2970
2971 2971 [[package]]
2972 2972 name = "makeover"
2973 - version = "2.0.0"
2974 - source = "registry+https://github.com/rust-lang/crates.io-index"
2975 - checksum = "1cae0ab5923b75a7cc18e08d1dc6f116252e76e74a5b317942aff38edd1d0b4f"
2973 + version = "2.1.0"
2976 2974 dependencies = [
2977 2975 "include_dir",
2978 2976 "serde",
M Cargo.toml +1 -1
@@ -57,7 +57,7 @@
57 57 libc = "0.2"
58 58 midir = "0.11"
59 59 tagtree = { path = "../../MNW/shared/tagtree" }
60 - makeover = "2.0.0"
60 + makeover = { path = "../../Libraries/makeover" }
61 61
62 62 [workspace.lints.rust]
63 63 unused = "warn"
@@ -1,4 +1,10 @@
1 1 //! License activation screen, trial mode, and deactivation logic.
2 + //!
3 + //! The activation screen is a soft pitch, not a gate. The license is optional:
4 + //! entering a valid key unlocks "audiofiles Pro" (see the benefits table on the
5 + //! screen itself), but a user can also click Continue and skip the screen
6 + //! entirely, optionally ticking "don't show again" to skip it on every future
7 + //! launch too.
2 8
3 9 use audiofiles_browser::ui::theme;
4 10 use eframe::egui;
@@ -43,71 +49,69 @@
43 49 egui::CentralPanel::default().show(ui, |ui| {
44 50 let available = ui.available_size();
45 51
46 - ui.add_space((available.y * 0.35).max(40.0));
52 + egui::ScrollArea::vertical().show(ui, |ui| {
53 + ui.add_space((available.y * 0.08).max(24.0));
47 54
48 - ui.vertical_centered(|ui| {
49 - ui.heading("audiofiles");
50 - ui.add_space(theme::space::MD);
51 - ui.label("Start a free trial, or activate a license key.");
52 - ui.add_space(theme::space::SECTION);
53 -
54 - // Trial entry (primary path for first-time users)
55 - let trial_expired = trial_is_expired(self.trial_state.as_ref());
56 - let trial_label = trial_button_label(self.trial_state.as_ref());
57 - let trial_btn = egui::Button::new(egui::RichText::new(trial_label).strong());
58 - if ui.add_enabled(!trial_expired, trial_btn).clicked() {
59 - self.start_trial();
60 - }
61 - if trial_expired {
55 + ui.vertical_centered(|ui| {
56 + ui.heading("audiofiles Pro");
62 57 ui.add_space(theme::space::SM);
63 58 ui.label(
64 - egui::RichText::new("Activate a license below to continue.")
65 - .small()
59 + egui::RichText::new(
60 + "Pay what you want for a lifetime license. Or don't. \
61 + audiofiles works the same either way.",
62 + )
63 + .color(theme::content_secondary()),
64 + );
65 + ui.add_space(theme::space::SECTION);
66 +
67 + // ── Benefits table ──
68 + let table_width = 460.0_f32.min(available.x - 40.0);
69 + ui.allocate_ui(egui::vec2(table_width, 0.0), |ui| {
70 + draw_pro_benefits_table(ui);
71 + });
72 +
73 + ui.add_space(theme::space::SECTION);
74 +
75 + // ── License key entry (optional) ──
76 + ui.label(
77 + egui::RichText::new("Have a key? Paste it here.")
66 78 .color(theme::content_secondary()),
67 79 );
68 - }
80 + ui.add_space(theme::space::SM);
69 81
70 - ui.add_space(theme::space::XL);
71 - ui.separator();
72 - ui.add_space(theme::space::MD);
82 + 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 + );
89 + // Clear stale activation error as soon as the user edits the field.
90 + if response.changed() {
91 + self.activation_error = None;
92 + }
93 + // Submit on Enter.
94 + if response.lost_focus()
95 + && ui.input(|i| i.key_pressed(egui::Key::Enter))
96 + && !self.activating
97 + && !self.license_key_input.trim().is_empty()
98 + {
99 + self.start_activation();
100 + }
101 + });
73 102
74 - // License key entry
75 - ui.label(
76 - egui::RichText::new("Already have a license?")
77 - .color(theme::content_secondary()),
78 - );
79 - ui.add_space(theme::space::SM);
103 + ui.add_space(theme::space::MD);
80 104
81 - let input_width = 360.0_f32.min(available.x - 40.0);
82 - ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| {
83 - let response = ui.add_sized(
84 - ui.available_size(),
85 - egui::TextEdit::singleline(&mut self.license_key_input)
86 - .hint_text("five-word-license-key-example"),
87 - );
88 - // Clear stale activation error as soon as the user edits the field.
89 - if response.changed() {
90 - self.activation_error = None;
91 - }
92 - // Submit on Enter
93 - if response.lost_focus()
94 - && ui.input(|i| i.key_pressed(egui::Key::Enter))
95 - && !self.activating
96 - && !self.license_key_input.trim().is_empty()
97 - {
98 - self.start_activation();
99 - }
100 - });
101 -
102 - ui.add_space(theme::space::MD);
103 -
104 - let can_activate = !self.activating && !self.license_key_input.trim().is_empty();
105 - ui.horizontal(|ui| {
105 + let can_activate =
106 + !self.activating && !self.license_key_input.trim().is_empty();
106 107 let button_text = if self.activating {
107 108 "Activating\u{2026}"
108 109 } else {
109 110 "Activate"
110 111 };
112 + // Add the button directly to the vertical_centered scope so
113 + // it centers with the rest of the column; a horizontal here
114 + // would fill the width and left-align its contents.
111 115 if ui
112 116 .add_enabled(can_activate, egui::Button::new(button_text))
113 117 .clicked()
@@ -115,42 +119,66 @@
115 119 self.start_activation();
116 120 }
117 121 if self.activating {
122 + ui.add_space(theme::space::SM);
118 123 ui.spinner();
119 124 }
120 - });
121 125
122 - if let Some(err) = self.activation_error.clone() {
123 - ui.add_space(theme::space::MD);
124 - ui.colored_label(theme::danger(), err.to_string());
125 - ui.add_space(theme::space::SM);
126 - // Per-class recovery affordance.
127 - match recovery_affordance(&err) {
128 - RecoveryAffordance::Retry => {
129 - if ui.button("Try again").clicked() && !self.activating {
130 - self.start_activation();
126 + if let Some(err) = self.activation_error.clone() {
127 + ui.add_space(theme::space::MD);
128 + ui.colored_label(theme::danger(), err.to_string());
129 + ui.add_space(theme::space::SM);
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 + );
131 147 }
132 148 }
133 - RecoveryAffordance::GetNewKey => {
134 - ui.hyperlink_to(
135 - "Get a new license key",
136 - "https://makenot.work/store/audiofiles",
137 - );
138 - }
139 - RecoveryAffordance::ContactSupport => {
140 - ui.hyperlink_to(
141 - "Contact support",
142 - "mailto:info@makenot.work?subject=License%20activation%20issue",
143 - );
144 - }
145 149 }
146 - }
147 150
148 - ui.add_space(theme::space::MD);
149 - ui.hyperlink_to("Get a license key", "https://makenot.work/store/audiofiles");
151 + ui.add_space(theme::space::SM);
152 + ui.hyperlink_to(
153 + "Get a license (pay what you want, $5 minimum)",
154 + "https://makenot.work/store/audiofiles",
155 + );
150 156
151 - ui.add_space(theme::space::XL);
152 - ui.horizontal(|ui| {
153 - ui.add_space((ui.available_width() / 2.0 - 60.0).max(0.0));
157 + ui.add_space(theme::space::SECTION);
158 + ui.separator();
159 + ui.add_space(theme::space::MD);
160 +
161 + // ── Continue without a key ──
162 + let mut dismissed = self.prefs.activation_prompt_dismissed;
163 + if ui
164 + .checkbox(&mut dismissed, "Don't show this again")
165 + .changed()
166 + {
167 + self.prefs.activation_prompt_dismissed = dismissed;
168 + self.prefs.save(&self.config_dir);
169 + }
170 +
171 + ui.add_space(theme::space::SM);
172 +
173 + if ui
174 + .button(egui::RichText::new("Continue without a key").strong())
175 + .clicked()
176 + {
177 + self.continue_without_license();
178 + return;
179 + }
180 +
181 + ui.add_space(theme::space::XL);
154 182 if ui.small_button("About audiofiles").clicked() {
155 183 self.show_about = true;
156 184 }
@@ -159,19 +187,10 @@
159 187 });
160 188 }
161 189
162 - /// Start or continue trial mode: create trial state if needed, then proceed.
163 - pub(crate) fn start_trial(&mut self) {
164 - if self.trial_state.is_none() {
165 - let now = chrono::Utc::now().to_rfc3339();
166 - let trial = super::license::TrialState {
167 - first_launch_date: now.clone(),
168 - last_seen_date: Some(now),
169 - };
170 - if let Err(e) = super::license::save_trial(&self.config_dir, &trial) {
171 - tracing::error!("Failed to save trial state: {e}");
172 - }
173 - self.trial_state = Some(trial);
174 - }
190 + /// Skip the activation screen and proceed to vault setup or the browser.
191 + /// Called from the "Continue without a key" button; honors whatever state
192 + /// the "don't show again" checkbox is in (it saves independently on click).
193 + pub(crate) fn continue_without_license(&mut self) {
175 194 if self.vault_registry.is_some() {
176 195 self.activate_browser();
177 196 } else {
@@ -195,21 +214,19 @@
195 214
196 215 /// Push license info into the browser settings state.
197 216 pub(crate) fn sync_license_to_browser(&mut self) {
198 - if let Some(ref mut browser) = self.browser {
199 - if let Some(ref cache) = self.license_cache {
200 - browser.settings.license_key_masked = Some(mask_key(&cache.key_code));
201 - browser.settings.trial_days_remaining = None;
202 - } else if let Some(ref trial) = self.trial_state {
203 - browser.settings.trial_days_remaining =
204 - Some(super::license::trial_days_remaining(trial));
205 - }
206 - let mid = &self.machine_id;
207 - browser.settings.machine_id = Some(if mid.len() > 12 {
208 - format!("{}...{}", &mid[..8], &mid[mid.len() - 4..])
209 - } else {
210 - mid.clone()
211 - });
212 - }
217 + let Some(ref mut browser) = self.browser else {
218 + return;
219 + };
220 + browser.settings.license_key_masked = self
221 + .license_cache
222 + .as_ref()
223 + .map(|cache| mask_key(&cache.key_code));
224 + let mid = &self.machine_id;
225 + browser.settings.machine_id = Some(if mid.len() > 12 {
226 + format!("{}...{}", &mid[..8], &mid[mid.len() - 4..])
227 + } else {
228 + mid.clone()
229 + });
213 230 }
214 231
215 232 /// Deactivate the license: notify the server (best-effort), delete the
@@ -268,41 +285,53 @@
268 285 }
269 286 }
270 287
271 - /// Whether the "Continue trial" button should be disabled: a trial exists and
272 - /// has no days left. Absence of a trial is *not* expired (fresh start is offered).
273 - pub(crate) fn trial_is_expired(trial: Option<&super::license::TrialState>) -> bool {
274 - matches!(trial, Some(t) if super::license::trial_days_remaining(t) <= 0)
275 - }
288 + /// The audiofiles Pro benefit list, in display order. Kept as a plain constant
289 + /// so the tongue-in-cheek copy is easy to spot and edit in one place.
290 + const PRO_BENEFITS: &[(&str, &str)] = &[
291 + (
292 + "More coffee for the developer",
293 + "Directly funds the caffeine that keeps this project going.",
294 + ),
295 + (
296 + "A sense of pride and luxury",
297 + "You are now the sort of person who buys the pro version of things.",
298 + ),
299 + (
300 + "Helps me test the license key system",
301 + "Genuinely useful; the code path barely runs otherwise.",
302 + ),
303 + ];
276 304
277 - /// The label for the primary trial button, reflecting current trial state.
278 - pub(crate) fn trial_button_label(trial: Option<&super::license::TrialState>) -> String {
279 - match trial {
280 - Some(trial) => {
281 - let days = super::license::trial_days_remaining(trial);
282 - if days > 0 {
283 - let unit = if days == 1 { "day" } else { "days" };
284 - format!("Continue trial ({days} {unit} left)")
285 - } else {
286 - "Trial expired".to_string()
305 + /// Draw the tongue-in-cheek benefits grid shown on the Pro pitch screen.
306 + /// Two columns: benefit title (strong) and a short one-liner underneath.
307 + fn draw_pro_benefits_table(ui: &mut egui::Ui) {
308 + egui::Frame::group(ui.style())
309 + .inner_margin(theme::space::MD)
310 + .show(ui, |ui| {
311 + for (i, (title, blurb)) in PRO_BENEFITS.iter().enumerate() {
312 + if i > 0 {
313 + ui.add_space(theme::space::SM);
314 + ui.separator();
315 + ui.add_space(theme::space::SM);
316 + }
317 + ui.horizontal(|ui| {
318 + ui.label(egui::RichText::new(format!("{}.", i + 1)).weak());
319 + ui.vertical(|ui| {
320 + ui.label(egui::RichText::new(*title).strong());
321 + ui.label(
322 + egui::RichText::new(*blurb).color(theme::content_secondary()),
323 + );
324 + });
325 + });
287 326 }
288 - }
289 - None => "Start free trial: 30 days, no card".to_string(),
290 - }
327 + });
291 328 }
292 329
293 330 #[cfg(test)]
294 331 mod tests {
295 - use super::super::license::{ActivationError, TrialState};
332 + use super::super::license::ActivationError;
296 333 use super::*;
297 334
298 - fn trial_starting_days_ago(days: i64) -> TrialState {
299 - let start = chrono::Utc::now() - chrono::Duration::days(days);
300 - TrialState {
301 - first_launch_date: start.to_rfc3339(),
302 - last_seen_date: None,
303 - }
304 - }
305 -
306 335 // ── recovery_affordance: error-class -> recovery routing ──
307 336
308 337 #[test]
@@ -345,73 +374,15 @@
345 374 );
346 375 }
347 376
348 - // ── trial_is_expired ──
377 + // ── Pro benefits copy ──
349 378
350 379 #[test]
351 - fn no_trial_is_not_expired() {
352 - // None means "never started" -> a fresh trial is offered, not expired.
353 - assert!(!trial_is_expired(None));
354 - }
355 -
356 - #[test]
357 - fn fresh_trial_is_not_expired() {
358 - assert!(!trial_is_expired(Some(&trial_starting_days_ago(1))));
359 - }
360 -
361 - #[test]
362 - fn old_trial_is_expired() {
363 - assert!(trial_is_expired(Some(&trial_starting_days_ago(31))));
364 - }
365 -
366 - #[test]
367 - fn trial_exactly_at_zero_is_expired() {
368 - // day 30 -> 0 remaining, and the guard is `<= 0`, so it counts as expired.
369 - assert!(trial_is_expired(Some(&trial_starting_days_ago(30))));
370 - }
371 -
372 - // ── trial_button_label ──
373 -
374 - #[test]
375 - fn label_no_trial_offers_start() {
376 - assert_eq!(
377 - trial_button_label(None),
378 - "Start free trial: 30 days, no card"
379 - );
380 - }
381 -
382 - #[test]
383 - fn label_fresh_trial_shows_days_plural() {
384 - let label = trial_button_label(Some(&trial_starting_days_ago(0)));
385 - assert_eq!(label, "Continue trial (30 days left)");
386 - }
387 -
388 - #[test]
389 - fn label_one_day_left_is_singular() {
390 - // 29 days elapsed -> 1 day remaining -> singular "day".
391 - let label = trial_button_label(Some(&trial_starting_days_ago(29)));
392 - assert_eq!(label, "Continue trial (1 day left)");
393 - }
394 -
395 - #[test]
396 - fn label_expired_trial() {
397 - assert_eq!(
398 - trial_button_label(Some(&trial_starting_days_ago(45))),
399 - "Trial expired"
400 - );
401 - }
402 -
403 - #[test]
404 - fn label_and_expiry_agree() {
405 - // The two helpers must never disagree about a given trial.
406 - for days in [0, 1, 15, 29, 30, 31, 60] {
407 - let t = trial_starting_days_ago(days);
408 - let expired = trial_is_expired(Some(&t));
409 - let label = trial_button_label(Some(&t));
410 - if expired {
411 - assert_eq!(label, "Trial expired", "day {days}");
412 - } else {
413 - assert!(label.starts_with("Continue trial ("), "day {days}: {label}");
414 - }
415 - }
380 + fn pro_benefits_are_exactly_the_three_promised() {
381 + // The three-item list is a contract with the user (see the Pro pitch
382 + // in the app description). Guard against silent re-ordering / drops.
383 + assert_eq!(PRO_BENEFITS.len(), 3);
384 + assert!(PRO_BENEFITS[0].0.contains("coffee"));
385 + assert!(PRO_BENEFITS[1].0.contains("pride"));
386 + assert!(PRO_BENEFITS[2].0.contains("license key system"));
416 387 }
417 388 }
@@ -270,75 +270,6 @@
270 270 }
271 271 }
272 272
273 - // ── Trial state ──
274 -
275 - /// Persisted trial state: tracks when the user first launched the app.
276 - #[derive(Debug, Clone, Serialize, Deserialize)]
277 - pub(crate) struct TrialState {
278 - pub first_launch_date: String,
279 - /// Last time the app was launched, used to detect system clock rollback.
280 - #[serde(default)]
281 - pub last_seen_date: Option<String>,
282 - }
283 -
284 - /// Load the trial state from `trial.json` in the config directory.
285 - pub(crate) fn load_trial(config_dir: &Path) -> Option<TrialState> {
286 - let path = config_dir.join("trial.json");
287 - let bytes = std::fs::read(&path).ok()?;
288 - let state: TrialState = serde_json::from_slice(&bytes).ok()?;
289 - // Fail-open: a present-but-corrupt trial (unparseable first_launch_date) is
290 - // treated as no trial, so the caller starts a fresh one rather than leaving
291 - // the user with a disabled "Continue trial" button and no recovery
292 - // (trial-mode.md: deleting/breaking the config just grants another 30 days).
293 - chrono::DateTime::parse_from_rfc3339(&state.first_launch_date).ok()?;
294 - Some(state)
295 - }
296 -
297 - /// Save the trial state to `trial.json` in the config directory.
298 - pub(crate) fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> {
299 - let path = config_dir.join("trial.json");
300 - let tmp = config_dir.join("trial.json.tmp");
301 - let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?;
302 - std::fs::write(&tmp, &json)?;
303 - std::fs::rename(&tmp, &path)
304 - }
305 -
306 - /// Calculate days remaining in the trial (goes negative after day 30).
307 - ///
308 - /// Uses the furthest-seen elapsed time (max of now-since-first and
309 - /// last_seen-since-first). A clock rolled backward therefore cannot *inflate* the
310 - /// remaining days (the cheat the monotonic floor blocks), but it also never
311 - /// permanently zeros a genuine trial on a legitimate NTP/DST correction, the old
312 - /// behavior, which contradicted trial-mode.md ("not DRM… does not lock the user
313 - /// out"). A corrupt `first_launch_date` is treated as expired here, but
314 - /// [`load_trial`] regenerates such a trial before this is reached.
315 - pub(crate) fn trial_days_remaining(trial: &TrialState) -> i64 {
316 - let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else {
317 - return 0;
318 - };
319 - let now = chrono::Utc::now();
320 -
321 - let mut elapsed = now.signed_duration_since(first);
322 - if let Some(ref last) = trial.last_seen_date
323 - && let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(last)
324 - {
325 - let seen_elapsed = last_seen.signed_duration_since(first);
326 - if seen_elapsed > elapsed {
327 - elapsed = seen_elapsed;
328 - }
329 - }
330 -
331 - 30 - elapsed.num_days()
332 - }
333 -
334 - /// Update the last_seen_date to now. Call on each app launch.
335 - pub(crate) fn touch_trial(config_dir: &std::path::Path) {
336 - if let Some(mut trial) = load_trial(config_dir) {
337 - trial.last_seen_date = Some(chrono::Utc::now().to_rfc3339());
338 - let _ = save_trial(config_dir, &trial);
339 - }
340 - }
341 -
342 273 #[cfg(test)]
343 274 mod tests {
344 275 use super::*;
@@ -451,68 +382,4 @@
451 382 assert!(resp.success);
452 383 assert_eq!(resp.message, "deactivated");
453 384 }
454 -
455 - #[test]
456 - fn save_load_trial_roundtrip() {
457 - let dir = tempfile::tempdir().unwrap();
458 - let state = TrialState {
459 - first_launch_date: "2026-04-01T00:00:00Z".to_string(),
460 - last_seen_date: None,
461 - };
462 - save_trial(dir.path(), &state).unwrap();
463 - let loaded = load_trial(dir.path()).unwrap();
464 - assert_eq!(loaded.first_launch_date, state.first_launch_date);
465 - }
466 -
467 - #[test]
468 - fn load_trial_missing_returns_none() {
469 - let dir = tempfile::tempdir().unwrap();
470 - assert!(load_trial(dir.path()).is_none());
471 - }
472 -
473 - #[test]
474 - fn trial_days_remaining_fresh() {
475 - let state = TrialState {
476 - first_launch_date: chrono::Utc::now().to_rfc3339(),
477 - last_seen_date: None,
478 - };
479 - assert_eq!(trial_days_remaining(&state), 30);
480 - }
481 -
482 - #[test]
483 - fn trial_days_remaining_expired() {
484 - let past = chrono::Utc::now() - chrono::Duration::days(35);
485 - let state = TrialState {
486 - first_launch_date: past.to_rfc3339(),
487 - last_seen_date: None,
488 - };
489 - assert_eq!(trial_days_remaining(&state), -5);
490 - }
491 -
492 - #[test]
493 - fn trial_clock_rollback_does_not_inflate_or_lock_out() {
494 - let now = chrono::Utc::now();
495 - let seen = now + chrono::Duration::days(10);
496 - let state = TrialState {
497 - first_launch_date: now.to_rfc3339(),
498 - last_seen_date: Some(seen.to_rfc3339()),
499 - };
500 - // Clock rolled back 10 days: the monotonic floor uses the furthest-seen
501 - // elapsed (10 days), so remaining is 20, not inflated to a fresh 30 (the
502 - // cheat) and not permanently zeroed (the lock-out trial-mode.md forbids).
503 - assert_eq!(trial_days_remaining(&state), 20);
504 - }
505 -
506 - #[test]
507 - fn load_trial_corrupt_date_returns_none() {
508 - let dir = tempfile::tempdir().unwrap();
509 - // Valid JSON, garbage date: fail-open treats it as no trial so a fresh one
510 - // is started rather than locking the user out with a disabled button.
511 - std::fs::write(
512 - dir.path().join("trial.json"),
513 - r#"{"first_launch_date":"not-a-date","last_seen_date":null}"#,
514 - )
515 - .unwrap();
516 - assert!(load_trial(dir.path()).is_none());
517 - }
518 385 }
@@ -209,22 +209,27 @@
209 209 Browser,
210 210 }
211 211
212 - /// Determine the initial screen based on vault registry, license status, and trial.
212 + /// Determine the initial screen based on vault registry, license status, and
213 + /// whether the user has dismissed the (now-optional) Pro pitch.
214 + ///
215 + /// The license is optional: an unlicensed user still reaches the browser. The
216 + /// activation screen appears once as a friendly pitch and can be permanently
217 + /// dismissed from a checkbox on the screen itself. Licensed users skip it
218 + /// implicitly (having a key is dismissal in the strongest form).
213 219 ///
214 220 /// This is the pure decision logic extracted from `AudioFilesApp::new()` so it
215 221 /// can be tested without constructing the full app.
216 222 fn resolve_initial_screen(
217 223 vault_registry: Option<&VaultRegistry>,
218 224 license_status: &license::LicenseStatus,
219 - has_trial: bool,
225 + activation_prompt_dismissed: bool,
220 226 ) -> AppScreen {
221 - let licensed_or_trial =
222 - matches!(license_status, license::LicenseStatus::Licensed(_)) || has_trial;
223 - match (vault_registry, licensed_or_trial) {
227 + let licensed = matches!(license_status, license::LicenseStatus::Licensed(_));
228 + let bypass_activation = licensed || activation_prompt_dismissed;
229 + match (vault_registry, bypass_activation) {
224 230 (Some(_), true) => AppScreen::Browser,
225 - (Some(_), false) => AppScreen::Activation,
226 231 (None, true) => AppScreen::VaultSetup,
227 - (None, false) => AppScreen::Activation,
232 + (_, false) => AppScreen::Activation,
228 233 }
229 234 }
230 235
@@ -269,7 +274,6 @@
269 274 activation_error: Option<license::ActivationError>,
270 275 activating: bool,
271 276 license_cache: Option<license::LicenseCache>,
272 - trial_state: Option<license::TrialState>,
273 277 }
274 278
275 279 impl AudioFilesApp {
@@ -292,8 +296,6 @@
292 296
293 297 let machine_id = license::get_or_create_machine_id(&config_dir);
294 298 let license_status = license::load_license(&config_dir);
295 - let trial_state = license::load_trial(&config_dir);
296 - license::touch_trial(&config_dir);
297 299
298 300 // Load (or create) the vault registry
299 301 let vault_registry = match vault::load_registry() {
@@ -304,19 +306,23 @@
304 306 }
305 307 };
306 308
307 - let has_active_trial = trial_state
308 - .as_ref()
309 - .is_some_and(|t| license::trial_days_remaining(t) > 0);
310 - let screen =
311 - resolve_initial_screen(vault_registry.as_ref(), &license_status, has_active_trial);
309 + let screen = resolve_initial_screen(
310 + vault_registry.as_ref(),
311 + &license_status,
312 + prefs.activation_prompt_dismissed,
313 + );
312 314
313 - let licensed_or_trial =
314 - matches!(&license_status, license::LicenseStatus::Licensed(_)) || has_active_trial;
315 + // Whether we should open the browser on launch (registry exists AND the
316 + // user has cleared the pitch, one way or another). The license is
317 + // optional now, so an unlicensed-but-dismissed user still gets a
318 + // browser rather than sitting on an intermediate screen.
319 + let bypass_activation = matches!(&license_status, license::LicenseStatus::Licensed(_))
320 + || prefs.activation_prompt_dismissed;
315 321
316 322 let (data_dir, browser, error, sync_manager, license_cache) =
317 - match (&vault_registry, &license_status) {
318 - // Registry exists and user is licensed → open the active vault
319 - (Some(reg), license::LicenseStatus::Licensed(cache)) => {
323 + match (&vault_registry, &license_status, bypass_activation) {
324 + // Registry exists and the user has cleared the pitch → open vault.
325 + (Some(reg), status, true) => {
320 326 let data_dir = reg.active.clone();
321 327 let _ = std::fs::create_dir_all(&data_dir);
322 328 let sync_manager = create_sync_manager(&data_dir, runtime.handle());
@@ -325,37 +331,31 @@
325 331 shared.clone(),
326 332 &vault_setup::vault_name_for_path(reg, &data_dir),
327 333 );
328 - (data_dir, browser, error, sync_manager, Some(cache.clone()))
334 + let cache = if let license::LicenseStatus::Licensed(c) = status {
335 + Some(c.clone())
336 + } else {
337 + None
338 + };
339 + (data_dir, browser, error, sync_manager, cache)
329 340 }
330 - // Registry exists, unlicensed but in trial → open the active vault
331 - (Some(reg), license::LicenseStatus::Unlicensed) if has_active_trial => {
332 - let data_dir = reg.active.clone();
333 - let _ = std::fs::create_dir_all(&data_dir);
334 - let sync_manager = create_sync_manager(&data_dir, runtime.handle());
335 - let (browser, error) = init_browser(
336 - &data_dir,
337 - shared.clone(),
338 - &vault_setup::vault_name_for_path(reg, &data_dir),
339 - );
340 - (data_dir, browser, error, sync_manager, None)
341 - }
342 - // Registry exists but unlicensed (deactivated and reactivated)
343 - (Some(reg), license::LicenseStatus::Unlicensed) => {
344 - tracing::info!("No valid license, showing activation screen");
341 + // Registry exists but pitch not yet dismissed → show it, keep the vault path.
342 + (Some(reg), _, false) => {
343 + tracing::info!("Showing Pro pitch screen (registry present)");
345 344 (reg.active.clone(), None, None, None, None)
346 345 }
347 - // No registry + licensed → vault setup (existing user upgrading)
348 - (None, license::LicenseStatus::Licensed(cache)) => {
349 - tracing::info!("Licensed but no vault registry, showing vault setup");
350 - (default_vault.clone(), None, None, None, Some(cache.clone()))
351 - }
352 - // No registry + unlicensed → activation first (or vault setup if trial)
353 - (None, license::LicenseStatus::Unlicensed) => {
354 - if licensed_or_trial {
355 - tracing::info!("Trial mode, showing vault setup");
346 + // No registry + cleared → vault setup.
347 + (None, status, true) => {
348 + tracing::info!("Cleared pitch without registry, showing vault setup");
349 + let cache = if let license::LicenseStatus::Licensed(c) = status {
350 + Some(c.clone())
356 351 } else {
357 - tracing::info!("No license, showing activation screen");
358 - }
352 + None
353 + };
354 + (default_vault.clone(), None, None, None, cache)
355 + }
356 + // No registry + first-time user → show the pitch.
357 + (None, _, false) => {
358 + tracing::info!("First launch, showing Pro pitch screen");
359 359 (default_vault.clone(), None, None, None, None)
360 360 }
361 361 };
@@ -386,7 +386,6 @@
386 386 activation_error: None,
387 387 activating: false,
388 388 license_cache,
389 - trial_state,
390 389 };
391 390 app.sync_vault_list_to_browser();
392 391 app.sync_license_to_browser();
@@ -1099,7 +1098,10 @@
1099 1098 }
1100 1099
1101 1100 #[test]
1102 - fn initial_screen_unlicensed_with_registry() {
1101 + fn initial_screen_unlicensed_with_registry_shows_pitch() {
1102 + // First-time launch (dismissed=false) still shows the Pro pitch even
1103 + // though the registry exists — the pitch is a one-time onboarding
1104 + // surface, not a per-vault gate.
1103 1105 let dir = tempfile::tempdir().unwrap();
1104 1106 let reg = Some(make_registry(dir.path()));
1105 1107 let status = license::LicenseStatus::Unlicensed;
@@ -1110,7 +1112,7 @@
1110 1112 }
1111 1113
1112 1114 #[test]
1113 - fn initial_screen_unlicensed_without_registry() {
1115 + fn initial_screen_unlicensed_without_registry_shows_pitch() {
1114 1116 let status = license::LicenseStatus::Unlicensed;
1115 1117 assert_eq!(
1116 1118 resolve_initial_screen(None, &status, false),
@@ -1119,7 +1121,9 @@
1119 1121 }
1120 1122
1121 1123 #[test]
1122 - fn initial_screen_trial_with_registry() {
1124 + fn initial_screen_dismissed_skips_pitch_with_registry() {
1125 + // "Don't show again" was ticked: unlicensed user drops straight into
1126 + // the browser on subsequent launches.
1123 1127 let dir = tempfile::tempdir().unwrap();
1124 1128 let reg = Some(make_registry(dir.path()));
1125 1129 let status = license::LicenseStatus::Unlicensed;
@@ -1130,7 +1134,7 @@
1130 1134 }
1131 1135
1132 1136 #[test]
1133 - fn initial_screen_trial_without_registry() {
1137 + fn initial_screen_dismissed_skips_pitch_without_registry() {
1134 1138 let status = license::LicenseStatus::Unlicensed;
1135 1139 assert_eq!(
1136 1140 resolve_initial_screen(None, &status, true),
@@ -21,12 +21,21 @@
21 21 /// environments can disable from the About box.
22 22 #[serde(default = "default_check_for_updates")]
23 23 pub check_for_updates: bool,
24 +
25 + /// Whether the user has ticked "don't show this again" on the Pro pitch.
26 + /// When `true`, launch skips the activation screen and drops straight into
27 + /// vault setup or the browser. Set only via the checkbox on that screen;
28 + /// entering a key doesn't flip it (a licensed user has already dismissed
29 + /// the pitch by virtue of being licensed).
30 + #[serde(default)]
31 + pub activation_prompt_dismissed: bool,
24 32 }
25 33
26 34 impl Default for Preferences {
27 35 fn default() -> Self {
28 36 Self {
29 37 check_for_updates: default_check_for_updates(),
38 + activation_prompt_dismissed: false,
30 39 }
31 40 }
32 41 }
@@ -82,6 +91,7 @@
82 91 let dir = tempfile::tempdir().unwrap();
83 92 let p = Preferences {
84 93 check_for_updates: false,
94 + activation_prompt_dismissed: false,
85 95 };
86 96 p.save(dir.path());
87 97 let loaded = Preferences::load(dir.path());
@@ -301,8 +301,6 @@
301 301 pub license_key_masked: Option<String>,
302 302 /// Machine ID for display.
303 303 pub machine_id: Option<String>,
304 - /// Trial days remaining (None if not in trial mode).
305 - pub trial_days_remaining: Option<i64>,
306 304
307 305 /// Cached list of tombstoned samples shown in the Trash section, most
308 306 /// recently deleted first. Refreshed when the section opens and after an
@@ -1,6 +1,7 @@
1 1 //! UI submodules: each panel and widget type in its own file.
2 2
3 3 pub mod classifier;
4 + pub mod color;
4 5 pub mod detail;
5 6 pub mod dialog;
6 7 pub mod edit_panel;
@@ -728,7 +728,12 @@
728 728 // ── License section ──
729 729
730 730 fn draw_license_section(ui: &mut egui::Ui, state: &mut BrowserState) {
731 - egui::CollapsingHeader::new(egui::RichText::new("License").strong())
731 + let header = if state.settings.license_key_masked.is_some() {
732 + "audiofiles Pro"
733 + } else {
734 + "License"
735 + };
736 + egui::CollapsingHeader::new(egui::RichText::new(header).strong())
732 737 .default_open(false)
733 738 .show(ui, |ui| {
734 739 if let Some(ref masked) = state.settings.license_key_masked {
@@ -736,24 +741,13 @@
736 741 ui.label("Key:");
737 742 ui.label(egui::RichText::new(masked).color(theme::content_secondary()));
738 743 });
739 - } else if let Some(days) = state.settings.trial_days_remaining {
740 - // "Trial: 0 days" was technically correct but uncomfortably
741 - // terse at the expired state; rephrase so the dead-end reads
742 - // as a status, not a counter (m-13). A Purchase button would
743 - // belong here but the buy flow is not yet wired.
744 - let text = if days > 0 {
745 - format!("Trial: {days} days left")
746 - } else {
747 - "Trial expired".to_string()
748 - };
749 - let color = if days > 7 {
750 - theme::content_secondary()
751 - } else if days > 0 {
752 - theme::warning()
753 - } else {
754 - theme::content_muted()
755 - };
756 - ui.label(egui::RichText::new(text).color(color));
744 + } else {
745 + ui.label(
746 + egui::RichText::new(
747 + "No license key. audiofiles is fully functional without one.",
748 + )
749 + .color(theme::content_secondary()),
750 + );
757 751 }
758 752 if let Some(ref mid) = state.settings.machine_id {
759 753 ui.horizontal(|ui| {
@@ -316,7 +316,7 @@
316 316 ui.add_space(theme::space::MD);
317 317 ui.label(
318 318 egui::RichText::new(
319 - "Open a vault and ensure your license or trial is active to enable sync.",
319 + "Open a vault to enable sync.",
320 320 )
321 321 .small()
322 322 .weak(),
@@ -100,23 +100,23 @@
100 100
101 101 impl Default for ThemeColors {
102 102 fn default() -> Self {
103 - // audiofiles default: muted sage & mocha (keep in sync with
103 + // audiofiles default: Mac OS 8 Platinum (keep in sync with
104 104 // themes/audiofiles.toml, this is the pre-load Rust fallback).
105 105 Self {
106 - surface_page: Color32::from_rgb(0xCC, 0xDA, 0xD1),
107 - surface_overlay: Color32::from_rgb(0xB4, 0xC5, 0xBB),
108 - surface_sunken: Color32::from_rgb(0x9C, 0xAE, 0xA9),
109 - surface_raised: Color32::from_rgb(0xDA, 0xE3, 0xDC),
110 - content: Color32::from_rgb(0x38, 0x30, 0x2E),
111 - content_secondary: Color32::from_rgb(0x6F, 0x68, 0x66),
112 - content_muted: Color32::from_rgb(0x78, 0x85, 0x85),
113 - danger: Color32::from_rgb(0xB0, 0x5F, 0x4E),
114 - success: Color32::from_rgb(0x6F, 0x8A, 0x5C),
115 - action: Color32::from_rgb(0x5E, 0x7C, 0x8E),
116 - warning: Color32::from_rgb(0xC1, 0x9A, 0x53),
117 - category_five: Color32::from_rgb(0x83, 0x6A, 0x80),
118 - category_six: Color32::from_rgb(0x5F, 0x8C, 0x82),
119 - border: Color32::from_rgb(0x9C, 0xAE, 0xA9),
106 + surface_page: Color32::from_rgb(0xDD, 0xDD, 0xDD),
107 + surface_overlay: Color32::from_rgb(0xEE, 0xEE, 0xEE),
108 + surface_sunken: Color32::from_rgb(0xB0, 0xB0, 0xB0),
109 + surface_raised: Color32::from_rgb(0xFF, 0xFF, 0xFF),
110 + content: Color32::from_rgb(0x00, 0x00, 0x00),
111 + content_secondary: Color32::from_rgb(0x33, 0x33, 0x33),
112 + content_muted: Color32::from_rgb(0x80, 0x80, 0x80),
113 + danger: Color32::from_rgb(0xC2, 0x2F, 0x2F),
114 + success: Color32::from_rgb(0x2E, 0x7D, 0x32),
115 + action: Color32::from_rgb(0x3B, 0x5A, 0x9F),
116 + warning: Color32::from_rgb(0xE0, 0xA0, 0x30),
117 + category_five: Color32::from_rgb(0x00, 0x9C, 0xDF),
118 + category_six: Color32::from_rgb(0x97, 0x39, 0x99),
119 + border: Color32::from_rgb(0x80, 0x80, 0x80),
120 120 rounding: 4.0,
121 121 item_spacing_x: 8.0,
122 122 item_spacing_y: 5.0,
@@ -184,27 +184,10 @@
184 184 // egui's `Color32` to that shared implementation; the derivation *choices*
185 185 // (which colors, what ratio) stay here as audiofiles' egui styling.
186 186
187 - fn to_rgb(c: Color32) -> makeover::Rgb {
188 - makeover::Rgb {
189 - r: c.r(),
190 - g: c.g(),
191 - b: c.b(),
192 - }
193 - }
194 - fn from_rgb(c: makeover::Rgb) -> Color32 {
195 - Color32::from_rgb(c.r, c.g, c.b)
196 - }
197 -
198 - /// Pick white or black text for legibility on `bg`, by WCAG contrast ratio.
199 - fn contrast_color(bg: Color32) -> Color32 {
200 - from_rgb(makeover::readable_on(to_rgb(bg)))
201 - }
202 -
203 - /// Perceptual (OKLab) blend from `a` to `b` by `t` in [0,1]. Used to derive row
204 - /// stripes, selection highlights, and hover states from the base palette.
205 - fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
206 - from_rgb(makeover::mix(to_rgb(a), to_rgb(b), t))
207 - }
187 + // Color-math primitives live in `super::color`; the theme calls them through
188 + // that seam so the makeover↔egui adapter can be pulled into its own crate if
189 + // another project ever needs it.
190 + use super::color::{contrast_ratio, is_light, lerp_color};
208 191
209 192 // --- Public accessors ---
210 193
@@ -343,23 +326,6 @@
343 326 }
344 327 }
345 328
346 - fn relative_luminance(c: Color32) -> f64 {
347 - fn lin(ch: u8) -> f64 {
348 - let c = ch as f64 / 255.0;
349 - if c <= 0.03928 {
350 - c / 12.92
351 - } else {
352 - ((c + 0.055) / 1.055).powf(2.4)
353 - }
354 - }
355 - 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
356 - }
357 -
358 - fn contrast_ratio(a: Color32, b: Color32) -> f64 {
359 - let (la, lb) = (relative_luminance(a), relative_luminance(b));
360 - let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
361 - (hi + 0.05) / (lo + 0.05)
362 - }
363 329
364 330 /// Load a theme's full color set by id (bundled or custom on disk).
365 331 fn theme_colors_for(id: &str) -> Option<ThemeColors> {
@@ -670,7 +636,15 @@
670 636 /// Apply the current theme's visuals to the egui context.
671 637 pub fn apply_theme(ctx: &egui::Context) {
672 638 let t = THEME.read();
673 - let mut visuals = egui::Visuals::dark();
639 + // Pick the base preset by luminance of the page background: a light theme
640 + // needs `dark_mode = false` so `RichText::strong()` renders BLACK (readable
641 + // on a light surface) rather than the dark-mode WHITE (invisible on it).
642 + // Widget colors are all overridden below regardless.
643 + let mut visuals = if is_light(t.surface_page) {
644 + egui::Visuals::light()
645 + } else {
646 + egui::Visuals::dark()
647 + };
674 648
675 649 visuals.panel_fill = t.surface_overlay;
676 650 visuals.window_fill = t.surface_overlay;
@@ -686,12 +660,34 @@
686 660 visuals.widgets.noninteractive.bg_fill = t.surface_overlay;
687 661 visuals.widgets.inactive.bg_fill = lerp_color(t.surface_overlay, t.surface_sunken, 0.3);
688 662 visuals.widgets.hovered.bg_fill = t.surface_sunken;
689 - visuals.widgets.active.bg_fill = t.action;
663 + // Pressed/active fill stays in the neutral-gray family (darker than
664 + // hovered, for a "pressed" feel) rather than adopting the accent color.
665 + // egui derives `strong_text_color()` from `widgets.active.fg_stroke`, so
666 + // if this bg were the accent blue we'd be forced to set fg to
667 + // `contrast_color(action)` = WHITE, which then poisons every `.strong()`
668 + // label in the app (white text on light panels = unreadable). The
669 + // accent still shows up as the focus/selection stroke below.
670 + visuals.widgets.active.bg_fill = lerp_color(t.surface_sunken, t.content, 0.15);
671 +
672 + // egui's Button widget paints its background from `weak_bg_fill`, not
673 + // `bg_fill`; the latter only shows once the button is hovered/pressed.
674 + // Without this mirror the buttons picked up the base-preset default
675 + // (near-black in dark, near-white in light), which had them either
676 + // invisibly dark on a light theme or blowing out any surrounding chrome.
677 + visuals.widgets.noninteractive.weak_bg_fill = visuals.widgets.noninteractive.bg_fill;
678 + visuals.widgets.inactive.weak_bg_fill = visuals.widgets.inactive.bg_fill;
679 + visuals.widgets.hovered.weak_bg_fill = visuals.widgets.hovered.bg_fill;
680 + visuals.widgets.active.weak_bg_fill = visuals.widgets.active.bg_fill;
681 + visuals.widgets.open.weak_bg_fill = visuals.widgets.inactive.bg_fill;
690 682
691 683 visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, t.content_secondary);
692 684 visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, t.content);
693 685 visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, t.content);
694 - visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, contrast_color(t.action));
686 + // Active fg is the *strong-text* color across egui, not just pressed-button
687 + // text. Keep it at `t.content` so `RichText::strong()` reads as the
688 + // theme's primary text on light panels (BLACK), not the previous
689 + // accent-inverted WHITE that vanished into the background.
690 + visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, t.content);
695 691 visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, t.content);
696 692
697 693 visuals.window_stroke = egui::Stroke::new(1.0, t.border);
@@ -742,100 +738,9 @@
742 738 use super::*;
743 739 use std::collections::HashMap;
744 740
745 - // lerp_color
746 -
747 - #[test]
748 - fn contrast_color_black_bg_returns_white() {
749 - assert_eq!(contrast_color(Color32::BLACK), Color32::WHITE);
750 - }
751 -
752 - #[test]
753 - fn contrast_color_white_bg_returns_black() {
754 - assert_eq!(contrast_color(Color32::WHITE), Color32::BLACK);
755 - }
756 -
757 - #[test]
758 - fn contrast_color_medium_blue_returns_black() {
759 - // #0a84ff is a medium blue; by WCAG contrast ratio black reads better
760 - // than white on it (~5.8:1 vs ~3.6:1), so readable_on picks black.
761 - assert_eq!(
762 - contrast_color(Color32::from_rgb(0x0a, 0x84, 0xff)),
763 - Color32::BLACK
764 - );
765 - }
766 -
767 - #[test]
768 - fn contrast_color_bright_yellow_returns_black() {
769 - assert_eq!(
770 - contrast_color(Color32::from_rgb(0xff, 0xd6, 0x0a)),
771 - Color32::BLACK
772 - );
773 - }
774 -
775 - // lerp_color
776 -
777 - #[test]
778 - fn lerp_color_t0_returns_a() {
779 - let a = Color32::from_rgb(100, 150, 200);
780 - let b = Color32::from_rgb(200, 50, 0);
781 - assert_eq!(lerp_color(a, b, 0.0), a);
782 - }
783 -
784 - #[test]
785 - fn lerp_color_t1_returns_b() {
786 - let a = Color32::from_rgb(100, 150, 200);
787 - let b = Color32::from_rgb(200, 50, 0);
788 - assert_eq!(lerp_color(a, b, 1.0), b);
789 - }
790 -
791 - #[test]
792 - fn lerp_color_midpoint_is_between() {
793 - // OKLab blend: not the sRGB arithmetic mean, but each channel lies
794 - // between the endpoints.
795 - let a = Color32::from_rgb(0, 0, 0);
796 - let b = Color32::from_rgb(100, 200, 50);
797 - let mid = lerp_color(a, b, 0.5);
798 - assert!(mid.r() > 0 && mid.r() < 100);
799 - assert!(mid.g() > 0 && mid.g() < 200);
800 - assert!(mid.b() > 0 && mid.b() < 50);
801 - }
802 -
803 - #[test]
804 - fn lerp_color_quarter_is_closer_to_a() {
805 - let a = Color32::from_rgb(0, 0, 0);
806 - let b = Color32::from_rgb(100, 200, 40);
807 - let quarter = lerp_color(a, b, 0.25);
808 - let half = lerp_color(a, b, 0.5);
809 - // 0.25 sits between a and the midpoint along lightness.
810 - assert!(quarter.g() > 0 && quarter.g() < half.g());
811 - }
812 -
813 - #[test]
814 - fn lerp_color_identical_returns_same() {
815 - let c = Color32::from_rgb(42, 42, 42);
816 - assert_eq!(lerp_color(c, c, 0.5), c);
817 - }
818 -
819 - #[test]
820 - fn lerp_color_black_to_white() {
821 - let black = Color32::from_rgb(0, 0, 0);
822 - let white = Color32::from_rgb(255, 255, 255);
823 - let mid = lerp_color(black, white, 0.5);
824 - // Perceptual mid-gray (r==g==b, strictly between the endpoints).
825 - assert_eq!(mid.r(), mid.g());
826 - assert_eq!(mid.g(), mid.b());
827 - assert!(mid.r() > 0 && mid.r() < 255);
828 - }
829 -
830 - #[test]
831 - fn lerp_color_white_to_black() {
832 - let black = Color32::from_rgb(0, 0, 0);
833 - let white = Color32::from_rgb(255, 255, 255);
834 - let mid = lerp_color(white, black, 0.5);
835 - assert_eq!(mid.r(), mid.g());
836 - assert_eq!(mid.g(), mid.b());
837 - assert!(mid.r() > 0 && mid.r() < 255);
838 - }
741 + // Color-math primitives (contrast_color, is_light, lerp_color,
742 + // relative_luminance, contrast_ratio) have their own tests in
743 + // `super::color::tests` now that the makeover↔egui adapter lives there.
839 744
840 745 // parse_hex
841 746
@@ -1158,11 +1063,12 @@
1158 1063 #[test]
1159 1064 fn theme_colors_default_is_audiofiles() {
1160 1065 let d = ThemeColors::default();
1161 - // The muted sage-and-mocha default skin.
1162 - assert_eq!(d.surface_page, Color32::from_rgb(0xCC, 0xDA, 0xD1));
1163 - assert_eq!(d.content, Color32::from_rgb(0x38, 0x30, 0x2E));
1164 - assert_eq!(d.action, Color32::from_rgb(0x5E, 0x7C, 0x8E));
1165 - assert_eq!(d.border, Color32::from_rgb(0x9C, 0xAE, 0xA9));
1066 + // Mac OS 8 Platinum: warm-neutral chrome, white wells, black text,
1067 + // Appearance-Manager navy for the action ring.
1068 + assert_eq!(d.surface_page, Color32::from_rgb(0xDD, 0xDD, 0xDD));
1069 + assert_eq!(d.content, Color32::from_rgb(0x00, 0x00, 0x00));
1070 + assert_eq!(d.action, Color32::from_rgb(0x3B, 0x5A, 0x9F));
1071 + assert_eq!(d.border, Color32::from_rgb(0x80, 0x80, 0x80));
1166 1072 }
1167 1073
1168 1074 // Bundled theme parsing (round-trip all embedded themes)
@@ -1,0 +1,215 @@
1 + //! Color-math adapter between [`makeover`] (OKLab-based perceptual color
2 + //! utilities) and egui's [`Color32`].
3 + //!
4 + //! `makeover` speaks `Rgb { r, g, b }`; egui speaks `Color32`. Every place the
5 + //! theme wants a perceptual blend, a WCAG-driven readable text pick, or a
6 + //! contrast ratio, it does so through this module — so the seam stays in one
7 + //! place and the rest of the theme code stays egui-native.
8 + //!
9 + //! Kept as a plain module for now. If a second project ever needs the same
10 + //! shim (Alloy's egui surfaces are the most likely candidate), promote to
11 + //! `Libraries/makeover-egui` with the same public API and swap the `use`
12 + //! sites; nothing here depends on audiofiles-specific state.
13 +
14 + use egui::Color32;
15 +
16 + /// Convert egui `Color32` → `makeover::Rgb`, dropping the alpha channel.
17 + /// `makeover`'s color math is defined on opaque sRGB triples; the caller is
18 + /// responsible for compositing anything translucent before this hop.
19 + pub fn to_rgb(c: Color32) -> makeover::Rgb {
20 + makeover::Rgb {
21 + r: c.r(),
22 + g: c.g(),
23 + b: c.b(),
24 + }
25 + }
26 +
27 + /// Convert `makeover::Rgb` → opaque egui `Color32`.
28 + pub fn from_rgb(c: makeover::Rgb) -> Color32 {
29 + Color32::from_rgb(c.r, c.g, c.b)
30 + }
31 +
32 + /// Pick white or black text for legibility on `bg`, by WCAG contrast ratio.
33 + /// Thin wrapper over [`makeover::readable_on`] that stays in egui types.
34 + pub fn contrast_color(bg: Color32) -> Color32 {
35 + from_rgb(makeover::readable_on(to_rgb(bg)))
36 + }
37 +
38 + /// Whether `bg` reads as a light color, using the same WCAG-driven test as
39 + /// [`contrast_color`]. A "light" surface is one where BLACK is the more
40 + /// readable text choice; useful for picking egui's light-vs-dark base preset
41 + /// so `RichText::strong()` and `.weak()` resolve to the right end of the
42 + /// spectrum.
43 + pub fn is_light(bg: Color32) -> bool {
44 + contrast_color(bg) == Color32::BLACK
45 + }
46 +
47 + /// Perceptual (OKLab) blend from `a` to `b` by `t` in `[0, 1]`. Used to
48 + /// derive row stripes, selection highlights, and hover states from a base
49 + /// palette without the muddy midpoints an sRGB lerp would give.
50 + pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
51 + from_rgb(makeover::mix(to_rgb(a), to_rgb(b), t))
52 + }
53 +
54 + /// Relative luminance per WCAG 2.x, in `[0, 1]`. Pure sRGB math — no
55 + /// `makeover` dependency — but lives here so all contrast/readability
56 + /// primitives sit in one module.
57 + pub fn relative_luminance(c: Color32) -> f64 {
58 + fn lin(ch: u8) -> f64 {
59 + let c = ch as f64 / 255.0;
60 + if c <= 0.03928 {
61 + c / 12.92
62 + } else {
63 + ((c + 0.055) / 1.055).powf(2.4)
64 + }
65 + }
66 + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
67 + }
68 +
69 + /// WCAG 2.x contrast ratio between two colors, in `[1, 21]`. Higher is
70 + /// more legible; 4.5:1 is the AA threshold for normal text.
71 + pub fn contrast_ratio(a: Color32, b: Color32) -> f64 {
72 + let (la, lb) = (relative_luminance(a), relative_luminance(b));
73 + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
74 + (hi + 0.05) / (lo + 0.05)
75 + }
76 +
77 + #[cfg(test)]
78 + mod tests {
79 + use super::*;
80 +
81 + // ── contrast_color ──
82 +
83 + #[test]
84 + fn contrast_color_black_bg_returns_white() {
85 + assert_eq!(contrast_color(Color32::BLACK), Color32::WHITE);
86 + }
87 +
88 + #[test]
89 + fn contrast_color_white_bg_returns_black() {
90 + assert_eq!(contrast_color(Color32::WHITE), Color32::BLACK);
91 + }
92 +
93 + #[test]
94 + fn contrast_color_medium_blue_returns_black() {
95 + // #0a84ff is a medium blue; by WCAG contrast ratio black reads better
96 + // than white on it (~5.8:1 vs ~3.6:1), so readable_on picks black.
97 + assert_eq!(
98 + contrast_color(Color32::from_rgb(0x0a, 0x84, 0xff)),
99 + Color32::BLACK
100 + );
101 + }
102 +
103 + #[test]
104 + fn contrast_color_bright_yellow_returns_black() {
105 + assert_eq!(
106 + contrast_color(Color32::from_rgb(0xff, 0xd6, 0x0a)),
107 + Color32::BLACK
108 + );
109 + }
110 +
111 + // ── is_light ──
112 +
113 + #[test]
114 + fn is_light_agrees_with_contrast_color() {
115 + // is_light is a strict alias for "black text is the readable pick".
116 + for (r, g, b) in [
117 + (0, 0, 0),
118 + (255, 255, 255),
119 + (0xEE, 0xEE, 0xEE),
120 + (0x3B, 0x5A, 0x9F),
121 + (0xff, 0xd6, 0x0a),
122 + ] {
123 + let c = Color32::from_rgb(r, g, b);
124 + assert_eq!(is_light(c), contrast_color(c) == Color32::BLACK);
125 + }
126 + }
127 +
128 + // ── lerp_color ──
129 +
130 + #[test]
131 + fn lerp_color_t0_returns_a() {
132 + let a = Color32::from_rgb(100, 150, 200);
133 + let b = Color32::from_rgb(200, 50, 0);
134 + assert_eq!(lerp_color(a, b, 0.0), a);
135 + }
136 +
137 + #[test]
138 + fn lerp_color_t1_returns_b() {
139 + let a = Color32::from_rgb(100, 150, 200);
140 + let b = Color32::from_rgb(200, 50, 0);
141 + assert_eq!(lerp_color(a, b, 1.0), b);
142 + }
143 +
144 + #[test]
145 + fn lerp_color_midpoint_is_between() {
146 + // OKLab blend: not the sRGB arithmetic mean, but each channel lies
147 + // between the endpoints.
148 + let a = Color32::from_rgb(0, 0, 0);
149 + let b = Color32::from_rgb(100, 200, 50);
150 + let mid = lerp_color(a, b, 0.5);
151 + assert!(mid.r() > 0 && mid.r() < 100);
152 + assert!(mid.g() > 0 && mid.g() < 200);
153 + assert!(mid.b() > 0 && mid.b() < 50);
154 + }
155 +
156 + #[test]
157 + fn lerp_color_quarter_is_closer_to_a() {
158 + let a = Color32::from_rgb(0, 0, 0);
159 + let b = Color32::from_rgb(100, 200, 40);
160 + let quarter = lerp_color(a, b, 0.25);
161 + let half = lerp_color(a, b, 0.5);
162 + // 0.25 sits between a and the midpoint along lightness.
163 + assert!(quarter.g() > 0 && quarter.g() < half.g());
164 + }
165 +
166 + #[test]
167 + fn lerp_color_identical_returns_same() {
168 + let c = Color32::from_rgb(42, 42, 42);
169 + assert_eq!(lerp_color(c, c, 0.5), c);
170 + }
171 +
172 + #[test]
173 + fn lerp_color_black_to_white() {
174 + let black = Color32::from_rgb(0, 0, 0);
175 + let white = Color32::from_rgb(255, 255, 255);
176 + let mid = lerp_color(black, white, 0.5);
177 + // Perceptual mid-gray (r==g==b, strictly between the endpoints).
178 + assert_eq!(mid.r(), mid.g());
179 + assert_eq!(mid.g(), mid.b());
180 + assert!(mid.r() > 0 && mid.r() < 255);
181 + }
182 +
183 + #[test]
184 + fn lerp_color_white_to_black() {
185 + let black = Color32::from_rgb(0, 0, 0);
186 + let white = Color32::from_rgb(255, 255, 255);
187 + let mid = lerp_color(white, black, 0.5);
188 + assert_eq!(mid.r(), mid.g());
189 + assert_eq!(mid.g(), mid.b());
190 + assert!(mid.r() > 0 && mid.r() < 255);
191 + }
192 +
193 + // ── relative_luminance / contrast_ratio ──
194 +
195 + #[test]
196 + fn relative_luminance_endpoints() {
197 + assert!((relative_luminance(Color32::BLACK) - 0.0).abs() < 1e-9);
198 + assert!((relative_luminance(Color32::WHITE) - 1.0).abs() < 1e-9);
199 + }
200 +
201 + #[test]
202 + fn contrast_ratio_black_on_white_is_21() {
203 + let r = contrast_ratio(Color32::BLACK, Color32::WHITE);
204 + assert!((r - 21.0).abs() < 1e-6);
205 + }
206 +
207 + #[test]
208 + fn contrast_ratio_is_symmetric() {
209 + let a = Color32::from_rgb(0x3B, 0x5A, 0x9F);
210 + let b = Color32::from_rgb(0xEE, 0xEE, 0xEE);
211 + let r1 = contrast_ratio(a, b);
212 + let r2 = contrast_ratio(b, a);
213 + assert!((r1 - r2).abs() < 1e-12);
214 + }
215 + }