Skip to main content

max / makeover-geometry

0.6.0: emit the spacing layer inside the family cascade layer The cascade resolves origin and importance, then layer, then specificity, then source order, and unlayered normal declarations outrank every named layer. Custom properties follow the ordinary cascade like everything else, so an app that puts its own :root overrides in a named layer while this file stayed unlayered would find the generated tokens beating the overrides meant to replace them. Not visible until the app adopts layers, and not fixable from the app's own stylesheet, because the fix is to layer a file it does not own. CSS_LAYER and in_css_layer live here rather than in makeover-webview for the reason Density::media_condition does: this is the one crate every CSS-emitting crate in the family already depends on, and a second copy is the drift that argument exists to prevent. It is not a claim that spacing owns the layer name.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-02 16:46 UTC
Signed with PGP, not checked
Commit: e241d2954ced3e5a53c24a82bc185b46ca3d8c4c
Parent: ba22bf5
2 files changed, +81 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-geometry"
3 - version = "0.5.0"
3 + version = "0.6.0"
4 4 edition = "2024"
5 5 description = "The invariant half of the make-family design system: relational spacing, radius, border width and type scale. Geometry never varies by theme, which is why it does not live in makeover."
6 6 license = "MIT"
M src/lib.rs +80
@@ -675,6 +675,55 @@
675 675 out
676 676 }
677 677
678 + /// The cascade layer every stylesheet the make-family generates is wrapped in.
679 + ///
680 + /// One name shared by every emitter in the family, so an app writes it once and
681 + /// the design system's output lands in one place it can order against:
682 + ///
683 + /// ```css
684 + /// @layer makeover, base, components, responsive;
685 + /// ```
686 + ///
687 + /// # Why a layer at all
688 + ///
689 + /// The cascade resolves origin and importance, then layer, then specificity,
690 + /// then source order, and **unlayered normal declarations outrank every named
691 + /// layer**. So the moment an app declares any layer of its own, every rule it
692 + /// owns loses to unlayered generated CSS regardless of specificity or of
693 + /// loading last. Emitting into a layer is what stops that, and putting the name
694 + /// here rather than in each app is what stops three apps picking three names.
695 + ///
696 + /// # Why this constant lives in the geometry crate
697 + ///
698 + /// Not because spacing owns it. This crate is the only one every CSS-emitting
699 + /// crate in the family already depends on, and it is already the crate that
700 + /// spells CSS for the family (`media_condition`, `Step::token`, `Ratio::css`).
701 + /// A second copy in `makeover-webview` is exactly the drift
702 + /// [`Density::media_condition`] exists to prevent, one layer up.
703 + pub const CSS_LAYER: &str = "makeover";
704 +
705 + /// Wrap generated CSS in [`CSS_LAYER`].
706 + ///
707 + /// Every whole-stylesheet emitter in the family ends with this call. Exposed
708 + /// rather than kept private because an app that assembles its own stylesheet
709 + /// out of this family's pieces has to put it in the same layer: goingson builds
710 + /// `tables.css` in its own `build.rs` from `makeover_webview::list`, and those
711 + /// rules are as generated as the ones in `layout.css`.
712 + #[must_use]
713 + pub fn in_css_layer(css: &str) -> String {
714 + let mut out = format!("@layer {CSS_LAYER} {{\n");
715 + for line in css.lines() {
716 + // Blank lines stay blank; indenting one leaves trailing whitespace.
717 + if line.is_empty() {
718 + out.push('\n');
719 + } else {
720 + let _ = writeln!(out, " {line}");
721 + }
722 + }
723 + out.push_str("}\n");
724 + out
725 + }
726 +
678 727 /// Emit the whole geometry layer as a `:root { … }` block at one density.
679 728 ///
680 729 /// Mirrors `makeover::intent_css_vars`. Unlike the colour layer this is
@@ -708,8 +757,20 @@
708 757 /// `(hover: none)` alone, and audiofiles had no switch at all; the first of
709 758 /// those asked what device this is as a proxy for a capability already
710 759 /// reported.
760 + ///
761 + /// Emitted inside [`CSS_LAYER`] since 0.6.0. Custom properties follow the
762 + /// ordinary cascade, so unlayered ones outrank layered ones: an app that puts
763 + /// its own `:root` overrides in a named layer while this file stayed unlayered
764 + /// would find the generated tokens beating the overrides meant to replace them.
765 + /// That is the same trap the component sheet had, and it is not visible until
766 + /// the app adopts layers.
711 767 #[must_use]
712 768 pub fn density_css(explicit_touch: Option<&str>) -> String {
769 + in_css_layer(&density_declarations(explicit_touch))
770 + }
771 +
772 + /// [`density_css`] without the layer wrapper.
773 + fn density_declarations(explicit_touch: Option<&str>) -> String {
713 774 let mut css = geometry_css_vars(Density::Pointer);
714 775 css.push_str("\n/* Touch: targets separate, shells hold. */\n");
715 776 css.push_str("@media ");
@@ -758,6 +819,25 @@
758 819 assert!(!css.contains("ui-mode"), "a device mode crept in");
759 820 }
760 821
822 + #[test]
823 + fn the_spacing_layer_is_emitted_inside_the_family_layer() {
824 + // Unlayered declarations outrank layered ones, so an app that layers
825 + // its own :root overrides would lose to an unlayered geometry.css.
826 + let css = density_css(None);
827 + assert!(css.starts_with(&format!("@layer {CSS_LAYER} {{\n")));
828 + assert!(css.trim_end().ends_with('}'));
829 + // Everything still there, one level in.
830 + assert!(css.contains(" :root {"));
831 + assert!(css.contains("--gap-peer"));
832 + }
833 +
834 + #[test]
835 + fn wrapping_leaves_no_trailing_whitespace_on_blank_lines() {
836 + // A formatter strips these later and calls it a diff.
837 + let css = in_css_layer("a {\n\nb\n}\n");
838 + assert!(!css.lines().any(|l| l != l.trim_end()), "{css:?}");
839 + }
840 +
761 841 #[test]
762 842 fn the_two_density_conditions_are_complements_and_not_negations() {
763 843 let pointer = Density::Pointer.media_condition();