- 1
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 2
- 3
//! Guards the design system's own rule: "keep the desktop and admin surfaces - 4
//! on the same token set — the admin UI is explicitly built to match - 5
//! vak-desktop, not to diverge stylistically." - 6
//! - 7
//! Both UIs declared that set independently, by copy-paste, and had already - 8
//! drifted: `--faint` differed, `--accent-wash` differed in the third decimal, - 9
//! the same status role was `--yellow` in one and `--amber` in the other, and - 10
//! the admin had accumulated dead tokens (`--purple`, `--text-dim`) that - 11
//! nothing referenced and that broke the fixed-status-palette rule. - 12
//! - 13
//! This lives in vak-config rather than a JS test because the workspace has no - 14
//! frontend test runner, and a guard nobody runs is not a guard. - 15
- 16
use std::collections::BTreeMap; - 17
use std::path::{Path, PathBuf}; - 18
- 19
// One client, two hosts (docs/design/48-web-client.md): the workspace - 20
// client moved out of vak-desktop so the browser build could share it. - 21
const DESKTOP: &str = "crates/vak-client-ui/src/styles.css"; - 22
const ADMIN: &str = "crates/vak-admin-ui/src/styles.css"; - 23
- 24
/// Tokens both surfaces define and must agree on. Surface-specific additions - 25
/// (the admin's `--border-strong`, the desktop's alternate themes) are allowed - 26
/// and simply absent here. - 27
const SHARED: &[&str] = &[ - 28
"bg", - 29
"surface", - 30
"surface-raised", - 31
"surface-hover", - 32
"surface-active", - 33
"sidebar", - 34
"border", - 35
"border-soft", - 36
"text", - 37
"text-soft", - 38
"muted", - 39
"faint", - 40
"accent", - 41
"accent-bright", - 42
"accent-wash", - 43
"green", - 44
"yellow", - 45
"red", - 46
"blue", - 47
"mono", - 48
"sans", - 49
"radius-sm", - 50
"radius", - 51
"radius-lg", - 52
"shadow-lg", - 53
]; - 54
- 55
fn root() -> PathBuf { - 56
Path::new(env!("CARGO_MANIFEST_DIR")) - 57
.ancestors() - 58
.nth(2) - 59
.expect("workspace root") - 60
.to_path_buf() - 61
} - 62
- 63
/// Values from the first `:root { … }` block — the light/default declaration. - 64
/// Later theme blocks legitimately override, and are not compared. - 65
fn base_tokens(css: &str) -> BTreeMap<String, String> { - 66
let start = css.find(":root").expect(":root block"); - 67
let body = &css[start..]; - 68
let end = body.find('}').expect("closing brace"); - 69
body[..end] - 70
.lines() - 71
.filter_map(|line| { - 72
let line = line.trim().trim_end_matches(';'); - 73
let (name, value) = line.split_once(':')?; - 74
let name = name.trim().strip_prefix("--")?; - 75
Some((name.to_string(), value.trim().to_string())) - 76
}) - 77
.collect() - 78
} - 79
- 80
fn read(relative: &str) -> BTreeMap<String, String> { - 81
base_tokens(&std::fs::read_to_string(root().join(relative)).expect(relative)) - 82
} - 83
- 84
#[test] - 85
fn both_surfaces_agree_on_every_shared_token() { - 86
let desktop = read(DESKTOP); - 87
let admin = read(ADMIN); - 88
- 89
let mut drift = Vec::new(); - 90
for token in SHARED { - 91
match (desktop.get(*token), admin.get(*token)) { - 92
(Some(d), Some(a)) if d != a => { - 93
drift.push(format!("--{token}: desktop {d} != admin {a}")); - 94
} - 95
(None, _) => drift.push(format!("--{token}: missing from {DESKTOP}")), - 96
(_, None) => drift.push(format!("--{token}: missing from {ADMIN}")), - 97
_ => {} - 98
} - 99
} - 100
assert!( - 101
drift.is_empty(), - 102
"the two surfaces have drifted apart:\n {}", - 103
drift.join("\n ") - 104
); - 105
} - 106
- 107
/// A status role must have one name. The admin called warning `--amber` while - 108
/// the desktop called it `--yellow`, which is how a shared palette silently - 109
/// becomes two palettes. - 110
#[test] - 111
fn status_roles_use_one_vocabulary() { - 112
for (label, path) in [("desktop", DESKTOP), ("admin", ADMIN)] { - 113
let css = std::fs::read_to_string(root().join(path)).expect(path); - 114
assert!( - 115
!css.contains("--amber"), - 116
"{label} still defines --amber; the shared name for that role is --yellow" - 117
); - 118
} - 119
} - 120
- 121
/// Every token defined must be referenced. `--purple` broke the "fixed status - 122
/// set, no second accent" rule while being used by nothing at all. - 123
#[test] - 124
fn no_token_is_defined_and_never_used() { - 125
for (label, dir, css_path) in [ - 126
("desktop", "crates/vak-client-ui/src", DESKTOP), - 127
("admin", "crates/vak-admin-ui/src", ADMIN), - 128
] { - 129
let defined = read(css_path); - 130
let mut sources = String::new(); - 131
collect_sources(&root().join(dir), &mut sources); - 132
- 133
let dead: Vec<&String> = defined - 134
.keys() - 135
.filter(|name| !sources.contains(&format!("var(--{name})"))) - 136
.collect(); - 137
assert!( - 138
dead.is_empty(), - 139
"{label} defines tokens nothing references: {dead:?} — \ - 140
delete them or use them" - 141
); - 142
} - 143
} - 144
- 145
fn collect_sources(dir: &Path, out: &mut String) { - 146
let Ok(entries) = std::fs::read_dir(dir) else { - 147
return; - 148
}; - 149
for entry in entries.flatten() { - 150
let path = entry.path(); - 151
if path.is_dir() { - 152
collect_sources(&path, out); - 153
} else if matches!( - 154
path.extension().and_then(|e| e.to_str()), - 155
Some("css" | "ts" | "tsx") - 156
) && let Ok(text) = std::fs::read_to_string(&path) - 157
{ - 158
out.push_str(&text); - 159
} - 160
} - 161
} - 162
- 163
/// DESIGN.md is the written spec; if the code and the document disagree, one - 164
/// of them is lying to whoever reads it next. Its front matter names colours - 165
/// by what they are for (paper, ink, success), not by token, and records the - 166
/// dark theme under a `dark-` prefix; both are checked. - 167
#[test] - 168
fn design_md_matches_the_shipped_palette() { - 169
let design = std::fs::read_to_string(root().join("DESIGN.md")) - 170
.expect("DESIGN.md") - 171
.to_ascii_lowercase(); - 172
let css = std::fs::read_to_string(root().join(DESKTOP)).expect(DESKTOP); - 173
let light = base_tokens(&css); - 174
let dark = theme_tokens(&css, "dark"); - 175
let light_only = [ - 176
("surface", "surface-raised"), - 177
("muted", "muted"), - 178
("saffron-ink", "yellow"), - 179
("success", "green"), - 180
("danger", "red"), - 181
("info", "blue"), - 182
]; - 183
let both = [ - 184
("paper", "bg"), - 185
("surface", "surface"), - 186
("sidebar", "sidebar"), - 187
("line", "border"), - 188
("ink", "text"), - 189
("ink-2", "text-soft"), - 190
("ink-3", "faint"), - 191
("primary", "accent"), - 192
]; - 193
let dark_only = [("muted", "muted")]; - 194
let mut wrong = Vec::new(); - 195
let mut check = |doc_key: String, shipped: &String, token: &str| { - 196
let needle = format!("{doc_key}: \"{}\"", shipped.to_ascii_lowercase()); - 197
if !design.contains(&needle) { - 198
wrong.push(format!( - 199
"DESIGN.md does not record {doc_key} as {shipped} (--{token})" - 200
)); - 201
} - 202
}; - 203
for (doc_key, token) in both.iter().chain(&light_only) { - 204
check(doc_key.to_string(), light.get(*token).expect(token), token); - 205
} - 206
for (doc_key, token) in both.iter().chain(&dark_only) { - 207
check( - 208
format!("dark-{doc_key}"), - 209
dark.get(*token).expect(token), - 210
token, - 211
); - 212
} - 213
assert!(wrong.is_empty(), "{}", wrong.join("\n ")); - 214
} - 215
- 216
/// `--faint` is mandated by DESIGN.md for *all* placeholder text, timestamps, - 217
/// hints, badges, and tooltips, so a failing value fails systematically. It - 218
/// must clear WCAG AA against the darkest surface it is painted on. - 219
#[test] - 220
fn faint_clears_wcag_aa_on_every_surface_it_sits_on() { - 221
let desktop = read(DESKTOP); - 222
let faint = desktop.get("faint").expect("--faint"); - 223
for ground in ["bg", "surface", "surface-raised"] { - 224
let behind = desktop.get(ground).expect(ground); - 225
let ratio = contrast(faint, behind); - 226
assert!( - 227
ratio >= 4.5, - 228
"--faint {faint} on --{ground} {behind} is {ratio:.2}:1, below WCAG AA (4.5:1)" - 229
); - 230
} - 231
} - 232
- 233
/// Tokens declared inside one `html[data-theme="<name>"]` block. - 234
fn theme_tokens(css: &str, theme: &str) -> BTreeMap<String, String> { - 235
let marker = format!("html[data-theme=\"{theme}\"]"); - 236
let start = css - 237
.find(&marker) - 238
.unwrap_or_else(|| panic!("no {marker} block")); - 239
let body = &css[start..]; - 240
let end = body.find('}').expect("closing brace"); - 241
let mut out = BTreeMap::new(); - 242
// Comments first: a `/* ... */` explaining a token can easily contain a - 243
// colon (`:root`, `3.0:1`), and `split_once(':')` would then read the - 244
// comment as the declaration and silently skip the real token — a test - 245
// that misses a value looks exactly like a test that passed. - 246
let body = strip_comments(&body[..end]); - 247
// Theme blocks pack several declarations per line, unlike `:root`. - 248
for declaration in body.split(';') { - 249
let Some((name, value)) = declaration.split_once(':') else { - 250
continue; - 251
}; - 252
let Some(name) = name.trim().strip_prefix("--") else { - 253
continue; - 254
}; - 255
out.insert(name.to_string(), value.trim().to_string()); - 256
} - 257
out - 258
} - 259
- 260
/// CSS `/* ... */` comments removed. - 261
fn strip_comments(css: &str) -> String { - 262
let mut out = String::with_capacity(css.len()); - 263
let mut rest = css; - 264
while let Some(start) = rest.find("/*") { - 265
out.push_str(&rest[..start]); - 266
match rest[start..].find("*/") { - 267
Some(end) => rest = &rest[start + end + 2..], - 268
None => return out, // unterminated: nothing after it is a token - 269
} - 270
} - 271
out.push_str(rest); - 272
out - 273
} - 274
- 275
/// Every theme is a real ground, so every theme's text must be readable on - 276
/// it — not just the default one. - 277
/// - 278
/// The light palette makes this load-bearing rather than pro-forma: it is - 279
/// the one place where a token inherited from the dark scale would be - 280
/// catastrophic rather than merely off. Burnt Terracotta at `#df795f` is - 281
/// 2.4:1 on white, so light DARKENS the accent instead of brightening it, - 282
/// and this is what keeps that true (docs/design/48-web-client.md §7.1). - 283
#[test] - 284
fn every_theme_clears_wcag_aa_for_body_text() { - 285
let css = std::fs::read_to_string(root().join(DESKTOP)).expect(DESKTOP); - 286
let base = base_tokens(&css); - 287
- 288
for theme in ["light", "dark", "contrast"] { - 289
let overrides = theme_tokens(&css, theme); - 290
// A theme need only redefine what it changes; the rest is `:root`. - 291
let token = |name: &str| -> String { - 292
overrides - 293
.get(name) - 294
.or_else(|| base.get(name)) - 295
.unwrap_or_else(|| panic!("{theme}: no --{name}")) - 296
.clone() - 297
}; - 298
let surface = token("surface"); - 299
for name in ["text", "text-soft", "muted", "faint"] { - 300
let value = token(name); - 301
let ratio = contrast(&value, &surface); - 302
assert!( - 303
ratio >= 4.5, - 304
"{theme}: --{name} {value} on --surface {surface} is {ratio:.2}:1, \ - 305
below WCAG AA (4.5:1)" - 306
); - 307
} - 308
} - 309
} - 310
- 311
/// Text drawn ON the accent fill (primary buttons) has to be readable too, - 312
/// and it is the one place the palette uses a fixed near-black in every - 313
/// theme rather than a per-theme token. - 314
#[test] - 315
fn primary_button_text_is_readable_on_every_accent() { - 316
let css = std::fs::read_to_string(root().join(DESKTOP)).expect(DESKTOP); - 317
let base = base_tokens(&css); - 318
- 319
for theme in ["", "light", "dark", "contrast"] { - 320
// BOTH sides can be overridden per theme. Reading `--on-accent` - 321
// only from `:root` was the first version of this, and it reported - 322
// light as failing after light had already been fixed. - 323
let overrides = if theme.is_empty() { - 324
BTreeMap::new() - 325
} else { - 326
theme_tokens(&css, theme) - 327
}; - 328
let resolve = |name: &str, fallback: &BTreeMap<String, String>| { - 329
overrides - 330
.get(name) - 331
.or_else(|| fallback.get(name)) - 332
.unwrap_or_else(|| panic!("no --{name}")) - 333
.clone() - 334
}; - 335
let accent = resolve("accent", &base); - 336
let on_accent = resolve("on-accent", &base); - 337
let ratio = contrast(&on_accent, &accent); - 338
let label = if theme.is_empty() { "warm" } else { theme }; - 339
assert!( - 340
ratio >= 4.5, - 341
"{label}: --on-accent {on_accent} on --accent {accent} is {ratio:.2}:1, \ - 342
below WCAG AA (4.5:1)" - 343
); - 344
} - 345
} - 346
- 347
fn channel(value: f64) -> f64 { - 348
let value = value / 255.0; - 349
if value <= 0.04045 { - 350
value / 12.92 - 351
} else { - 352
((value + 0.055) / 1.055).powf(2.4) - 353
} - 354
} - 355
- 356
fn luminance(hex: &str) -> f64 { - 357
let hex = hex.trim().trim_start_matches('#'); - 358
// `#fff` is as valid as `#ffffff` and the contrast theme uses it, so a - 359
// 6-digit-only reader does not measure that palette — it panics on it. - 360
let hex: String = if hex.len() == 3 { - 361
hex.chars().flat_map(|c| [c, c]).collect() - 362
} else { - 363
hex.to_string() - 364
}; - 365
assert!(hex.len() == 6, "not a hex colour: {hex}"); - 366
let parse = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex pair") as f64; - 367
0.2126 * channel(parse(0)) + 0.7152 * channel(parse(2)) + 0.0722 * channel(parse(4)) - 368
} - 369
- 370
fn contrast(a: &str, b: &str) -> f64 { - 371
let (x, y) = (luminance(a), luminance(b)); - 372
let (hi, lo) = if x > y { (x, y) } else { (y, x) }; - 373
(hi + 0.05) / (lo + 0.05) - 374
} - 375
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.