makeover 3.5.1

Shared theme loading for the make-family apps: TOML theme files parsed into intent-based color tokens, with perceptual derivations and WCAG contrast.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
//! Loading / parsing

use crate::{
    COLOR_SECTIONS, Emphasis, Rgb, STEP_FLOOR, SemanticTokens, ThemeColors, ThemeMeta,
    find_theme_path, resolve, tonal, wcag_contrast,
};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

// Names this module's prose links to, resolved for rustdoc.
#[allow(unused_imports)]
use crate::ansi_intent;

/// Validate a theme ID contains only safe characters (alphanumeric, hyphens, underscores).
pub fn validate_theme_id(id: &str) -> Result<(), String> {
    if !id
        .chars()
        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
    {
        return Err(format!("Invalid theme ID: {id}"));
    }
    Ok(())
}

/// Parse the `[meta]` section into `ThemeMeta`.
///
/// Falls back to the file ID as the name and `"dark"` as the variant.
pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
    let meta = table.get("meta").and_then(|m| m.as_table());
    let name = meta
        .and_then(|m| m.get("name"))
        .and_then(|v| v.as_str())
        .unwrap_or(id)
        .to_string();
    let variant = meta
        .and_then(|m| m.get("variant"))
        .and_then(|v| v.as_str())
        .unwrap_or("dark")
        .to_string();

    ThemeMeta {
        id: id.to_string(),
        name,
        variant,
        is_custom,
    }
}

/// Extract the intent color sections into a flat `HashMap` with dotted keys
/// like `"surface.page"`, `"status.danger"`, `"category.one"`.
///
/// The tonal steps of `content.primary` are filled in here rather than read, by
/// [`derive_tonal_steps`]. Anything a theme authored under those keys is
/// replaced.
pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
    let mut colors = HashMap::new();
    for section in COLOR_SECTIONS {
        if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
            for (key, val) in sect {
                if let Some(color) = val.as_str() {
                    colors.insert(format!("{section}.{key}"), color.to_string());
                }
            }
        }
    }
    derive_tonal_steps(&mut colors);
    colors
}

/// Fill in the tonal steps of `content.primary`, overwriting whatever the theme
/// authored under those keys.
///
/// # Why they are not authored
///
/// `content.secondary` and `content.muted` are not independent colours. They are
/// the ink, one step and two steps back, and a theme that names them separately
/// is stating three times something it stated once — which is how three of the
/// bundled themes came to author a `secondary` *lighter* than their own
/// `primary` (nord, solarized-dark) or identical to it (dracula), inverting the
/// emphasis ramp the whole vocabulary rests on. Deriving them makes
/// `content` > `content-secondary` > `content-muted` true by construction in
/// every theme, including one a user writes.
///
/// Applied at load rather than in [`resolve`] so that there is one answer: the
/// resolved token layer, the ANSI table ([`ansi_intent`] reads authored keys),
/// and every consumer holding a [`ThemeColors`] all see the same value. A
/// derivation visible from only one of those is how a terminal and a webview
/// come to disagree about what muted means.
///
/// Both keys need `content.primary` and `surface.page` to exist and parse. When
/// either is missing the step is skipped and anything authored is left where it
/// is, mirroring the skip-missing behaviour of the rest of the crate — a
/// half-written theme keeps whatever it has rather than losing it.
///
/// # The ratio is a starting point, not the answer
///
/// Each step is pushed further toward the page until it clears [`STEP_FLOOR`]
/// against the ink, so what the theme gets is a step that can be seen rather
/// than a step of the agreed size. The two are the same number in every bundled
/// theme but the two with a pure-black ink, where the ratio has no range to
/// travel in and the nominal step lands 3/255 from where it started.
pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
    let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
    let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
    let (Some(ink), Some(page)) = (ink, page) else {
        return;
    };
    // Each step starts no nearer than the one before it landed, so pushing
    // secondary out cannot carry it past muted and invert the ramp.
    let mut reached = 0.0;
    for (key, step) in [
        ("content.secondary", Emphasis::Secondary),
        ("content.muted", Emphasis::Muted),
    ] {
        let (color, ratio) = step_clearing_floor(ink, page, step.ratio().max(reached));
        reached = ratio;
        colors.insert(key.to_string(), color.to_hex());
    }
}

