Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:21:19 -03:00

157 lines
7.9 KiB
Rust

//! The built-in themes load, round trip and hold WCAG contrast.
use notescraft_themes::{CalloutKind, Rgba, Theme, Variant, builtin, builtin_ids, builtin_source};
/// Every id the crate ships, in the order the picker shows them.
const EXPECTED_IDS: [&str; 8] =
["dark", "light", "catppuccin-mocha", "catppuccin-latte", "catppuccin-frappe", "catppuccin-macchiato", "solarized-dark", "solarized-light"];
#[test]
fn builtin_ids_are_the_expected_eight() {
assert_eq!(builtin_ids(), &EXPECTED_IDS);
for id in builtin_ids() {
assert!(builtin_source(id).is_some(), "{id} has no source");
assert!(builtin(id).is_some(), "{id} did not load");
}
assert!(builtin("nope").is_none());
assert!(builtin_source("nope").is_none());
assert!(builtin("").is_none());
}
#[test]
fn every_builtin_source_loads_without_diagnostics() {
for id in builtin_ids() {
let source = builtin_source(id).expect("source");
let (theme, diagnostics) = Theme::from_toml(source).expect("valid built-in");
assert!(diagnostics.is_empty(), "{id}: {diagnostics:?}");
assert_eq!(&theme.id, id);
assert!(builtin(id).is_some_and(|loaded| loaded == theme), "{id}: builtin() disagrees with its source");
}
}
#[test]
fn every_builtin_round_trips_through_toml() {
for id in builtin_ids() {
let theme = builtin(id).expect("built-in");
let (again, diagnostics) = Theme::from_toml(&theme.to_toml()).expect("round trip");
assert!(diagnostics.is_empty(), "{id}: {diagnostics:?}");
assert_eq!(again, theme, "{id} did not survive a round trip");
}
}
#[test]
fn every_builtin_carries_all_callout_kinds() {
for id in builtin_ids() {
let theme = builtin(id).expect("built-in");
assert_eq!(theme.callouts.len(), CalloutKind::ALL.len(), "{id}");
for kind in CalloutKind::ALL {
assert_eq!(theme.callout(kind), theme.callouts.get(&kind).copied().unwrap_or(theme.colors.accent), "{id}: {kind:?}");
}
}
}
#[test]
fn catppuccin_uses_the_spec_token_mapping() {
// SPEC §8.2: background=base, surface=mantle, canvas=crust,
// surface_2=surface0, border=surface1, text=text, accent=mauve.
let cases = [
("catppuccin-mocha", "#1e1e2e", "#181825", "#11111b", "#313244", "#45475a", "#cdd6f4", "#a6adc8", "#cba6f7", "#11111b"),
("catppuccin-latte", "#eff1f5", "#e6e9ef", "#dce0e8", "#ccd0da", "#bcc0cc", "#4c4f69", "#54566c", "#8839ef", "#eff1f5"),
("catppuccin-frappe", "#303446", "#292c3c", "#232634", "#414559", "#51576d", "#c6d0f5", "#acb4d2", "#ca9ee6", "#232634"),
("catppuccin-macchiato", "#24273a", "#1e2030", "#181926", "#363a4f", "#494d64", "#cad3f5", "#a5adce", "#c6a0f6", "#181926"),
];
for (id, background, surface, canvas, surface_2, border, text, muted, accent, on_accent) in cases {
let theme = builtin(id).expect("built-in");
let colors = theme.colors;
assert_eq!(colors.background.to_hex(), background, "{id} background");
assert_eq!(colors.surface.to_hex(), surface, "{id} surface");
assert_eq!(colors.canvas.to_hex(), canvas, "{id} canvas");
assert_eq!(colors.surface_2.to_hex(), surface_2, "{id} surface_2");
assert_eq!(colors.border.to_hex(), border, "{id} border");
assert_eq!(colors.text.to_hex(), text, "{id} text");
assert_eq!(colors.text_muted.to_hex(), muted, "{id} text_muted");
assert_eq!(colors.accent.to_hex(), accent, "{id} accent");
assert_eq!(colors.on_accent.to_hex(), on_accent, "{id} on_accent");
assert_eq!(colors.link_unresolved.to_hex(), colors.danger.to_hex(), "{id} danger mirrors link_unresolved");
}
assert_eq!(builtin("catppuccin-mocha").map(|t| t.variant), Some(Variant::Dark));
assert_eq!(builtin("catppuccin-latte").map(|t| t.variant), Some(Variant::Light));
assert_eq!(builtin("solarized-dark").map(|t| t.variant), Some(Variant::Dark));
assert_eq!(builtin("solarized-light").map(|t| t.variant), Some(Variant::Light));
}
#[test]
fn every_builtin_meets_wcag_contrast() {
// SPEC §8.2 requires 4.5:1 for every text/background pair. All eight
// built-ins clear it, so no 3.0:1 relaxation is needed for the link,
// link_unresolved or callout colours either.
for id in builtin_ids() {
let theme = builtin(id).expect("built-in");
let colors = theme.colors;
let pairs = [
("text on background", colors.text, colors.background),
("text on surface", colors.text, colors.surface),
("text on surface_2", colors.text, colors.surface_2),
("text on canvas", colors.text, colors.canvas),
("text on code_background", colors.text, colors.code_background),
("text_muted on background", colors.text_muted, colors.background),
("text_muted on surface", colors.text_muted, colors.surface),
("text_muted on surface_2", colors.text_muted, colors.surface_2),
("text_muted on canvas", colors.text_muted, colors.canvas),
("on_accent on accent", colors.on_accent, colors.accent),
("link on background", colors.link, colors.background),
("link on surface", colors.link, colors.surface),
("link_unresolved on background", colors.link_unresolved, colors.background),
("link_unresolved on surface", colors.link_unresolved, colors.surface),
("success on background", colors.success, colors.background),
("warning on background", colors.warning, colors.background),
("danger on background", colors.danger, colors.background),
];
for (label, foreground, background) in pairs {
let ratio = Rgba::contrast_ratio(foreground, background);
assert!(ratio >= 4.5, "{id}: {label} is {ratio:.2}:1 ({foreground:?} on {background:?})");
}
// Callouts are drawn on the editor background, where they must clear
// the full 4.5:1; on the panel tone behind them 3.0:1 is enough (SPEC §8
// allows the relaxed bound for callout colours).
for kind in CalloutKind::ALL {
let colour = theme.callout(kind);
let on_background = Rgba::contrast_ratio(colour, colors.background);
assert!(on_background >= 4.5, "{id}: callout {kind:?} is {on_background:.2}:1 on the background");
let on_surface = Rgba::contrast_ratio(colour, colors.surface);
assert!(on_surface >= 3.0, "{id}: callout {kind:?} is {on_surface:.2}:1 on the surface");
}
}
}
#[test]
fn every_syntax_token_holds_contrast_on_the_code_background() {
for id in builtin_ids() {
let theme = builtin(id).expect("built-in");
let background = theme.colors.code_background;
for (key, colour) in notescraft_themes::SyntaxColors::KEYS.into_iter().zip(theme.syntax.all()) {
let ratio = Rgba::contrast_ratio(colour, background);
assert!(ratio >= 4.5, "{id}: syntax.{key} is {ratio:.2}:1 on the code background");
}
}
}
#[test]
fn a_user_theme_overrides_one_syntax_token_and_falls_back_for_the_rest() {
let source = "id = \"mine\"\nname = \"Mine\"\nvariant = \"light\"\n\n[syntax]\nkeyword = \"#112233\"\nstring = \"nope\"\nbogus = \"#000000\"\n";
let (theme, diagnostics) = Theme::from_toml(source).expect("loads");
let base = builtin("light").expect("light");
assert_eq!(theme.syntax.keyword, Rgba::parse("#112233").expect("hex"));
assert_eq!(theme.syntax.string, base.syntax.string, "bad value falls back");
assert_eq!(theme.syntax.function, base.syntax.function);
let keys: Vec<&str> = diagnostics.iter().map(|d| d.key.as_str()).collect();
assert!(keys.contains(&"syntax.string") && keys.contains(&"syntax.bogus"), "{keys:?}");
}
#[test]
fn syntax_colours_survive_a_toml_round_trip() {
let theme = builtin("catppuccin-mocha").expect("mocha");
let (again, _) = Theme::from_toml(&theme.to_toml()).expect("reloads");
assert_eq!(again.syntax, theme.syntax);
}