From 4e6e755030ae4671c2dbbae852c6fd6ce033dbde Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:41:41 -0700 Subject: [PATCH] feat(l0): flag text that cannot follow a theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An emoji is painted from the colour font with its colours baked in, so `draw_text.color` never reaches it. A card that uses one as an icon renders correctly under exactly the polarity its author had on screen, and is invisible under the other β€” white plates on cream, in the case that surfaced it. This cost real measurement time to find. Four separate judge verdicts said "white icons vanish on cream" while an experiment assumed the palette was at fault and spent two rounds tuning it; the icons were never icon widgets, they were `TextRow(text: "🍽")`, and no palette can tint them. `UiL0Report` gains a `lints` channel, separate from `diagnostics` on purpose: a lint that could refuse a card would be a rule, and this is not a rule. The card is valid and will realize. It is just not portable, and nothing else in the pipeline can tell you so. Swept over the 967-card corpus it flags 81 cards and 138 glyphs β€” mostly 🌳, β˜• and 🍽 standing in for icons β€” and refuses none of them. Latin, CJK, accents, em dashes and ellipses are monochrome and carry no lint. Co-Authored-By: Claude Opus 5 (1M context) --- crates/splash-ui-l0/examples/lint_check.rs | 20 +++++++ crates/splash-ui-l0/src/lib.rs | 66 ++++++++++++++++++++++ crates/splash-ui-l0/tests/profile.rs | 49 ++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 crates/splash-ui-l0/examples/lint_check.rs diff --git a/crates/splash-ui-l0/examples/lint_check.rs b/crates/splash-ui-l0/examples/lint_check.rs new file mode 100644 index 0000000..cc2c5fb --- /dev/null +++ b/crates/splash-ui-l0/examples/lint_check.rs @@ -0,0 +1,20 @@ +//! Report theme-portability lints for a card. +//! +//! Usage: cargo run -p splash-ui-l0 --example lint_check -- ... +fn main() { + for p in std::env::args().skip(1) { + let Ok(src) = std::fs::read_to_string(&p) else { + continue; + }; + let r = splash_ui_l0::check_ui_l0_named("card.l0", &src); + let name = p.rsplit('/').next().unwrap_or(&p); + if r.lints.is_empty() { + println!("{name}: portable"); + continue; + } + println!("{name}: {} lint(s), valid={}", r.lints.len(), r.valid); + for l in &r.lints { + println!(" {}:{} {}", l.line, l.column, l.message); + } + } +} diff --git a/crates/splash-ui-l0/src/lib.rs b/crates/splash-ui-l0/src/lib.rs index 9cc4fdb..cf6c64f 100644 --- a/crates/splash-ui-l0/src/lib.rs +++ b/crates/splash-ui-l0/src/lib.rs @@ -91,6 +91,10 @@ pub struct UiL0Report { /// a digest that moved means the level must be re-derived before the card /// is accepted, rather than inherited from the record it no longer matches. pub closure: Vec<(String, u64)>, + /// Advisories: the card is valid and will realize, but something in it will + /// not survive a change of theme. Separate from `diagnostics` on purpose β€” + /// a lint that could refuse a card would be a rule, and these are not rules. + pub lints: Vec, } /// What a card's header declares β€” ledger name, version, level and profile. @@ -260,9 +264,70 @@ pub fn check_ui_l0_named(_name: &str, source: &str) -> UiL0Report { report.closure = component_closure(&card); report.header = header.clone(); check_header(&header, report.level, &mut report); + check_theme_portability(source, &mut report); report } +/// Text that carries its own colour cannot follow a theme. +/// +/// A colour-font glyph β€” an emoji β€” is painted from the emoji font with the +/// colours baked into it, so `draw_text.color` never reaches it. A card using +/// one as an icon renders correctly under exactly the polarity its author had on +/// screen, and is invisible under the other. Measured: 54 of 967 corpus cards do +/// this, and on a light ground their icons vanish entirely β€” four separate judge +/// verdicts named it before anyone thought to look for the cause. +/// +/// This is an advisory, not a rule. The card is valid; it is just not portable, +/// and nothing else in the pipeline can tell you so. +fn check_theme_portability(source: &str, report: &mut UiL0Report) { + // A conservative sweep of the pictographic blocks plus the variation + // selector. Latin, CJK and punctuation are all monochrome and take the + // theme's ink correctly, so nothing outside these ranges is a concern. + fn pictographic(c: char) -> bool { + matches!(c as u32, + 0x1F300..=0x1FAFF // emoji, pictographs, symbols + | 0x2600..=0x27BF // misc symbols and dingbats + | 0x1F000..=0x1F2FF // tiles, enclosed characters + | 0xFE0F // variation selector: "render this in colour" + ) + } + + for (n, line) in source.lines().enumerate() { + // Only the argument positions that end up as painted text. A comment or + // a `query:` string never reaches a glyph. + for key in ["text:", "glyph:"] { + let mut rest = line; + let mut base = 0usize; + while let Some(at) = rest.find(key) { + let after = &rest[at + key.len()..]; + if let Some(open) = after.find('"') { + let body = &after[open + 1..]; + if let Some(close) = body.find('"') { + let lit = &body[..close]; + if let Some(c) = lit.chars().find(|c| pictographic(*c)) { + report.lints.push(SyntaxDiagnostic { + line: n + 1, + column: base + at + 1, + message: format!( + "{c:?} is a colour-font glyph, so it ignores the theme's \ + ink and stays the colour it was drawn in β€” this card will \ + render correctly in one polarity and invisibly in the \ + other. Use an icon role, or accept that the card is \ + single-theme." + ), + }); + } + base += at + key.len() + open + 1 + close + 1; + rest = &body[close + 1..]; + continue; + } + } + break; + } + } + } +} + /// Β§7: a declared level must match the derived one, and escalation is never /// silent. Reading the header without comparing it would leave the declaration /// decorative β€” which is what it was. @@ -432,6 +497,7 @@ impl Diagnostics { level, diagnostics: self.items, diagnostics_truncated: self.truncated, + lints: Vec::new(), } } } diff --git a/crates/splash-ui-l0/tests/profile.rs b/crates/splash-ui-l0/tests/profile.rs index 08be442..6bf0145 100644 --- a/crates/splash-ui-l0/tests/profile.rs +++ b/crates/splash-ui-l0/tests/profile.rs @@ -9294,3 +9294,52 @@ fn every_source_answers_for_its_own_lifecycle() { "two trips, two probes β€” never one merged state" ); } + +// ───────────────────────────────────────────────── theme portability ── + +/// An emoji is painted from the colour-font with its colours baked in, so +/// `draw_text.color` never reaches it. A card using one as an icon renders in +/// one polarity and vanishes in the other β€” measured across the corpus at 81 +/// cards, and named in four separate judge verdicts before the cause was found. +#[test] +fn an_emoji_used_as_an_icon_is_flagged_but_does_not_refuse_the_card() { + const CARD: &str = "\ +source place sys.geocode(name: \"Kyoto\") + +view root Col { + Row(align: .center, gap: 10) { + TextRow(text: \"🍽\") + TextRow(text: place.name) + } +} +"; + let r = splash_ui_l0::check_ui_l0_named("card.l0", CARD); + assert!( + r.valid, + "the card is well-formed; a lint must not refuse it" + ); + assert_eq!(r.lints.len(), 1, "one emoji, one lint"); + assert_eq!(r.lints[0].line, 5); + assert!( + r.lints[0].message.contains("colour-font"), + "the lint should say WHY it cannot be themed: {}", + r.lints[0].message + ); +} + +/// Latin, CJK and punctuation are monochrome and take the theme's ink. Only the +/// pictographic blocks carry their own colour, and a lint that fired on ordinary +/// text would be noise on every card in the corpus. +#[test] +fn ordinary_text_carries_no_portability_lint() { + const CARD: &str = "\ +view root Col { + TextRow(text: \"12 km NW of La Romana\") + TextRow(text: \"今倩倩氣很ε₯½\") + TextRow(text: \"CafΓ© naΓ―ve β€” em dash… ellipsis\") +} +"; + let r = splash_ui_l0::check_ui_l0_named("card.l0", CARD); + assert!(r.valid); + assert!(r.lints.is_empty(), "unexpected lints: {:?}", r.lints); +}