Skip to main content

ferro_json_ui/render/
classes.rs

1//! Shared interactive-base class fragments. Single source of truth for the
2//! focus-visible ring, disabled treatment, and frequency-tiered motion, composed
3//! into each component's class string so the interactive feel cannot drift.
4//!
5//! Every constant is a complete class literal in crate source so the Tailwind
6//! `@source` scanner picks the utilities up; compose via `concat!`/`format!`
7//! of these fragments — never build class names dynamically.
8
9/// focus-visible ring from the `--color-ring` token (D-14). ring-2 + offset-2 baseline.
10pub(crate) const FOCUS_RING: &str =
11    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2";
12
13/// Fast tier (D-03): hover, toggles, controls, nav — high-frequency interactions.
14pub(crate) const MOTION_FAST: &str = "transition-colors duration-fast ease-base";
15
16/// Base tier (D-03): dropdowns, modals, toasts.
17pub(crate) const MOTION_BASE: &str = "transition-opacity duration-base ease-base";
18
19/// Uniform disabled treatment for native controls (D-16).
20pub(crate) const DISABLED_BASE: &str = "disabled:opacity-50 disabled:pointer-events-none";
21
22/// Toast tone treatments: translucent tone tint (70% alpha) paired with a
23/// `backdrop-blur-md` on the toast shell. Single source of truth shared by
24/// the SSR `render_toast` and the JS runtime's `VARIANT_CLASSES` — lockstep
25/// is asserted in `runtime::tests::toast_tone_classes_match_ssr`.
26pub(crate) const TOAST_TONE_NEUTRAL: &str = "bg-primary/70 text-primary-foreground";
27/// Success toast tone — see [`TOAST_TONE_NEUTRAL`].
28pub(crate) const TOAST_TONE_SUCCESS: &str = "bg-success/70 text-primary-foreground";
29/// Warning toast tone — see [`TOAST_TONE_NEUTRAL`].
30pub(crate) const TOAST_TONE_WARNING: &str = "bg-warning/70 text-primary-foreground";
31/// Destructive toast tone — see [`TOAST_TONE_NEUTRAL`].
32pub(crate) const TOAST_TONE_DESTRUCTIVE: &str = "bg-destructive/70 text-primary-foreground";
33
34/// Fast-tier interactive base = fast motion + focus ring, for buttons/links/nav.
35pub(crate) const INTERACTIVE_BASE: &str = concat!(
36    "transition-colors duration-fast ease-base ",
37    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
38);
39
40/// Touch-manipulation scroll optimisation for tap targets. Full literal.
41pub const TOUCH_ACTION: &str = "touch-manipulation";
42
43/// Minimum 44px hit target for interactive elements (WCAG 2.5.5). Full literal.
44pub const HIT_TARGET_MIN: &str = "min-h-[44px] min-w-[44px]";
45
46/// Enlarged 56px hit target for Numpad keys (Phase 256 consumer). Full literal.
47pub const HIT_TARGET_NUMPAD: &str = "min-h-[56px] min-w-[56px]";
48
49/// Press-state feedback for tap surfaces: scale-down + border tint.
50/// Token-compliant (`active:bg-border` = `--color-border`); no raw palette class.
51pub const PRESS_ACTIVE: &str = "active:scale-95 active:bg-border";
52
53/// Prevents scroll bounce from escaping a pane boundary. Full literal.
54pub const OVERSCROLL_CONTAIN: &str = "overscroll-contain";
55
56/// Removes the default iOS tap-highlight rectangle on touch targets.
57/// Backed by `@utility pos-tap-highlight` in input.css (Path B — guaranteed CSS).
58pub const TAP_HIGHLIGHT: &str = "pos-tap-highlight";
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    /// `INTERACTIVE_BASE` must be exactly the fast-tier motion + focus ring —
65    /// editing one fragment without the other is drift.
66    #[test]
67    fn interactive_base_is_motion_fast_plus_focus_ring() {
68        assert_eq!(INTERACTIVE_BASE, format!("{MOTION_FAST} {FOCUS_RING}"));
69    }
70
71    /// Every fragment sources the Phase 250 token vocabulary, never raw values.
72    #[test]
73    fn fragments_use_token_utilities() {
74        assert!(FOCUS_RING.contains("focus-visible:ring-ring"));
75        assert!(MOTION_FAST.contains("duration-fast") && MOTION_FAST.contains("ease-base"));
76        assert!(MOTION_BASE.contains("duration-base") && MOTION_BASE.contains("ease-base"));
77        assert!(DISABLED_BASE.contains("disabled:pointer-events-none"));
78    }
79
80    /// D-07: no render file (outside classes.rs) may inline the touch literals.
81    /// Auto-covers Phase 256 render files via read_dir — no manual re-enrollment.
82    #[test]
83    fn render_functions_use_constants_not_literals() {
84        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
85        let render_dir = std::path::Path::new(&manifest_dir).join("src/render");
86        let guarded = [
87            "touch-manipulation",
88            "min-h-[44px] min-w-[44px]",
89            "min-h-[56px] min-w-[56px]",
90        ];
91        for entry in std::fs::read_dir(&render_dir).expect("src/render readable") {
92            let path = entry.unwrap().path();
93            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
94                continue;
95            }
96            let filename = path.file_name().unwrap().to_str().unwrap().to_string();
97            if filename == "classes.rs" {
98                continue;
99            }
100            let source = std::fs::read_to_string(&path).unwrap();
101            for lit in &guarded {
102                assert!(
103                    !source.contains(lit),
104                    "{filename}: raw POS literal {lit:?} — import from render::classes instead"
105                );
106            }
107        }
108    }
109
110    #[test]
111    fn touch_constants_are_full_literals_and_token_compliant() {
112        assert_eq!(TOUCH_ACTION, "touch-manipulation");
113        assert_eq!(HIT_TARGET_MIN, "min-h-[44px] min-w-[44px]");
114        assert_eq!(HIT_TARGET_NUMPAD, "min-h-[56px] min-w-[56px]");
115        assert_eq!(OVERSCROLL_CONTAIN, "overscroll-contain");
116        assert_eq!(TAP_HIGHLIGHT, "pos-tap-highlight");
117        assert!(PRESS_ACTIVE.contains("active:scale-95"));
118        assert!(PRESS_ACTIVE.contains("active:bg-border"));
119        for raw in ["red-", "blue-", "orange-", "zinc-", "gray-", "slate-"] {
120            assert!(
121                !PRESS_ACTIVE.contains(raw),
122                "raw palette class in PRESS_ACTIVE"
123            );
124        }
125    }
126}