/// The step `from` of the way from `ink` to `page`, pushed toward `page` until
/// it clears [`STEP_FLOOR`] against the ink it is a step of. Returns the colour
/// and the ratio it was found at.
///
/// A forward scan rather than a solve, because it wants the *first* ratio that
/// clears: contrast against the base rises with the distance travelled, but it
/// rises through sRGB's transfer curve and OKLab's chroma path, and a bisection
/// would trust a monotonicity nothing here guarantees.
///
/// Travel stops at the ground. A theme whose ink and page are the same colour
/// has no step to take, and the ground is the honest answer — nothing past it
/// is a step of the ink any more.
fn step_clearing_floor(ink: Rgb, page: Rgb, from: f32) -> (Rgb, f32) {
    // Finer than 8-bit sRGB can resolve on the shortest ramp in the corpus, so
    // the scan never steps over the first colour that clears.
    const PROBE: f32 = 0.005;
    let mut ratio = from.clamp(0.0, 1.0);
    loop {
        let color = tonal(ink, page, ratio);
        if wcag_contrast(color, ink) >= STEP_FLOOR || ratio >= 1.0 {
            return (color, ratio);
        }
        ratio = (ratio + PROBE).min(1.0);
    }
}

/// Scan directories for `.toml` theme files and return metadata for each.
///
/// Directories are checked in order; later entries override earlier ones by ID.
/// Each entry in `dirs` is `(path, is_custom)`.
pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
    let mut seen: HashMap<String, ThemeMeta> = HashMap::new();

    for (dir, is_custom) in dirs {
        let Ok(entries) = std::fs::read_dir(dir) else {
            continue;
        };

        for entry in entries {
            let Ok(entry) = entry else {
                continue;
            };
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("toml") {
                continue;
            }

            let id = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or_default()
                .to_string();

            let Ok(content) = std::fs::read_to_string(&path) else {
                continue;
            };
            let table: toml::Table = match content.parse() {
                Ok(t) => t,
                Err(_) => continue,
            };

            seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
        }
    }

    let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
    themes.sort_by(|a, b| a.name.cmp(&b.name));
    themes
}

/// Parse a complete theme (metadata + colors) from raw TOML content, with no
/// filesystem access. For callers that embed themes at compile time.
pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
    validate_theme_id(id)?;
    let table: toml::Table = content
        .parse()
        .map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
    let meta = parse_meta(id, &table, is_custom);
    let colors = extract_colors(&table);
    Ok(ThemeColors { meta, colors })
}

/// Load a complete theme (metadata + colors) by ID from the given directories.
pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
    validate_theme_id(id)?;

    let (path, is_custom) =
        find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;

    let content = std::fs::read_to_string(&path)
        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;

    let table: toml::Table = content
        .parse()
        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;

    let meta = parse_meta(id, &table, is_custom);
    let colors = extract_colors(&table);

    Ok(ThemeColors { meta, colors })
}

/// Load a theme and resolve it to the full intent token set in one step.
pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
    Ok(resolve(&load_theme(dirs, id)?))
}

/// Import a theme TOML file into the custom themes directory.
///
/// Validates that the file is parseable TOML with at least one intent color
/// section, then copies it to `custom_dir/{id}.toml`. Returns the theme metadata.
pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
    let content = std::fs::read_to_string(source_path)
        .map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;

    let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;

    let has_colors = COLOR_SECTIONS
        .iter()
        .any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
    if !has_colors {
        return Err(format!(
            "Theme file must have at least one color section ({})",
            COLOR_SECTIONS.join(", ")
        ));
    }

    let id = source_path
        .file_stem()
        .and_then(|s| s.to_str())
        .ok_or("Invalid file name")?
        .to_string();
    validate_theme_id(&id)?;

    std::fs::create_dir_all(custom_dir)
        .map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;

    let dest = custom_dir.join(format!("{id}.toml"));
    std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;

    Ok(parse_meta(&id, &table, true))
}

/// Delete a custom theme by ID.
///
/// Only operates on `custom_dir` — bundled themes are not deletable through
/// this entry point.
pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
    validate_theme_id(id)?;

    let path = custom_dir.join(format!("{id}.toml"));
    if !path.is_file() {
        return Err(format!("Custom theme '{id}' not found"));
    }

    std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
}

/// A four-color preview for theme thumbnails: the representative swatch from
/// each of the principal roles.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemePreview {
    pub meta: ThemeMeta,
    /// Page background (`surface.page`).
    pub background: Option<String>,
    /// Body text (`content.primary`).
    pub foreground: Option<String>,
    /// Brand/interactive color (`action.primary`).
    pub accent: Option<String>,
    /// Divider/outline color (`line.border`).
    pub border: Option<String>,
}

fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
    table
        .get(section)
        .and_then(|s| s.as_table())
        .and_then(|s| s.get(key))
        .and_then(|v| v.as_str())
        .map(std::string::ToString::to_string)
}

/// Load just the preview swatches for a theme — for UI thumbnails.
pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
    validate_theme_id(id)?;

    let (path, is_custom) =
        find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;

    let content = std::fs::read_to_string(&path)
        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;

    let table: toml::Table = content
        .parse()
        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;

    Ok(ThemePreview {
        meta: parse_meta(id, &table, is_custom),
        background: color_at(&table, "surface", "page"),
        foreground: color_at(&table, "content", "primary"),
        accent: color_at(&table, "action", "primary"),
        border: color_at(&table, "line", "border"),
    })
}

/// Export a theme to a user-chosen path.
pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
    validate_theme_id(id)?;

    let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;

    std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::fixture::nord_toml;
    use crate::{bundled_themes_dir, embedded_themes};
    use std::fs;

    // ---- id validation ----

    #[test]
    fn validate_theme_id_alphanumeric() {
        assert!(validate_theme_id("darkmode").is_ok());
        assert!(validate_theme_id("Theme123").is_ok());
    }

    #[test]
    fn validate_theme_id_hyphens_underscores() {
        assert!(validate_theme_id("dark-mode").is_ok());
        assert!(validate_theme_id("my_theme_v2").is_ok());
    }

    #[test]
    fn validate_theme_id_rejects_path_traversal() {
        assert!(validate_theme_id("../etc/passwd").is_err());
        assert!(validate_theme_id("foo/bar").is_err());
        assert!(validate_theme_id("theme.toml").is_err());
    }

    // ---- meta ----

    #[test]
    fn parse_meta_with_name_and_variant() {
        let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
            .parse()
            .unwrap();
        let meta = parse_meta("nord", &table, false);
        assert_eq!(meta.id, "nord");
        assert_eq!(meta.name, "Nord");
        assert_eq!(meta.variant, "light");
        assert!(!meta.is_custom);
    }

    #[test]
    fn parse_meta_defaults_to_id_and_dark() {
        let table: toml::Table = "".parse().unwrap();
        let meta = parse_meta("fallback", &table, true);
        assert_eq!(meta.name, "fallback");
        assert_eq!(meta.variant, "dark");
        assert!(meta.is_custom);
    }

    #[test]
    fn extract_colors_reads_intent_sections() {
        let table: toml::Table = nord_toml().parse().unwrap();
        let colors = extract_colors(&table);
        assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
        assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
        assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
        assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
        assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
        assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
        assert_eq!(colors.len(), 19);
    }

    #[test]
    fn every_shipped_theme_ramps_one_way() {
        // The property authoring the steps separately could not hold: three
        // themes had shipped a secondary lighter than their own primary, so a
        // renderer reading the emphasis order got the reverse of it.
        for (id, toml) in embedded_themes() {
            let theme = parse_theme_str(id, toml, false).unwrap();
            let t = resolve(&theme);
            let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap();
            let steps = ["content", "content-secondary", "content-muted"]
                .map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page));
            assert!(
                steps[0] > steps[1] && steps[1] > steps[2],
                "{id}: emphasis does not fall monotonically: {steps:?}"
            );
        }
    }

    #[test]
    fn every_shipped_theme_takes_a_visible_first_step() {
        // The property that was missing when 2.6.0 derived these, and the
        // reason a pure-black ink shipped a secondary 3/255 away from it: the
        // ramp falling monotonically says nothing about how far it falls, and
        // a step nobody can see is not a step.
        for (id, toml) in embedded_themes() {
            let theme = parse_theme_str(id, toml, false).unwrap();
            let t = resolve(&theme);
            let ink = Rgb::from_hex(t.hex("content").unwrap()).unwrap();
            let secondary = Rgb::from_hex(t.hex("content-secondary").unwrap()).unwrap();
            let step = wcag_contrast(ink, secondary);
            assert!(
                step >= STEP_FLOOR,
                "{id}: secondary is {step:.2} from its ink, under the {STEP_FLOOR} floor"
            );
        }
    }

    #[test]
    fn an_authored_emphasis_step_does_not_survive_loading() {
        // `nord_toml` still authors both, because a user's theme file might and
        // the answer has to be the same one.
        let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
        assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88");
        assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0");
    }

    #[test]
    fn a_theme_with_no_page_keeps_what_it_authored() {
        // Skip-missing: there is nothing to read the step against, so the step
        // is not taken and a half-written theme does not lose a colour.
        let mut colors = HashMap::new();
        colors.insert("content.primary".to_string(), "#d8dee9".to_string());
        colors.insert("content.muted".to_string(), "#616e88".to_string());
        derive_tonal_steps(&mut colors);
        assert_eq!(colors.get("content.muted").unwrap(), "#616e88");
    }

    // ---- loading / fs ----

    #[test]
    fn load_and_resolve_round_trip() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
        let dirs = vec![(dir.path().to_path_buf(), false)];
        let t = load_semantic(&dirs, "nord").unwrap();
        assert_eq!(t.meta.name, "Nord");
        assert_eq!(t.hex("action"), Some("#81a1c1"));
    }

    #[test]
    fn load_theme_rejects_invalid_id() {
        assert!(load_theme(&[], "../evil").is_err());
    }

    #[test]
    fn list_themes_from_dirs_finds_toml_files() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
        fs::write(dir.path().join("x.txt"), "ignored").unwrap();
        let dirs = vec![(dir.path().to_path_buf(), false)];
        let themes = list_themes_from_dirs(&dirs);
        assert_eq!(themes.len(), 1);
        assert_eq!(themes[0].id, "t");
    }

    #[test]
    fn import_theme_valid_and_rejects_empty() {
        let src_dir = tempfile::tempdir().unwrap();
        let custom_dir = tempfile::tempdir().unwrap();

        let good = src_dir.path().join("my-theme.toml");
        fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
        let meta = import_theme(&good, custom_dir.path()).unwrap();
        assert_eq!(meta.id, "my-theme");
        assert!(custom_dir.path().join("my-theme.toml").exists());

        let empty = src_dir.path().join("empty.toml");
        fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
        assert!(import_theme(&empty, custom_dir.path()).is_err());
    }

    #[test]
    fn import_theme_rejects_invalid_toml() {
        let src_dir = tempfile::tempdir().unwrap();
        let custom_dir = tempfile::tempdir().unwrap();
        let src = src_dir.path().join("bad.toml");
        fs::write(&src, "this is not [valid toml [[[").unwrap();
        assert!(import_theme(&src, custom_dir.path()).is_err());
    }

    #[test]
    fn delete_theme_removes_and_guards() {
        let custom = tempfile::tempdir().unwrap();
        let path = custom.path().join("doomed.toml");
        fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap();
        delete_theme(custom.path(), "doomed").unwrap();
        assert!(!path.exists());
        assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
        assert!(delete_theme(custom.path(), "ghost").is_err());
    }

    #[test]
    fn export_theme_copies_file() {
        let src_dir = tempfile::tempdir().unwrap();
        let dest_dir = tempfile::tempdir().unwrap();
        let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n";
        fs::write(src_dir.path().join("e.toml"), content).unwrap();
        let dirs = vec![(src_dir.path().to_path_buf(), false)];
        let dest = dest_dir.path().join("out.toml");
        export_theme(&dirs, "e", &dest).unwrap();
        assert_eq!(fs::read_to_string(&dest).unwrap(), content);
        assert!(export_theme(&dirs, "missing", &dest).is_err());
    }

    #[test]
    fn load_theme_preview_returns_role_swatches() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
        let dirs = vec![(dir.path().to_path_buf(), false)];
        let p = load_theme_preview(&dirs, "nord").unwrap();
        assert_eq!(p.background.as_deref(), Some("#2e3440")); // surface.page
        assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); // content.primary
        assert_eq!(p.accent.as_deref(), Some("#81a1c1")); // action.primary
        assert_eq!(p.border.as_deref(), Some("#4c566a")); // line.border
    }

    #[test]
    fn every_shipped_theme_loads() {
        // Guards the data, not just the loader: a malformed or truncated
        // .toml in themes/ is a shipping bug, and it should fail here rather
        // than at a user's first launch.
        let dir = bundled_themes_dir().unwrap();
        let dirs = vec![(dir.clone(), false)];
        let themes = list_themes_from_dirs(&dirs);
        assert!(
            themes.len() >= 30,
            "expected the full theme set, got {}",
            themes.len()
        );
        for meta in &themes {
            load_theme(&dirs, &meta.id)
                .unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
        }
    }
}