Skip to content

Commit 4e6e755

Browse files
ymoteclaude
andcommitted
feat(l0): flag text that cannot follow a theme
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) <noreply@anthropic.com>
1 parent 1c0b83a commit 4e6e755

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//! Report theme-portability lints for a card.
2+
//!
3+
//! Usage: cargo run -p splash-ui-l0 --example lint_check -- <card>...
4+
fn main() {
5+
for p in std::env::args().skip(1) {
6+
let Ok(src) = std::fs::read_to_string(&p) else {
7+
continue;
8+
};
9+
let r = splash_ui_l0::check_ui_l0_named("card.l0", &src);
10+
let name = p.rsplit('/').next().unwrap_or(&p);
11+
if r.lints.is_empty() {
12+
println!("{name}: portable");
13+
continue;
14+
}
15+
println!("{name}: {} lint(s), valid={}", r.lints.len(), r.valid);
16+
for l in &r.lints {
17+
println!(" {}:{} {}", l.line, l.column, l.message);
18+
}
19+
}
20+
}

‎crates/splash-ui-l0/src/lib.rs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ pub struct UiL0Report {
9191
/// a digest that moved means the level must be re-derived before the card
9292
/// is accepted, rather than inherited from the record it no longer matches.
9393
pub closure: Vec<(String, u64)>,
94+
/// Advisories: the card is valid and will realize, but something in it will
95+
/// not survive a change of theme. Separate from `diagnostics` on purpose —
96+
/// a lint that could refuse a card would be a rule, and these are not rules.
97+
pub lints: Vec<SyntaxDiagnostic>,
9498
}
9599

96100
/// 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 {
260264
report.closure = component_closure(&card);
261265
report.header = header.clone();
262266
check_header(&header, report.level, &mut report);
267+
check_theme_portability(source, &mut report);
263268
report
264269
}
265270

271+
/// Text that carries its own colour cannot follow a theme.
272+
///
273+
/// A colour-font glyph — an emoji — is painted from the emoji font with the
274+
/// colours baked into it, so `draw_text.color` never reaches it. A card using
275+
/// one as an icon renders correctly under exactly the polarity its author had on
276+
/// screen, and is invisible under the other. Measured: 54 of 967 corpus cards do
277+
/// this, and on a light ground their icons vanish entirely — four separate judge
278+
/// verdicts named it before anyone thought to look for the cause.
279+
///
280+
/// This is an advisory, not a rule. The card is valid; it is just not portable,
281+
/// and nothing else in the pipeline can tell you so.
282+
fn check_theme_portability(source: &str, report: &mut UiL0Report) {
283+
// A conservative sweep of the pictographic blocks plus the variation
284+
// selector. Latin, CJK and punctuation are all monochrome and take the
285+
// theme's ink correctly, so nothing outside these ranges is a concern.
286+
fn pictographic(c: char) -> bool {
287+
matches!(c as u32,
288+
0x1F300..=0x1FAFF // emoji, pictographs, symbols
289+
| 0x2600..=0x27BF // misc symbols and dingbats
290+
| 0x1F000..=0x1F2FF // tiles, enclosed characters
291+
| 0xFE0F // variation selector: "render this in colour"
292+
)
293+
}
294+
295+
for (n, line) in source.lines().enumerate() {
296+
// Only the argument positions that end up as painted text. A comment or
297+
// a `query:` string never reaches a glyph.
298+
for key in ["text:", "glyph:"] {
299+
let mut rest = line;
300+
let mut base = 0usize;
301+
while let Some(at) = rest.find(key) {
302+
let after = &rest[at + key.len()..];
303+
if let Some(open) = after.find('"') {
304+
let body = &after[open + 1..];
305+
if let Some(close) = body.find('"') {
306+
let lit = &body[..close];
307+
if let Some(c) = lit.chars().find(|c| pictographic(*c)) {
308+
report.lints.push(SyntaxDiagnostic {
309+
line: n + 1,
310+
column: base + at + 1,
311+
message: format!(
312+
"{c:?} is a colour-font glyph, so it ignores the theme's \
313+
ink and stays the colour it was drawn in — this card will \
314+
render correctly in one polarity and invisibly in the \
315+
other. Use an icon role, or accept that the card is \
316+
single-theme."
317+
),
318+
});
319+
}
320+
base += at + key.len() + open + 1 + close + 1;
321+
rest = &body[close + 1..];
322+
continue;
323+
}
324+
}
325+
break;
326+
}
327+
}
328+
}
329+
}
330+
266331
/// §7: a declared level must match the derived one, and escalation is never
267332
/// silent. Reading the header without comparing it would leave the declaration
268333
/// decorative — which is what it was.
@@ -432,6 +497,7 @@ impl Diagnostics {
432497
level,
433498
diagnostics: self.items,
434499
diagnostics_truncated: self.truncated,
500+
lints: Vec::new(),
435501
}
436502
}
437503
}

‎crates/splash-ui-l0/tests/profile.rs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9294,3 +9294,52 @@ fn every_source_answers_for_its_own_lifecycle() {
92949294
"two trips, two probes — never one merged state"
92959295
);
92969296
}
9297+
9298+
// ───────────────────────────────────────────────── theme portability ──
9299+
9300+
/// An emoji is painted from the colour-font with its colours baked in, so
9301+
/// `draw_text.color` never reaches it. A card using one as an icon renders in
9302+
/// one polarity and vanishes in the other — measured across the corpus at 81
9303+
/// cards, and named in four separate judge verdicts before the cause was found.
9304+
#[test]
9305+
fn an_emoji_used_as_an_icon_is_flagged_but_does_not_refuse_the_card() {
9306+
const CARD: &str = "\
9307+
source place sys.geocode(name: \"Kyoto\")
9308+
9309+
view root Col {
9310+
Row(align: .center, gap: 10) {
9311+
TextRow(text: \"🍽\")
9312+
TextRow(text: place.name)
9313+
}
9314+
}
9315+
";
9316+
let r = splash_ui_l0::check_ui_l0_named("card.l0", CARD);
9317+
assert!(
9318+
r.valid,
9319+
"the card is well-formed; a lint must not refuse it"
9320+
);
9321+
assert_eq!(r.lints.len(), 1, "one emoji, one lint");
9322+
assert_eq!(r.lints[0].line, 5);
9323+
assert!(
9324+
r.lints[0].message.contains("colour-font"),
9325+
"the lint should say WHY it cannot be themed: {}",
9326+
r.lints[0].message
9327+
);
9328+
}
9329+
9330+
/// Latin, CJK and punctuation are monochrome and take the theme's ink. Only the
9331+
/// pictographic blocks carry their own colour, and a lint that fired on ordinary
9332+
/// text would be noise on every card in the corpus.
9333+
#[test]
9334+
fn ordinary_text_carries_no_portability_lint() {
9335+
const CARD: &str = "\
9336+
view root Col {
9337+
TextRow(text: \"12 km NW of La Romana\")
9338+
TextRow(text: \"今天天氣很好\")
9339+
TextRow(text: \"Café naïve — em dash… ellipsis\")
9340+
}
9341+
";
9342+
let r = splash_ui_l0::check_ui_l0_named("card.l0", CARD);
9343+
assert!(r.valid);
9344+
assert!(r.lints.is_empty(), "unexpected lints: {:?}", r.lints);
9345+
}

0 commit comments

Comments
 (0)