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/// Fast-tier interactive base = fast motion + focus ring, for buttons/links/nav.
23pub(crate) const INTERACTIVE_BASE: &str = concat!(
24    "transition-colors duration-fast ease-base ",
25    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
26);
27
28/// Touch-manipulation scroll optimisation for tap targets. Full literal.
29pub const TOUCH_ACTION: &str = "touch-manipulation";
30
31/// Minimum 44px hit target for interactive elements (WCAG 2.5.5). Full literal.
32pub const HIT_TARGET_MIN: &str = "min-h-[44px] min-w-[44px]";
33
34/// Enlarged 56px hit target for Numpad keys (Phase 256 consumer). Full literal.
35pub const HIT_TARGET_NUMPAD: &str = "min-h-[56px] min-w-[56px]";
36
37/// Press-state feedback for tap surfaces: scale-down + border tint.
38/// Token-compliant (`active:bg-border` = `--color-border`); no raw palette class.
39pub const PRESS_ACTIVE: &str = "active:scale-95 active:bg-border";
40
41/// Prevents scroll bounce from escaping a pane boundary. Full literal.
42pub const OVERSCROLL_CONTAIN: &str = "overscroll-contain";
43
44/// Removes the default iOS tap-highlight rectangle on touch targets.
45/// Backed by `@utility pos-tap-highlight` in input.css (Path B — guaranteed CSS).
46pub const TAP_HIGHLIGHT: &str = "pos-tap-highlight";
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    /// `INTERACTIVE_BASE` must be exactly the fast-tier motion + focus ring —
53    /// editing one fragment without the other is drift.
54    #[test]
55    fn interactive_base_is_motion_fast_plus_focus_ring() {
56        assert_eq!(INTERACTIVE_BASE, format!("{MOTION_FAST} {FOCUS_RING}"));
57    }
58
59    /// Every fragment sources the Phase 250 token vocabulary, never raw values.
60    #[test]
61    fn fragments_use_token_utilities() {
62        assert!(FOCUS_RING.contains("focus-visible:ring-ring"));
63        assert!(MOTION_FAST.contains("duration-fast") && MOTION_FAST.contains("ease-base"));
64        assert!(MOTION_BASE.contains("duration-base") && MOTION_BASE.contains("ease-base"));
65        assert!(DISABLED_BASE.contains("disabled:pointer-events-none"));
66    }
67
68    /// D-07: no render file (outside classes.rs) may inline the touch literals.
69    /// Auto-covers Phase 256 render files via read_dir — no manual re-enrollment.
70    #[test]
71    fn render_functions_use_constants_not_literals() {
72        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
73        let render_dir = std::path::Path::new(&manifest_dir).join("src/render");
74        let guarded = [
75            "touch-manipulation",
76            "min-h-[44px] min-w-[44px]",
77            "min-h-[56px] min-w-[56px]",
78        ];
79        for entry in std::fs::read_dir(&render_dir).expect("src/render readable") {
80            let path = entry.unwrap().path();
81            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
82                continue;
83            }
84            let filename = path.file_name().unwrap().to_str().unwrap().to_string();
85            if filename == "classes.rs" {
86                continue;
87            }
88            let source = std::fs::read_to_string(&path).unwrap();
89            for lit in &guarded {
90                assert!(
91                    !source.contains(lit),
92                    "{filename}: raw POS literal {lit:?} — import from render::classes instead"
93                );
94            }
95        }
96    }
97
98    /// D-07 extension (Plan 06): render functions migrated to fjui-* classes must not
99    /// re-introduce the specific appearance utility strings that moved to the skin layer.
100    ///
101    /// Scope: render functions migrated in Plans 05 and 06 (Button, Badge, Alert,
102    /// StatCard, EmptyState, Card, Tabs, Text, Separator, Progress, Avatar, Image,
103    /// Skeleton, Breadcrumb, Pagination, DescriptionList, Toast, Sidebar, Header,
104    /// CalendarCell, ActionCard, Tile).
105    ///
106    /// Deferred (NOT banned here — migrate in a later plan):
107    /// - QuantityStepper / Numpad button classes (plan-scope boundary)
108    /// - Checklist checkbox native-control classes (form-control migration deferred)
109    /// - StatCard icon tint classes (icon system migration deferred)
110    /// - Header sub-element avatar/notification badge micro-classes (CHR-01 deferred to Phase 247)
111    /// - render_sidebar border-t border-border on fixed_bottom nav separator (structural inline)
112    #[test]
113    fn atoms_render_fns_contain_no_migrated_appearance_utilities() {
114        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
115        let atoms_path = std::path::Path::new(&manifest_dir).join("src/render/atoms.rs");
116        let source = std::fs::read_to_string(&atoms_path).expect("atoms.rs readable");
117
118        // Scan only the production source (before #[cfg(test)]).
119        let test_start = source.find("#[cfg(test)]").unwrap_or(source.len());
120        let production_source = &source[..test_start];
121
122        // These are top-level container appearance utilities on the migrated component
123        // elements themselves (not on inner sub-elements still pending migration).
124        // Each is a string that must NOT appear in a class= attribute emitted by
125        // the migrated render functions listed in the doc comment above.
126        let banned: &[(&str, &str)] = &[
127            // Toast: moved to fjui-toast / fjui-toast--{tone}
128            (
129                "bg-primary/70",
130                "toast tone bg — use fjui-toast--neutral skin rule",
131            ),
132            (
133                "bg-success/70",
134                "toast tone bg — use fjui-toast--success skin rule",
135            ),
136            (
137                "bg-warning/70",
138                "toast tone bg — use fjui-toast--warning skin rule",
139            ),
140            (
141                "bg-destructive/70",
142                "toast tone bg — use fjui-toast--destructive skin rule",
143            ),
144            // Action card: moved to fjui-action-card / fjui-action-card--{tone}
145            (
146                "fjui-action-card\" class=\"",
147                "action card must not carry appearance utilities after fjui-action-card",
148            ),
149            // Pagination: moved to fjui-pagination__btn / fjui-pagination__btn--active
150            (
151                "bg-primary text-primary-foreground\">",
152                "pagination active page — use fjui-pagination__btn--active",
153            ),
154        ];
155
156        for (lit, reason) in banned {
157            assert!(
158                !production_source.contains(lit),
159                "atoms.rs: appearance utility {lit:?} found — {reason}"
160            );
161        }
162    }
163
164    /// D-07 extension (Plan 07): container render functions migrated to fjui-* classes
165    /// must not re-introduce the specific appearance utility strings that moved to the
166    /// skin layer.
167    ///
168    /// Scope: render functions migrated in Plan 07 (Card, PageHeader, Modal, Tabs,
169    /// KanbanBoard, KanbanCard, DataTable shell).
170    ///
171    /// Deferred (NOT banned here):
172    /// - DataTable row/cell classes (Plan 08 scope)
173    /// - render_action_group non-kebab button appearance (partial migration only)
174    #[test]
175    fn containers_render_fns_contain_no_migrated_appearance_utilities() {
176        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
177        let containers_path = std::path::Path::new(&manifest_dir).join("src/render/containers.rs");
178        let source = std::fs::read_to_string(&containers_path).expect("containers.rs readable");
179
180        // Scan only the production source (before #[cfg(test)]).
181        let test_start = source.find("#[cfg(test)]").unwrap_or(source.len());
182        let production_source = &source[..test_start];
183
184        // Appearance utilities that moved to the skin layer in Plan 07.
185        // Each string must NOT appear in any class= attribute emitted by the
186        // migrated render functions listed above.
187        let banned: &[(&str, &str)] = &[
188            // Card: moved to fjui-card
189            (
190                "rounded-lg border border-border bg-card",
191                "card outer — use fjui-card skin rule",
192            ),
193            (
194                "shadow-sm rounded-lg",
195                "card bordered shadow — use fjui-card skin rule",
196            ),
197            (
198                "shadow-md rounded-lg",
199                "card elevated shadow — use fjui-card skin rule",
200            ),
201            // Tabs: moved to fjui-tabs / fjui-tab / fjui-tab--active
202            // Note: bare "border-b border-border" is NOT banned — it appears on structural
203            // row separators (e.g. render_selection_panel line dividers) which are out of
204            // scope. Only the tabs-specific wrapper div pattern is banned.
205            (
206                "<div class=\"border-b border-border\">",
207                "tabs wrapper div — use fjui-tabs nav (no wrapper div needed)",
208            ),
209            (
210                "border-b-2 border-primary",
211                "active tab underline — use fjui-tab--active skin rule",
212            ),
213            (
214                "text-text-muted hover:text-text",
215                "tab inactive text — use fjui-tab skin rule",
216            ),
217            // Modal: moved to fjui-modal
218            (
219                "bg-card rounded-lg shadow-lg",
220                "modal dialog — use fjui-modal skin rule",
221            ),
222            // Kanban: moved to fjui-kanban__* skin rules
223            (
224                "bg-secondary/10 rounded-lg",
225                "kanban column bg — use fjui-kanban__column skin rule",
226            ),
227            (
228                "bg-primary text-primary-foreground",
229                "kanban active count badge — use fjui-badge--neutral skin rule",
230            ),
231            // PageHeader: moved to fjui-page-header
232            (
233                "border-b border-border bg-background",
234                "page header bar — use fjui-page-header skin rule",
235            ),
236        ];
237
238        for (lit, reason) in banned {
239            assert!(
240                !production_source.contains(lit),
241                "containers.rs: appearance utility {lit:?} found — {reason}"
242            );
243        }
244    }
245
246    /// D-07 extension (Plan 08): data.rs render functions (DataTable body/rows/cells)
247    /// must not contain the appearance utilities removed by the Plan 08 migration.
248    ///
249    /// Scope: render functions migrated in Plan 08 (DataTable row/cell).
250    ///
251    /// NOT banned (out of scope):
252    /// - Table (simple, not DataTable) — not migrated in Plan 08
253    /// - MediaCardGrid card classes — separate surface, partial migration only
254    #[test]
255    fn data_render_fns_contain_no_migrated_appearance_utilities() {
256        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
257        let data_path = std::path::Path::new(&manifest_dir).join("src/render/data.rs");
258        let source = std::fs::read_to_string(&data_path).expect("data.rs readable");
259
260        // Scan only the production source (before #[cfg(test)]).
261        let test_start = source.find("#[cfg(test)]").unwrap_or(source.len());
262        let production_source = &source[..test_start];
263
264        let banned: &[(&str, &str)] = &[
265            // DataTable row zebra striping: removed (RSK-01) — hover surface via skin rule.
266            // Note: render_table (simple Table, non-DataTable) is out of Plan 08 scope.
267            (
268                "even:bg-surface",
269                "DataTable row zebra — removed by RSK-01; skin hover replaces it",
270            ),
271            (
272                "odd:bg-surface",
273                "DataTable row zebra — removed by RSK-01; skin hover replaces it",
274            ),
275        ];
276
277        for (lit, reason) in banned {
278            assert!(
279                !production_source.contains(lit),
280                "data.rs: appearance utility {lit:?} found — {reason}"
281            );
282        }
283    }
284
285    /// D-07 extension (Plan 08): form.rs render functions (Input/Select/Textarea)
286    /// must not contain the appearance utilities removed by the Plan 08 migration.
287    ///
288    /// Scope: render functions migrated in Plan 08 (render_input, render_select).
289    ///
290    /// NOT banned (out of scope, not migrated in Plan 08):
291    /// - Checkbox/CheckboxList classes (native control micro-styling, deferred)
292    /// - Switch pill classes (complex state machine, deferred)
293    /// - File input classes (specialized input, deferred)
294    #[test]
295    fn form_render_fns_contain_no_migrated_appearance_utilities() {
296        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
297        let form_path = std::path::Path::new(&manifest_dir).join("src/render/form.rs");
298        let source = std::fs::read_to_string(&form_path).expect("form.rs readable");
299
300        // Scan only the production source (before #[cfg(test)]).
301        let test_start = source.find("#[cfg(test)]").unwrap_or(source.len());
302        let production_source = &source[..test_start];
303
304        let banned: &[(&str, &str)] = &[
305            // Input/Textarea old appearance: moved to fjui-input/fjui-textarea skin rule
306            (
307                "rounded-md border border-border px-3 py-2",
308                "Input/Textarea border+padding — use fjui-input skin rule",
309            ),
310            (
311                "rounded-md border border-destructive px-3 py-2",
312                "Input error border — use fjui-input--error skin rule",
313            ),
314            // Select old appearance: moved to fjui-select skin rule
315            (
316                "appearance-none bg-background rounded-md border",
317                "Select appearance — use fjui-select skin rule",
318            ),
319        ];
320
321        for (lit, reason) in banned {
322            assert!(
323                !production_source.contains(lit),
324                "form.rs: appearance utility {lit:?} found — {reason}"
325            );
326        }
327    }
328
329    #[test]
330    fn touch_constants_are_full_literals_and_token_compliant() {
331        assert_eq!(TOUCH_ACTION, "touch-manipulation");
332        assert_eq!(HIT_TARGET_MIN, "min-h-[44px] min-w-[44px]");
333        assert_eq!(HIT_TARGET_NUMPAD, "min-h-[56px] min-w-[56px]");
334        assert_eq!(OVERSCROLL_CONTAIN, "overscroll-contain");
335        assert_eq!(TAP_HIGHLIGHT, "pos-tap-highlight");
336        assert!(PRESS_ACTIVE.contains("active:scale-95"));
337        assert!(PRESS_ACTIVE.contains("active:bg-border"));
338        for raw in ["red-", "blue-", "orange-", "zinc-", "gray-", "slate-"] {
339            assert!(
340                !PRESS_ACTIVE.contains(raw),
341                "raw palette class in PRESS_ACTIVE"
342            );
343        }
344    }
345}