| 402 |
402 |
|
out
|
| 403 |
403 |
|
}
|
| 404 |
404 |
|
|
| 405 |
|
- |
/// Fail the build if a hand-written stylesheet re-specifies a class the
|
| 406 |
|
- |
/// generated one already defines.
|
|
405 |
+ |
/// Fail the build if a hand-written stylesheet takes a property the generated
|
|
406 |
+ |
/// one already sets on the same class.
|
| 407 |
407 |
|
///
|
| 408 |
408 |
|
/// The generated sheet sits in `@layer makeover`. Unlayered app CSS beats a
|
| 409 |
|
- |
/// layer by construction, whatever the specificity, so an app rule naming a
|
| 410 |
|
- |
/// generated class does not merge with it: it wins, silently, and the design
|
| 411 |
|
- |
/// system's version of that component stops applying to the one app most likely
|
| 412 |
|
- |
/// to be treated as the reference. Both sort-caret defects found on 2026-08-11
|
| 413 |
|
- |
/// were this, and both were live for months because nothing looked.
|
|
409 |
+ |
/// layer by construction, whatever the specificity, so an app declaration for a
|
|
410 |
+ |
/// property makeover already sets does not merge with it: it wins, silently,
|
|
411 |
+ |
/// and the design system's version of that component stops applying. Both
|
|
412 |
+ |
/// sort-caret defects found on 2026-08-11 were this, and both were live for
|
|
413 |
+ |
/// months because nothing looked.
|
|
414 |
+ |
///
|
|
415 |
+ |
/// # Why properties and not class names
|
|
416 |
+ |
///
|
|
417 |
+ |
/// A shared class name is not by itself a divergence, and the first run of this
|
|
418 |
+ |
/// check against goingson is what settled it: nine classes are shared and every
|
|
419 |
+ |
/// one is deliberate. `.badge` sets shape in the app and colour in the
|
|
420 |
+ |
/// generated sheet, and the app's own comment beside it reads "Fill, edge and
|
|
421 |
+ |
/// text colour come from the generated .badge in layout.css. Do not add
|
|
422 |
+ |
/// background, border or box-shadow here." That arrangement is correct, so a
|
|
423 |
+ |
/// check on names would have asked for it to be deleted. On properties, the
|
|
424 |
+ |
/// comment becomes the check.
|
|
425 |
+ |
///
|
|
426 |
+ |
/// # The exception list
|
|
427 |
+ |
///
|
|
428 |
+ |
/// `allowed` is `(class, property)` pairs this app has reviewed and kept. There
|
|
429 |
+ |
/// are legitimate ones: the sort caret's reserved gap is `content` on
|
|
430 |
+ |
/// `.table-heading`'s unsorted arm and the generated caret is `content` on the
|
|
431 |
+ |
/// sorted arm, which is a pairing rather than a collision. Deciding that here
|
|
432 |
+ |
/// would need a selector matcher, and a check that guesses wrong about
|
|
433 |
+ |
/// specificity fails correct builds -- so the app declares it instead, the same
|
|
434 |
+ |
/// shape as quasi-webview's `RENDERER_OWN`.
|
|
435 |
+ |
///
|
|
436 |
+ |
/// A pair that stops colliding fails too. A licence nobody is using is where
|
|
437 |
+ |
/// the next real collision lands and reads as company.
|
| 414 |
438 |
|
///
|
| 415 |
439 |
|
/// `frontend` is the directory holding `css/`. `generated` names the sheets
|
| 416 |
440 |
|
/// this crate writes, relative to `frontend/css`, which are skipped: the
|
| 417 |
|
- |
/// generated file naming a generated class is the point.
|
|
441 |
+ |
/// generated file setting a generated property is the point.
|
| 418 |
442 |
|
///
|
| 419 |
443 |
|
/// Emits `cargo:rerun-if-changed` for every file it read.
|
| 420 |
444 |
|
///
|
| 421 |
445 |
|
/// # Panics
|
| 422 |
446 |
|
///
|
| 423 |
|
- |
/// If `frontend/css` cannot be read, or if any hand-written sheet defines a
|
| 424 |
|
- |
/// rule for a generated class. A build script has nowhere useful to return an
|
| 425 |
|
- |
/// error to, and an app quietly overriding its own design system is worse than
|
| 426 |
|
- |
/// a failed build.
|
| 427 |
|
- |
pub fn check_vocabulary(frontend: impl AsRef<Path>, opts: &Emit, generated: &[&str]) {
|
|
447 |
+ |
/// If `frontend/css` cannot be read, if any hand-written sheet takes a
|
|
448 |
+ |
/// generated property without declaring it, or if a declared pair no longer
|
|
449 |
+ |
/// collides. A build script has nowhere useful to return an error to, and an
|
|
450 |
+ |
/// app quietly overriding its own design system is worse than a failed build.
|
|
451 |
+ |
pub fn check_vocabulary(
|
|
452 |
+ |
frontend: impl AsRef<Path>,
|
|
453 |
+ |
opts: &Emit,
|
|
454 |
+ |
generated: &[&str],
|
|
455 |
+ |
allowed: &[(&str, &str)],
|
|
456 |
+ |
) {
|
| 428 |
457 |
|
let frontend = frontend.as_ref();
|
| 429 |
458 |
|
let css = frontend.join("css");
|
| 430 |
459 |
|
let files: Vec<PathBuf> = files_with_extension(&css, "css")
|
| 434 |
463 |
|
!generated.contains(&name.as_str())
|
| 435 |
464 |
|
})
|
| 436 |
465 |
|
.collect();
|
| 437 |
|
- |
check_vocabulary_paths(&files, opts, Some(frontend));
|
|
466 |
+ |
check_vocabulary_paths(&files, opts, Some(frontend), allowed);
|
| 438 |
467 |
|
}
|
| 439 |
468 |
|
|
| 440 |
469 |
|
/// [`check_vocabulary`] against a named list of files rather than a tree.
|
| 446 |
475 |
|
///
|
| 447 |
476 |
|
/// # Panics
|
| 448 |
477 |
|
///
|
| 449 |
|
- |
/// If a listed file cannot be read, or if any of them re-specifies a generated
|
| 450 |
|
- |
/// class.
|
| 451 |
|
- |
pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit) {
|
|
478 |
+ |
/// As [`check_vocabulary`].
|
|
479 |
+ |
pub fn check_vocabulary_files<P: AsRef<Path>>(paths: &[P], opts: &Emit, allowed: &[(&str, &str)]) {
|
| 452 |
480 |
|
let paths: Vec<PathBuf> = paths.iter().map(|p| p.as_ref().to_path_buf()).collect();
|
| 453 |
|
- |
check_vocabulary_paths(&paths, opts, None);
|
|
481 |
+ |
check_vocabulary_paths(&paths, opts, None, allowed);
|
| 454 |
482 |
|
}
|
| 455 |
483 |
|
|
| 456 |
484 |
|
/// The check itself. `root`, when given, is stripped from reported paths.
|
| 457 |
|
- |
fn check_vocabulary_paths(paths: &[PathBuf], opts: &Emit, root: Option<&Path>) {
|
| 458 |
|
- |
let generated = makeover_webview::vocabulary::vocabulary(opts);
|
|
485 |
+ |
fn check_vocabulary_paths(
|
|
486 |
+ |
paths: &[PathBuf],
|
|
487 |
+ |
opts: &Emit,
|
|
488 |
+ |
root: Option<&Path>,
|
|
489 |
+ |
allowed: &[(&str, &str)],
|
|
490 |
+ |
) {
|
|
491 |
+ |
let generated =
|
|
492 |
+ |
makeover_webview::vocabulary::declarations_by_class(&makeover_webview::stylesheet(opts));
|
| 459 |
493 |
|
let mut clashes: Vec<String> = Vec::new();
|
|
494 |
+ |
let mut seen: Vec<(String, String)> = Vec::new();
|
| 460 |
495 |
|
|
| 461 |
496 |
|
for path in paths {
|
| 462 |
497 |
|
println!("cargo::rerun-if-changed={}", path.display());
|
| 468 |
503 |
|
};
|
| 469 |
504 |
|
// Read the app's sheet the same way the crate reads its own, or the two
|
| 470 |
505 |
|
// sides are not comparable.
|
| 471 |
|
- |
let local = makeover_webview::vocabulary::classes_in_css(&raw);
|
| 472 |
|
- |
for class in local.intersection(&generated) {
|
| 473 |
|
- |
clashes.push(format!(" {name} .{class}"));
|
|
506 |
+ |
let local = makeover_webview::vocabulary::declarations_by_class(&raw);
|
|
507 |
+ |
for (class, properties) in &local {
|
|
508 |
+ |
let Some(theirs) = generated.get(class) else {
|
|
509 |
+ |
continue;
|
|
510 |
+ |
};
|
|
511 |
+ |
for property in properties.intersection(theirs) {
|
|
512 |
+ |
seen.push((class.clone(), property.clone()));
|
|
513 |
+ |
if allowed.contains(&(class.as_str(), property.as_str())) {
|
|
514 |
+ |
continue;
|
|
515 |
+ |
}
|
|
516 |
+ |
clashes.push(format!(" {name} .{class} {{ {property} }}"));
|
|
517 |
+ |
}
|
| 474 |
518 |
|
}
|
| 475 |
519 |
|
}
|
| 476 |
520 |
|
|
| 477 |
521 |
|
assert!(
|
| 478 |
522 |
|
clashes.is_empty(),
|
| 479 |
|
- |
"{} hand-written rule(s) re-specify a class the generated stylesheet already \
|
| 480 |
|
- |
defines. App CSS is unlayered and beats @layer makeover, so each of these wins \
|
| 481 |
|
- |
over the design system silently:\n{}\n\nDelete the local rule, or, if it adds \
|
| 482 |
|
- |
something makeover does not answer for, move the addition onto a class of the \
|
| 483 |
|
- |
app's own. Count the consumers before deciding it is a divergence worth keeping.",
|
|
523 |
+ |
"{} hand-written declaration(s) take a property the generated stylesheet \
|
|
524 |
+ |
already sets on the same class. App CSS is unlayered and beats \
|
|
525 |
+ |
@layer makeover, so each of these wins over the design system \
|
|
526 |
+ |
silently:\n{}\n\nDelete the declaration, or, if it is a deliberate pairing \
|
|
527 |
+ |
on a different selector arm, add (class, property) to this check's \
|
|
528 |
+ |
allowed list and say why beside it. Count the consumers before deciding \
|
|
529 |
+ |
a divergence is worth keeping.",
|
| 484 |
530 |
|
clashes.len(),
|
| 485 |
531 |
|
clashes.join("\n")
|
| 486 |
532 |
|
);
|
|
533 |
+ |
|
|
534 |
+ |
let stale: Vec<&(&str, &str)> = allowed
|
|
535 |
+ |
.iter()
|
|
536 |
+ |
.filter(|(class, property)| {
|
|
537 |
+ |
!seen.contains(&((*class).to_string(), (*property).to_string()))
|
|
538 |
+ |
})
|
|
539 |
+ |
.collect();
|
|
540 |
+ |
assert!(
|
|
541 |
+ |
stale.is_empty(),
|
|
542 |
+ |
"the allowed list declares {stale:?}, which no longer collides with \
|
|
543 |
+ |
anything. Delete the entries: an exception nobody is using is where the \
|
|
544 |
+ |
next real collision lands and reads as company."
|
|
545 |
+ |
);
|
| 487 |
546 |
|
}
|
| 488 |
547 |
|
|
| 489 |
548 |
|
/// Warn when the generated vocabulary has grown dead, and fail when it grows
|
| 791 |
850 |
|
"css/styles.css",
|
| 792 |
851 |
|
"body { color: red; }\n.card { box-shadow: none; }\n",
|
| 793 |
852 |
|
);
|
| 794 |
|
- |
let err =
|
| 795 |
|
- |
std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[])).unwrap_err();
|
|
853 |
+ |
let err = std::panic::catch_unwind(|| check_vocabulary(&dir, &Emit::default(), &[], &[]))
|
|
854 |
+ |
.unwrap_err();
|
| 796 |
855 |
|
let msg = err
|
| 797 |
856 |
|
.downcast_ref::<String>()
|
| 798 |
857 |
|
.expect("panic payload is a String");
|
| 799 |
858 |
|
assert!(msg.contains(".card"), "got: {msg}");
|
|
859 |
+ |
assert!(msg.contains("box-shadow"), "got: {msg}");
|
| 800 |
860 |
|
assert!(msg.contains("css/styles.css"), "got: {msg}");
|
| 801 |
861 |
|
}
|
| 802 |
862 |
|
|
| 808 |
868 |
|
"css/styles.css",
|
| 809 |
869 |
|
".task-list-container { overflow: auto; }\n.day-plan-slot { height: 1rem; }\n",
|
| 810 |
870 |
|
);
|
| 811 |
|
- |
check_vocabulary(&dir, &Emit::default(), &[]);
|
|
871 |
+ |
check_vocabulary(&dir, &Emit::default(), &[], &[]);
|
| 812 |
872 |
|
}
|
| 813 |
873 |
|
|
| 814 |
874 |
|
#[test]
|
| 818 |
878 |
|
write(&dir, "css/layout.css", &makeover_webview::stylesheet(&opts));
|
| 819 |
879 |
|
// Without the skip this is the loudest failure possible: every class in
|
| 820 |
880 |
|
// the vocabulary, reported as a clash with the vocabulary.
|
| 821 |
|
- |
check_vocabulary(&dir, &opts, &["layout.css"]);
|
|
881 |
+ |
check_vocabulary(&dir, &opts, &["layout.css"], &[]);
|
| 822 |
882 |
|
}
|
| 823 |
883 |
|
|
| 824 |
884 |
|
#[test]
|
| 831 |
891 |
|
// Bare `.card` is the app's own class once the generated sheet writes
|
| 832 |
892 |
|
// `.mo-card`, so this has to pass.
|
| 833 |
893 |
|
write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
|
| 834 |
|
- |
check_vocabulary(&dir, &opts, &[]);
|
|
894 |
+ |
check_vocabulary(&dir, &opts, &[], &[]);
|
| 835 |
895 |
|
|
| 836 |
896 |
|
let dir = scratch("vocab-prefix-clash");
|
| 837 |
897 |
|
write(&dir, "css/styles.css", ".mo-card { box-shadow: none; }\n");
|
| 838 |
|
- |
assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[])).is_err());
|
|
898 |
+ |
assert!(std::panic::catch_unwind(|| check_vocabulary(&dir, &opts, &[], &[])).is_err());
|
|
899 |
+ |
}
|
|
900 |
+ |
|
|
901 |
+ |
#[test]
|
|
902 |
+ |
fn a_class_shared_without_a_shared_property_is_left_alone() {
|
|
903 |
+ |
let dir = scratch("vocab-additive");
|
|
904 |
+ |
// What goingson actually does: the generated `.badge` sets the text
|
|
905 |
+ |
// colour and the app sets the shape. Same class, no argument.
|
|
906 |
+ |
write(
|
|
907 |
+ |
&dir,
|
|
908 |
+ |
"css/styles.css",
|
|
909 |
+ |
".badge { padding: 2px; border-radius: 3px; font-weight: 600; }\n",
|
|
910 |
+ |
);
|
|
911 |
+ |
check_vocabulary(&dir, &Emit::default(), &[], &[]);
|
|
912 |
+ |
}
|
|
913 |
+ |
|
|
914 |
+ |
#[test]
|
|
915 |
+ |
fn a_reviewed_pair_passes_and_stops_passing_when_it_stops_colliding() {
|
|
916 |
+ |
let dir = scratch("vocab-allowed");
|
|
917 |
+ |
write(&dir, "css/styles.css", ".card { box-shadow: none; }\n");
|
|
918 |
+ |
check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")]);
|
|
919 |
+ |
|
|
920 |
+ |
// The same licence against a sheet that no longer collides has to fail,
|
|
921 |
+ |
// or the list only ever grows.
|
|
922 |
+ |
let dir = scratch("vocab-allowed-stale");
|
|
923 |
+ |
write(&dir, "css/styles.css", ".card { padding: 2px; }\n");
|
|
924 |
+ |
let err = std::panic::catch_unwind(|| {
|
|
925 |
+ |
check_vocabulary(&dir, &Emit::default(), &[], &[("card", "box-shadow")])
|
|
926 |
+ |
})
|
|
927 |
+ |
.unwrap_err();
|
|
928 |
+ |
let msg = err
|
|
929 |
+ |
.downcast_ref::<String>()
|
|
930 |
+ |
.expect("panic payload is a String");
|
|
931 |
+ |
assert!(msg.contains("no longer collides"), "got: {msg}");
|
| 839 |
932 |
|
}
|
| 840 |
933 |
|
|
| 841 |
934 |
|
#[test]
|