Generate the theme catalog from real themes instead of a hand table
The derivation landed in decision 0018 with the catalog and the terminal ANSI palette still fixed tables written by hand. tools/theme-gen reads the TextMate themes under assets/themes/ and emits the catalog and the reference vectors, so the anchors a palette is derived from are the ones the theme actually ships rather than the ones somebody transcribed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
This commit is contained in:
co-authored by
Claude Opus 5
parent
7bee985279
commit
401760d670
@@ -22,12 +22,15 @@
|
||||
pub mod accent;
|
||||
pub mod color;
|
||||
pub mod derive;
|
||||
pub mod generated;
|
||||
pub mod palette;
|
||||
pub mod terminal;
|
||||
|
||||
pub use accent::{ACCENTS, Accent, DEFAULT_ACCENT, accent_or_default};
|
||||
pub use color::{Srgb, contrast_ratio, relative_luminance};
|
||||
pub use derive::{RoleOverrides, ThemeAnchors, derive};
|
||||
pub use palette::{Palette, Provenance};
|
||||
pub use terminal::{AnsiColor, TerminalPalette};
|
||||
|
||||
/// The default theme's identifier.
|
||||
pub const DEFAULT_THEME: &str = "lumbridge-slate";
|
||||
@@ -49,6 +52,36 @@ pub const LUMBRIDGE_SLATE: ThemeAnchors = ThemeAnchors {
|
||||
added: Some(Srgb::from_hex(0x70d6a8)),
|
||||
deleted: Some(Srgb::from_hex(0xff6b6b)),
|
||||
modified: Some(Srgb::from_hex(0xf1b96a)),
|
||||
// The sixteen values `main.rs` already hard-codes for indexed terminal
|
||||
// colours, moved here unchanged, so adopting per-theme terminal palettes
|
||||
// costs the default theme no pixels either. The cursor colour is the one
|
||||
// `main.rs` uses; the colour of the glyph *under* a block cursor was never
|
||||
// chosen, so it derives the way every catalog theme's does — from the
|
||||
// background — rather than being guessed at.
|
||||
terminal: TerminalPalette {
|
||||
background: Srgb::from_hex(0x101620),
|
||||
foreground: Srgb::from_hex(0xdbe5f4),
|
||||
cursor: Srgb::from_hex(0xf4f8ff),
|
||||
cursor_text: Srgb::from_hex(0x101620),
|
||||
ansi: [
|
||||
Srgb::from_hex(0x1d2430),
|
||||
Srgb::from_hex(0xff6b6b),
|
||||
Srgb::from_hex(0x70d6a8),
|
||||
Srgb::from_hex(0xf1c76a),
|
||||
Srgb::from_hex(0x68b5f8),
|
||||
Srgb::from_hex(0xc79bf2),
|
||||
Srgb::from_hex(0x63d5da),
|
||||
Srgb::from_hex(0xdbe5f4),
|
||||
Srgb::from_hex(0x6d7a91),
|
||||
Srgb::from_hex(0xff8b8b),
|
||||
Srgb::from_hex(0x93e6be),
|
||||
Srgb::from_hex(0xf8d98c),
|
||||
Srgb::from_hex(0x8bc8ff),
|
||||
Srgb::from_hex(0xd9b4fb),
|
||||
Srgb::from_hex(0x86e7eb),
|
||||
Srgb::from_hex(0xf4f8ff),
|
||||
],
|
||||
},
|
||||
overrides: RoleOverrides {
|
||||
chrome: Some(Srgb::from_hex(0x090c12)),
|
||||
surface: Some(Srgb::from_hex(0x101620)),
|
||||
@@ -62,8 +95,30 @@ pub const LUMBRIDGE_SLATE: ThemeAnchors = ThemeAnchors {
|
||||
},
|
||||
};
|
||||
|
||||
/// Every theme available. One for now; the catalog lands separately.
|
||||
pub const CATALOG: &[ThemeAnchors] = &[LUMBRIDGE_SLATE];
|
||||
/// The number of themes on offer: the first-party one plus the generated set.
|
||||
const CATALOG_LEN: usize = 1 + generated::catalog::CATALOG.len();
|
||||
|
||||
/// Assembles the catalog with the first-party theme in front.
|
||||
///
|
||||
/// Written out rather than folded into the generated file so that the ordering
|
||||
/// rule lives where a reader looks for it. Decision 0018 requires the default
|
||||
/// theme to keep reproducing the appearance it replaced, and it is the only
|
||||
/// theme allowed to carry `RoleOverrides`; a generated file that knew about it
|
||||
/// could quietly stop putting it first.
|
||||
const fn build_catalog() -> [ThemeAnchors; CATALOG_LEN] {
|
||||
let mut catalog = [LUMBRIDGE_SLATE; CATALOG_LEN];
|
||||
let mut index = 0;
|
||||
while index < generated::catalog::CATALOG.len() {
|
||||
catalog[index + 1] = generated::catalog::CATALOG[index];
|
||||
index += 1;
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
const CATALOG_STORAGE: [ThemeAnchors; CATALOG_LEN] = build_catalog();
|
||||
|
||||
/// Every theme available, `lumbridge-slate` first.
|
||||
pub const CATALOG: &[ThemeAnchors] = &CATALOG_STORAGE;
|
||||
|
||||
/// Looks a theme up by name, falling back to the default.
|
||||
///
|
||||
@@ -90,6 +145,12 @@ mod tests {
|
||||
derive(&LUMBRIDGE_SLATE, accent.resolve(true, LUMBRIDGE_SLATE.fg))
|
||||
}
|
||||
|
||||
/// A theme as the interface will paint it out of the box.
|
||||
fn catalog_palette(theme: &crate::ThemeAnchors) -> crate::Palette {
|
||||
let accent = accent_or_default(crate::DEFAULT_ACCENT);
|
||||
derive(theme, accent.resolve(theme.is_dark(), theme.fg))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_theme_reproduces_the_palette_it_replaced() {
|
||||
// The eleven constants that lived in main.rs. If the engine ever stops
|
||||
@@ -139,22 +200,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog is only usable if every theme in it is readable, so the gate
|
||||
/// is here rather than only in the generator: a theme added by hand, or a
|
||||
/// generator whose contrast rule regressed, has to fail somewhere the test
|
||||
/// suite runs.
|
||||
#[test]
|
||||
fn every_catalog_theme_keeps_text_readable_on_its_own_surface() {
|
||||
fn every_catalog_theme_keeps_body_text_readable_on_its_own_surface() {
|
||||
for theme in CATALOG {
|
||||
let accent = accent_or_default(crate::DEFAULT_ACCENT);
|
||||
let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg));
|
||||
let palette = catalog_palette(theme);
|
||||
let text = contrast_ratio(palette.text, palette.surface);
|
||||
assert!(
|
||||
text >= 4.5,
|
||||
"{}: body text is {text:.2}:1 against its own surface",
|
||||
"{}: body text is {text:.2}:1 against its own surface, below WCAG AA; \
|
||||
the generator should have held this theme back",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary text is the theme author's comment colour, and a comment is
|
||||
/// meant to recede, so a handful of themes put it below the 3:1 large-text
|
||||
/// floor. That is a fact about those themes rather than a defect, and the
|
||||
/// generated list is where it is written down — checked in both directions
|
||||
/// so it can neither grow quietly nor go stale.
|
||||
#[test]
|
||||
fn secondary_text_below_the_readable_floor_is_named_in_the_catalog() {
|
||||
let listed = crate::generated::catalog::SECONDARY_TEXT_BELOW_3_TO_1;
|
||||
for theme in CATALOG {
|
||||
let palette = catalog_palette(theme);
|
||||
let muted = contrast_ratio(palette.muted, palette.surface);
|
||||
let recorded = listed.iter().find(|(name, _)| *name == theme.name);
|
||||
match recorded {
|
||||
None => assert!(
|
||||
muted >= 3.0,
|
||||
"{}: secondary text is {muted:.2}:1 but the catalog does not say so; \
|
||||
regenerate with `cargo run -p theme-gen`",
|
||||
theme.name
|
||||
),
|
||||
Some((_, ratio)) => {
|
||||
assert!(
|
||||
muted < 3.0,
|
||||
"{}: listed as below 3:1 but measures {muted:.2}:1; the list is stale",
|
||||
theme.name
|
||||
);
|
||||
assert!(
|
||||
(muted - ratio).abs() < 0.01,
|
||||
"{}: recorded {ratio:.3}:1 but measures {muted:.3}:1",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, _) in listed {
|
||||
assert!(
|
||||
muted >= 3.0,
|
||||
"{}: secondary text is {muted:.2}:1",
|
||||
theme.name
|
||||
CATALOG.iter().any(|theme| theme.name == *name),
|
||||
"{name} is listed as low-contrast but is not in the catalog"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -164,14 +264,14 @@ mod tests {
|
||||
#[test]
|
||||
fn provenance_colours_stay_distinguishable_in_every_theme() {
|
||||
for theme in CATALOG {
|
||||
let accent = accent_or_default(crate::DEFAULT_ACCENT);
|
||||
let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg));
|
||||
let palette = catalog_palette(theme);
|
||||
let roles = palette.provenance_roles();
|
||||
for (index, first) in roles.iter().enumerate() {
|
||||
for second in &roles[index + 1..] {
|
||||
assert_ne!(
|
||||
first, second,
|
||||
"{}: two provenance colours are identical",
|
||||
"{}: two provenance colours are identical, so a usage value's \
|
||||
source would be unreadable",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
@@ -179,6 +279,63 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal that paints two different escape codes the same colour has
|
||||
/// dropped information the running program encoded. The generator forces the
|
||||
/// eight base hues apart; this is the assertion that says it must.
|
||||
#[test]
|
||||
fn every_theme_keeps_its_eight_base_ansi_hues_apart() {
|
||||
for theme in CATALOG {
|
||||
let hues = theme.terminal.base_hues();
|
||||
for (index, first) in hues.iter().enumerate() {
|
||||
for second in &hues[index + 1..] {
|
||||
assert_ne!(
|
||||
first,
|
||||
second,
|
||||
"{}: ANSI slots {index} and later share {:06x}",
|
||||
theme.name,
|
||||
first.to_hex()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_catalog_leads_with_the_first_party_theme_and_nothing_else_pins_a_role() {
|
||||
assert_eq!(
|
||||
CATALOG.first().map(|theme| theme.name),
|
||||
Some(DEFAULT_THEME),
|
||||
"decision 0018 puts the first-party theme first"
|
||||
);
|
||||
for theme in &CATALOG[1..] {
|
||||
assert_eq!(
|
||||
theme.overrides,
|
||||
crate::RoleOverrides::NONE,
|
||||
"{}: a catalog theme that pins a role is defeating the engine",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_catalog_theme_has_an_identifier_no_other_theme_uses() {
|
||||
let mut seen: Vec<&str> = Vec::with_capacity(CATALOG.len());
|
||||
for theme in CATALOG {
|
||||
assert!(!theme.name.is_empty(), "a theme has no identifier");
|
||||
assert!(
|
||||
!theme.display_name.is_empty(),
|
||||
"{}: no display name to show in a picker",
|
||||
theme.name
|
||||
);
|
||||
assert!(
|
||||
!seen.contains(&theme.name),
|
||||
"{} appears twice; theme_or_default would resolve to whichever came first",
|
||||
theme.name
|
||||
);
|
||||
seen.push(theme.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provenance_maps_to_a_role_and_cannot_see_a_value() {
|
||||
let palette = slate_palette();
|
||||
@@ -193,3 +350,266 @@ mod tests {
|
||||
assert_eq!(theme_or_default(DEFAULT_THEME).name, DEFAULT_THEME);
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog and the derivation, checked against Buzz's original TypeScript.
|
||||
///
|
||||
/// The vectors in [`generated::reference_vectors`] were produced by running that
|
||||
/// TypeScript under Node over the same theme files this catalog was generated
|
||||
/// from — not by re-deriving them, which decision 0018 records as having gone
|
||||
/// wrong once already. A failure here means the Rust port drifted from the
|
||||
/// implementation the catalog's colours were promised to match.
|
||||
#[cfg(test)]
|
||||
mod reference_tests {
|
||||
use crate::color::Srgb;
|
||||
use crate::derive::{RoleOverrides, ThemeAnchors, derive};
|
||||
use crate::generated::catalog::{ANSI_SUBSTITUTIONS, COMMENT_SOURCES, SKIPPED_GIT_KEYS};
|
||||
use crate::generated::reference_vectors::{REFERENCE_VECTORS, ReferenceVector};
|
||||
use crate::palette::Palette;
|
||||
use crate::terminal::TerminalPalette;
|
||||
use crate::{CATALOG, DEFAULT_THEME};
|
||||
|
||||
fn palette_of(vector: &ReferenceVector) -> Palette {
|
||||
let anchors = ThemeAnchors {
|
||||
name: vector.name,
|
||||
display_name: vector.name,
|
||||
bg: vector.bg,
|
||||
fg: vector.fg,
|
||||
comment: vector.comment,
|
||||
added: vector.added,
|
||||
deleted: vector.deleted,
|
||||
modified: vector.modified,
|
||||
terminal: TerminalPalette {
|
||||
background: vector.bg,
|
||||
foreground: vector.fg,
|
||||
cursor: vector.fg,
|
||||
cursor_text: vector.bg,
|
||||
ansi: vector.ansi,
|
||||
},
|
||||
overrides: RoleOverrides::NONE,
|
||||
};
|
||||
derive(&anchors, Srgb::from_hex(0x3b82f6))
|
||||
}
|
||||
|
||||
/// Sixty-four real backgrounds through the luminance bisection, compared
|
||||
/// against the answers the reference gives for each one.
|
||||
#[test]
|
||||
fn the_derivation_reproduces_the_reference_for_every_vendored_theme() {
|
||||
assert!(
|
||||
REFERENCE_VECTORS.len() > 60,
|
||||
"the reference vectors look truncated: {} themes",
|
||||
REFERENCE_VECTORS.len()
|
||||
);
|
||||
for vector in REFERENCE_VECTORS {
|
||||
let palette = palette_of(vector);
|
||||
for (role, ours, theirs) in [
|
||||
("chrome", palette.chrome, vector.chrome),
|
||||
("surface", palette.surface, vector.surface),
|
||||
(
|
||||
"surface_raised",
|
||||
palette.surface_raised,
|
||||
vector.surface_raised,
|
||||
),
|
||||
(
|
||||
"surface_active",
|
||||
palette.surface_active,
|
||||
vector.surface_active,
|
||||
),
|
||||
(
|
||||
"surface_overlay",
|
||||
palette.surface_overlay,
|
||||
vector.surface_overlay,
|
||||
),
|
||||
(
|
||||
"surface_between",
|
||||
palette.surface_between,
|
||||
vector.surface_between,
|
||||
),
|
||||
("border", palette.border, vector.border),
|
||||
("border_quiet", palette.border_quiet, vector.border_quiet),
|
||||
] {
|
||||
assert_eq!(
|
||||
ours.to_hex(),
|
||||
theirs.to_hex(),
|
||||
"{}: {role} derived {:06x}, reference says {:06x}",
|
||||
vector.name,
|
||||
ours.to_hex(),
|
||||
theirs.to_hex()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
palette.is_dark, vector.is_dark,
|
||||
"{}: light/dark classification disagrees",
|
||||
vector.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every catalog theme's anchors, against what the reference extracted from
|
||||
/// the same file. The comment colour is the one field the two can disagree
|
||||
/// on, and only for the themes the catalog names and says why.
|
||||
#[test]
|
||||
fn the_extracted_anchors_reproduce_the_reference() {
|
||||
for theme in &CATALOG[1..] {
|
||||
let vector = reference_for(theme.name);
|
||||
assert_eq!(
|
||||
theme.bg.to_hex(),
|
||||
vector.bg.to_hex(),
|
||||
"{}: background disagrees with the reference",
|
||||
theme.name
|
||||
);
|
||||
assert_eq!(
|
||||
theme.fg.to_hex(),
|
||||
vector.fg.to_hex(),
|
||||
"{}: foreground disagrees with the reference",
|
||||
theme.name
|
||||
);
|
||||
match COMMENT_SOURCES.iter().find(|(name, _)| *name == theme.name) {
|
||||
None => assert_eq!(
|
||||
theme.comment.to_hex(),
|
||||
vector.comment.to_hex(),
|
||||
"{}: comment colour disagrees with the reference and is not \
|
||||
recorded as one of the themes the reference cannot read",
|
||||
theme.name
|
||||
),
|
||||
Some((_, why)) => {
|
||||
assert_eq!(
|
||||
vector.comment.to_hex(),
|
||||
vector.fg.to_hex(),
|
||||
"{}: recorded as unreadable by the reference ({why}), but the \
|
||||
reference did read a colour for it",
|
||||
theme.name
|
||||
);
|
||||
assert_ne!(
|
||||
theme.comment.to_hex(),
|
||||
theme.fg.to_hex(),
|
||||
"{}: recorded as recovered ({why}), yet secondary text is still \
|
||||
the same colour as body text",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The terminal palette, slot by slot, with the recorded collisions as the
|
||||
/// only permitted disagreements.
|
||||
#[test]
|
||||
fn the_terminal_palette_reproduces_the_reference_except_where_a_collision_was_recorded() {
|
||||
for theme in &CATALOG[1..] {
|
||||
let vector = reference_for(theme.name);
|
||||
for (index, ours) in theme.terminal.ansi.iter().enumerate() {
|
||||
let substituted = ANSI_SUBSTITUTIONS.contains(&(theme.name, index));
|
||||
let theirs = vector.ansi[index];
|
||||
if substituted {
|
||||
assert_ne!(
|
||||
ours.to_hex(),
|
||||
theirs.to_hex(),
|
||||
"{}: slot {index} is recorded as substituted but matches the \
|
||||
reference, so the record is stale",
|
||||
theme.name
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
ours.to_hex(),
|
||||
theirs.to_hex(),
|
||||
"{}: slot {index} is {:06x}, reference says {:06x}, and no \
|
||||
substitution is recorded for it",
|
||||
theme.name,
|
||||
ours.to_hex(),
|
||||
theirs.to_hex()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A substitution recorded for a theme that no longer ships would hide a
|
||||
/// real disagreement behind a name nobody checks.
|
||||
#[test]
|
||||
fn every_recorded_substitution_belongs_to_a_theme_that_ships() {
|
||||
for (name, slot) in ANSI_SUBSTITUTIONS {
|
||||
assert!(
|
||||
CATALOG.iter().any(|theme| theme.name == *name),
|
||||
"{name} has a recorded ANSI substitution for slot {slot} but is not in \
|
||||
the catalog"
|
||||
);
|
||||
assert!(
|
||||
*slot < 8 || ANSI_SUBSTITUTIONS.contains(&(*name, slot - 8)),
|
||||
"{name}: bright slot {slot} is recorded as substituted but its base is \
|
||||
not, so nothing explains why it moved"
|
||||
);
|
||||
}
|
||||
for (name, why) in COMMENT_SOURCES {
|
||||
assert!(
|
||||
CATALOG.iter().any(|theme| theme.name == *name),
|
||||
"{name} has a recorded comment source ({why}) but is not in the catalog"
|
||||
);
|
||||
}
|
||||
for (name, role, key) in SKIPPED_GIT_KEYS {
|
||||
assert!(
|
||||
CATALOG.iter().any(|theme| theme.name == *name),
|
||||
"{name} records skipping `{key}` for {role} but is not in the catalog"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three git-derived role colours, against what the reference read from
|
||||
/// the same file. A role whose first key the catalog passed over is allowed
|
||||
/// to differ, and only that role.
|
||||
#[test]
|
||||
fn the_git_role_colours_reproduce_the_reference_except_where_a_key_was_skipped() {
|
||||
for theme in &CATALOG[1..] {
|
||||
let vector = reference_for(theme.name);
|
||||
for (role, ours, theirs) in [
|
||||
("added", theme.added, vector.added),
|
||||
("deleted", theme.deleted, vector.deleted),
|
||||
("modified", theme.modified, vector.modified),
|
||||
] {
|
||||
if SKIPPED_GIT_KEYS
|
||||
.iter()
|
||||
.any(|(name, skipped, _)| *name == theme.name && *skipped == role)
|
||||
{
|
||||
assert_ne!(
|
||||
ours, theirs,
|
||||
"{}: {role} is recorded as having skipped a key but matches the \
|
||||
reference, so the record is stale",
|
||||
theme.name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
ours.map(Srgb::to_hex),
|
||||
theirs.map(Srgb::to_hex),
|
||||
"{}: {role} disagrees with the reference and no skipped key is \
|
||||
recorded for it",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reference_for(name: &str) -> &'static ReferenceVector {
|
||||
REFERENCE_VECTORS
|
||||
.iter()
|
||||
.find(|vector| vector.name == name)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{name} is in the catalog but has no reference vector; rerun \
|
||||
tools/theme-gen/reference/emit-reference-vectors.mjs"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The first-party theme has no upstream file, so it has no vector either.
|
||||
#[test]
|
||||
fn the_first_party_theme_is_the_only_one_without_a_reference_vector() {
|
||||
assert_eq!(CATALOG[0].name, DEFAULT_THEME);
|
||||
for theme in &CATALOG[1..] {
|
||||
assert!(
|
||||
REFERENCE_VECTORS.iter().any(|v| v.name == theme.name),
|
||||
"{} has no reference vector",
|
||||
theme.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user