| 952 |
952 |
|
format!(":root {{\n{}}}\n", intent_css_declarations(tokens))
|
| 953 |
953 |
|
}
|
| 954 |
954 |
|
|
|
955 |
+ |
// ============================================================================
|
|
956 |
+ |
// Every theme in one sheet, keyed by a root attribute.
|
|
957 |
+ |
//
|
|
958 |
+ |
// The block above serves one theme: a consumer resolves the chosen id, renders
|
|
959 |
+ |
// `:root`, and links the result. Changing the pin then means rendering a new
|
|
960 |
+ |
// sheet and getting the document to re-link it, which an htmx navigation does
|
|
961 |
+ |
// not do -- so a pinned change landed at the next launch and the screen had to
|
|
962 |
+ |
// apologise for it in a hint.
|
|
963 |
+ |
//
|
|
964 |
+ |
// The fix is to stop encoding the choice in *which* sheet is linked. One sheet
|
|
965 |
+ |
// carries every theme, each behind `:root[data-theme="<id>"]`, and choosing is
|
|
966 |
+ |
// setting an attribute. No reload, no second request, and the picker can
|
|
967 |
+ |
// preview a theme by writing the attribute and undo by writing the old one.
|
|
968 |
+ |
//
|
|
969 |
+ |
// It is a separate emitter rather than a wider `intent_css_vars` because the
|
|
970 |
+ |
// bundle is not free: 31 themes of custom properties, against the one block a
|
|
971 |
+ |
// server-rendered page injects per response. MNW ships a single theme and must
|
|
972 |
+ |
// keep paying for a single theme, so this is opt-in by being its own call.
|
|
973 |
+ |
// ============================================================================
|
|
974 |
+ |
|
|
975 |
+ |
/// The root attribute [`all_themes_css`] keys its blocks on.
|
|
976 |
+ |
///
|
|
977 |
+ |
/// Stated here so a consumer's frontend and its stylesheet cannot disagree
|
|
978 |
+ |
/// about the spelling; a picker writes this attribute on `document
|
|
979 |
+ |
/// .documentElement` and nothing else has to change.
|
|
980 |
+ |
pub const THEME_ATTRIBUTE: &str = "data-theme";
|
|
981 |
+ |
|
|
982 |
+ |
/// Emit one theme's intent layer keyed by [`THEME_ATTRIBUTE`], as
|
|
983 |
+ |
/// `:root[data-theme="<id>"] { … }`.
|
|
984 |
+ |
///
|
|
985 |
+ |
/// The attribute selector outranks the bare `:root` of [`intent_css_vars`],
|
|
986 |
+ |
/// including one inside a media query, so a sheet may carry an
|
|
987 |
+ |
/// ambient-following default and let a pin override it without `!important`
|
|
988 |
+ |
/// and without ordering games.
|
|
989 |
+ |
pub fn keyed_intent_css_vars(id: &str, tokens: &SemanticTokens) -> String {
|
|
990 |
+ |
format!(
|
|
991 |
+ |
":root[{THEME_ATTRIBUTE}=\"{id}\"] {{\n{}}}\n",
|
|
992 |
+ |
intent_css_declarations(tokens)
|
|
993 |
+ |
)
|
|
994 |
+ |
}
|
|
995 |
+ |
|
|
996 |
+ |
/// Every theme in `dirs` as one stylesheet: an ambient-following default, then
|
|
997 |
+ |
/// a keyed block per theme.
|
|
998 |
+ |
///
|
|
999 |
+ |
/// The sheet a consumer links once and never re-links. Setting
|
|
1000 |
+ |
/// [`THEME_ATTRIBUTE`] on the root element pins a theme; removing it, or
|
|
1001 |
+ |
/// setting it to anything that names no theme (`"system"`, say), falls back to
|
|
1002 |
+ |
/// the default blocks, which follow the OS through `prefers-color-scheme` and
|
|
1003 |
+ |
/// `prefers-contrast`. Those are the same three ambient modes
|
|
1004 |
+ |
/// [`ThemeSelection::resolve`] answers, so a sheet and a Rust-side resolution
|
|
1005 |
+ |
/// of the same selection agree.
|
|
1006 |
+ |
///
|
|
1007 |
+ |
/// `defaults` names the app's own fallbacks. A high-contrast default is only
|
|
1008 |
+ |
/// emitted when [`ThemeDefaults::high_contrast`] named one: falling back to the
|
|
1009 |
+ |
/// dark theme is right for a resolution and wrong for a media query, where it
|
|
1010 |
+ |
/// would answer `prefers-contrast: more` with a theme that is not one.
|
|
1011 |
+ |
///
|
|
1012 |
+ |
/// Themes that fail to load are skipped rather than failing the sheet: a
|
|
1013 |
+ |
/// consumer's custom directory is user-writable, and one unparseable file
|
|
1014 |
+ |
/// there should cost that file's block and nothing else.
|
|
1015 |
+ |
///
|
|
1016 |
+ |
/// Blocks are ordered by id so the output is byte-stable, which is what lets a
|
|
1017 |
+ |
/// caller cache it or compare two builds.
|
|
1018 |
+ |
pub fn all_themes_css(dirs: &[(PathBuf, bool)], defaults: &ThemeDefaults) -> String {
|
|
1019 |
+ |
let available = list_themes_from_dirs(dirs);
|
|
1020 |
+ |
let mut out = String::new();
|
|
1021 |
+ |
|
|
1022 |
+ |
let mut default_block = |variant: Variant, query: Option<&str>| {
|
|
1023 |
+ |
let id = ThemeSelection::Follow.resolve(variant, defaults, &available);
|
|
1024 |
+ |
let Ok(tokens) = load_semantic(dirs, &id) else {
|
|
1025 |
+ |
return;
|
|
1026 |
+ |
};
|
|
1027 |
+ |
match query {
|
|
1028 |
+ |
None => out.push_str(&intent_css_vars(&tokens)),
|
|
1029 |
+ |
Some(query) => {
|
|
1030 |
+ |
out.push_str("\n@media (");
|
|
1031 |
+ |
out.push_str(query);
|
|
1032 |
+ |
out.push_str(") {\n");
|
|
1033 |
+ |
out.push_str(&intent_css_vars(&tokens));
|
|
1034 |
+ |
out.push_str("}\n");
|
|
1035 |
+ |
}
|
|
1036 |
+ |
}
|
|
1037 |
+ |
};
|
|
1038 |
+ |
|
|
1039 |
+ |
default_block(Variant::Light, None);
|
|
1040 |
+ |
default_block(Variant::Dark, Some("prefers-color-scheme: dark"));
|
|
1041 |
+ |
if defaults.names_high_contrast() {
|
|
1042 |
+ |
default_block(Variant::HighContrast, Some("prefers-contrast: more"));
|
|
1043 |
+ |
}
|
|
1044 |
+ |
|
|
1045 |
+ |
let mut ids: Vec<&str> = available.iter().map(|meta| meta.id.as_str()).collect();
|
|
1046 |
+ |
ids.sort_unstable();
|
|
1047 |
+ |
for id in ids {
|
|
1048 |
+ |
if let Ok(tokens) = load_semantic(dirs, id) {
|
|
1049 |
+ |
out.push('\n');
|
|
1050 |
+ |
out.push_str(&keyed_intent_css_vars(id, &tokens));
|
|
1051 |
+ |
}
|
|
1052 |
+ |
}
|
|
1053 |
+ |
|
|
1054 |
+ |
out
|
|
1055 |
+ |
}
|
|
1056 |
+ |
|
| 955 |
1057 |
|
// ============================================================================
|
| 956 |
1058 |
|
// Typography — layer 1 of the house font model.
|
| 957 |
1059 |
|
//
|
| 1693 |
1795 |
|
self
|
| 1694 |
1796 |
|
}
|
| 1695 |
1797 |
|
|
|
1798 |
+ |
/// Whether a high-contrast default was named.
|
|
1799 |
+ |
///
|
|
1800 |
+ |
/// [`for_variant`] answers for every mode by falling back to the dark
|
|
1801 |
+ |
/// theme, which is right for resolving a selection and wrong for emitting
|
|
1802 |
+ |
/// a `prefers-contrast: more` block: that block would then answer the
|
|
1803 |
+ |
/// preference with a theme that does not honour it. A caller that renders
|
|
1804 |
+ |
/// per ambient mode asks this first.
|
|
1805 |
+ |
///
|
|
1806 |
+ |
/// [`for_variant`]: ThemeDefaults::for_variant
|
|
1807 |
+ |
#[must_use]
|
|
1808 |
+ |
pub const fn names_high_contrast(&self) -> bool {
|
|
1809 |
+ |
self.high_contrast.is_some()
|
|
1810 |
+ |
}
|
|
1811 |
+ |
|
| 1696 |
1812 |
|
#[must_use]
|
| 1697 |
1813 |
|
pub fn for_variant(&self, variant: Variant) -> &str {
|
| 1698 |
1814 |
|
match variant {
|
| 3307 |
3423 |
|
assert!(css.trim_end().ends_with('}'));
|
| 3308 |
3424 |
|
}
|
| 3309 |
3425 |
|
|
|
3426 |
+ |
// ---- every theme in one sheet ----
|
|
3427 |
+ |
|
|
3428 |
+ |
/// The shipped themes, as the search path a consumer hands the emitter.
|
|
3429 |
+ |
fn shipped() -> Vec<(PathBuf, bool)> {
|
|
3430 |
+ |
vec![(
|
|
3431 |
+ |
bundled_themes_dir().expect("makeover ships its themes"),
|
|
3432 |
+ |
false,
|
|
3433 |
+ |
)]
|
|
3434 |
+ |
}
|
|
3435 |
+ |
|
|
3436 |
+ |
#[test]
|
|
3437 |
+ |
fn a_keyed_block_carries_the_same_declarations_as_a_root_one() {
|
|
3438 |
+ |
let tokens = resolve(&parse_theme_str("nord", nord_toml(), false).unwrap());
|
|
3439 |
+ |
let keyed = keyed_intent_css_vars("nord", &tokens);
|
|
3440 |
+ |
assert!(
|
|
3441 |
+ |
keyed.starts_with(":root[data-theme=\"nord\"] {\n"),
|
|
3442 |
+ |
"{keyed}"
|
|
3443 |
+ |
);
|
|
3444 |
+ |
assert_eq!(
|
|
3445 |
+ |
keyed.replace(":root[data-theme=\"nord\"]", ":root"),
|
|
3446 |
+ |
intent_css_vars(&tokens),
|
|
3447 |
+ |
"the two emitters differ only in the selector"
|
|
3448 |
+ |
);
|
|
3449 |
+ |
}
|
|
3450 |
+ |
|
|
3451 |
+ |
#[test]
|
|
3452 |
+ |
fn every_installed_theme_gets_a_block_and_they_are_in_id_order() {
|
|
3453 |
+ |
let dirs = shipped();
|
|
3454 |
+ |
let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
|
|
3455 |
+ |
|
|
3456 |
+ |
let keys: Vec<&str> = css
|
|
3457 |
+ |
.match_indices(":root[data-theme=\"")
|
|
3458 |
+ |
.map(|(at, prefix)| {
|
|
3459 |
+ |
let rest = &css[at + prefix.len()..];
|
|
3460 |
+ |
&rest[..rest.find('"').unwrap()]
|
|
3461 |
+ |
})
|
|
3462 |
+ |
.collect();
|
|
3463 |
+ |
|
|
3464 |
+ |
let mut expected: Vec<String> = list_themes_from_dirs(&dirs)
|
|
3465 |
+ |
.into_iter()
|
|
3466 |
+ |
.map(|meta| meta.id)
|
|
3467 |
+ |
.collect();
|
|
3468 |
+ |
expected.sort();
|
|
3469 |
+ |
assert_eq!(keys, expected, "one block per theme, ordered by id");
|
|
3470 |
+ |
assert!(
|
|
3471 |
+ |
keys.len() > 20,
|
|
3472 |
+ |
"the shipped set is the whole picker: {keys:?}"
|
|
3473 |
+ |
);
|
|
3474 |
+ |
}
|
|
3475 |
+ |
|
|
3476 |
+ |
/// The property the whole sheet exists for: a pin is an attribute, and it
|
|
3477 |
+ |
/// beats the ambient default without `!important` or ordering games.
|
|
3478 |
+ |
#[test]
|
|
3479 |
+ |
fn the_default_follows_the_system_and_a_pin_outranks_it() {
|
|
3480 |
+ |
let css = all_themes_css(
|
|
3481 |
+ |
&shipped(),
|
|
3482 |
+ |
&ThemeDefaults::new("goingson", "catppuccin-mocha"),
|
|
3483 |
+ |
);
|
|
3484 |
+ |
|
|
3485 |
+ |
assert!(css.starts_with(":root {\n"), "the light default is first");
|
|
3486 |
+ |
assert!(css.contains("@media (prefers-color-scheme: dark) {\n:root {\n"));
|
|
3487 |
+ |
|
|
3488 |
+ |
// Specificity, not order: (0,1,0) for the default against (0,2,0) for
|
|
3489 |
+ |
// a keyed block. Asserted as the fact that the keyed blocks follow the
|
|
3490 |
+ |
// defaults, which is the ordering that would matter if they tied.
|
|
3491 |
+ |
let dark = css.find("prefers-color-scheme").unwrap();
|
|
3492 |
+ |
let first_key = css.find(":root[data-theme=").unwrap();
|
|
3493 |
+ |
assert!(dark < first_key, "defaults, then the keyed blocks");
|
|
3494 |
+ |
}
|
|
3495 |
+ |
|
|
3496 |
+ |
/// `for_variant` answers every mode by falling back to dark, so emitting a
|
|
3497 |
+ |
/// `prefers-contrast` block unconditionally would answer the preference
|
|
3498 |
+ |
/// with a theme that does not honour it.
|
|
3499 |
+ |
#[test]
|
|
3500 |
+ |
fn a_high_contrast_block_appears_only_when_one_was_named() {
|
|
3501 |
+ |
let dirs = shipped();
|
|
3502 |
+ |
let plain = ThemeDefaults::new("goingson", "catppuccin-mocha");
|
|
3503 |
+ |
assert!(!all_themes_css(&dirs, &plain).contains("prefers-contrast"));
|
|
3504 |
+ |
|
|
3505 |
+ |
let named = plain.clone().high_contrast("high-contrast");
|
|
3506 |
+ |
let css = all_themes_css(&dirs, &named);
|
|
3507 |
+ |
assert!(
|
|
3508 |
+ |
css.contains("@media (prefers-contrast: more) {\n:root {\n"),
|
|
3509 |
+ |
"{css}"
|
|
3510 |
+ |
);
|
|
3511 |
+ |
}
|
|
3512 |
+ |
|
|
3513 |
+ |
/// A consumer's custom directory is user-writable, so one bad file there
|
|
3514 |
+ |
/// costs its own block and nothing else.
|
|
3515 |
+ |
#[test]
|
|
3516 |
+ |
fn an_unloadable_theme_is_skipped_rather_than_failing_the_sheet() {
|
|
3517 |
+ |
let custom = tempfile::tempdir().unwrap();
|
|
3518 |
+ |
fs::write(custom.path().join("broken.toml"), "this is not = = toml").unwrap();
|
|
3519 |
+ |
fs::write(
|
|
3520 |
+ |
custom.path().join("mine.toml"),
|
|
3521 |
+ |
"[meta]\nname = \"Mine\"\nvariant = \"dark\"\n[surface]\npage = \"#101010\"\n",
|
|
3522 |
+ |
)
|
|
3523 |
+ |
.unwrap();
|
|
3524 |
+ |
|
|
3525 |
+ |
let mut dirs = shipped();
|
|
3526 |
+ |
dirs.push((custom.path().to_path_buf(), true));
|
|
3527 |
+ |
let css = all_themes_css(&dirs, &ThemeDefaults::new("goingson", "catppuccin-mocha"));
|
|
3528 |
+ |
|
|
3529 |
+ |
assert!(
|
|
3530 |
+ |
css.contains(":root[data-theme=\"mine\"] {"),
|
|
3531 |
+ |
"a custom theme is switchable too"
|
|
3532 |
+ |
);
|
|
3533 |
+ |
assert!(!css.contains("data-theme=\"broken\""), "{css}");
|
|
3534 |
+ |
assert!(
|
|
3535 |
+ |
css.contains(":root[data-theme=\"nord\"] {"),
|
|
3536 |
+ |
"the rest of the sheet survives"
|
|
3537 |
+ |
);
|
|
3538 |
+ |
}
|
|
3539 |
+ |
|
| 3310 |
3540 |
|
// ---- typography ----
|
| 3311 |
3541 |
|
|
| 3312 |
3542 |
|
#[test]
|