diff --git a/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php b/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php new file mode 100755 index 000000000..da48bc80d --- /dev/null +++ b/app/RSpade/CodeQuality/Rules/Manifest/ScssClassScope_CodeQualityRule.php @@ -0,0 +1,358 @@ + file_path (without extension) + $blade_ids = []; // id => file_path (without extension) + + foreach ($files as $file => $file_metadata) { + $extension = $file_metadata['extension'] ?? ''; + + // Collect Component subclasses (includes Spa_Action, Spa_Layout, and direct Component subclasses) + if ($extension === 'js') { + $class_name = $file_metadata['class'] ?? null; + if ($class_name) { + try { + if (Manifest::js_is_subclass_of($class_name, 'Component')) { + $components[$class_name] = pathinfo($file, PATHINFO_FILENAME); + } + } catch (\Exception $e) { + // Class not found in inheritance chain, skip + } + } + } + + // Collect Blade @rsx_id values + if ($extension === 'blade.php') { + $id = $file_metadata['id'] ?? null; + if ($id) { + // Remove .blade.php to get base filename + $filename = basename($file, '.blade.php'); + $blade_ids[$id] = $filename; + } + } + + // Collect jqhtml component IDs (from ) + // These are Components without a companion .js file + if ($extension === 'jqhtml') { + $id = $file_metadata['id'] ?? null; + if ($id) { + // Check if there's already a .js file for this component + // If so, skip - the .js file takes precedence + if (!isset($components[$id])) { + $filename = pathinfo($file, PATHINFO_FILENAME); + $components[$id] = $filename; + } + } + } + } + + // Build map of wrapper classes that have a primary SCSS file (filename matches) + // This allows supplemental SCSS files with different names for the same wrapper class + $wrapper_classes_with_primary_scss = []; + + foreach ($files as $file => $file_metadata) { + $extension = $file_metadata['extension'] ?? ''; + if ($extension !== 'scss') { + continue; + } + + $wrapper_class = $file_metadata['scss_wrapper_class'] ?? null; + if ($wrapper_class === null) { + continue; + } + + $scss_filename = pathinfo($file, PATHINFO_FILENAME); + + // Check if this SCSS file's name matches its wrapper class's associated file + $matched_filename = $components[$wrapper_class] ?? $blade_ids[$wrapper_class] ?? null; + if ($matched_filename !== null && strcasecmp($scss_filename, $matched_filename) === 0) { + $wrapper_classes_with_primary_scss[$wrapper_class] = true; + } + } + + // Now validate SCSS files + foreach ($files as $file => $file_metadata) { + $extension = $file_metadata['extension'] ?? ''; + + if ($extension !== 'scss') { + continue; + } + + // Determine which validation to apply based on path + $is_rsx_app = str_starts_with($file, 'rsx/app/'); + $is_theme_component = str_starts_with($file, 'rsx/theme/components/'); + + // Skip files outside our enforcement paths + if (!$is_rsx_app && !$is_theme_component) { + continue; + } + + // NO EXEMPTIONS - all files in these paths must follow the convention + + // Skip files that contain only variables and comments (no actual rules) + if (!empty($file_metadata['scss_variables_only'])) { + continue; + } + + $wrapper_class = $file_metadata['scss_wrapper_class'] ?? null; + $scss_filename = pathinfo($file, PATHINFO_FILENAME); + + $this->validate_scss_file($file, $wrapper_class, $scss_filename, $components, $blade_ids, $wrapper_classes_with_primary_scss); + } + } + + /** + * Validate SCSS file against Component classes and Blade @rsx_id values + */ + private function validate_scss_file( + string $file, + ?string $wrapper_class, + string $scss_filename, + array $components, + array $blade_ids, + array $wrapper_classes_with_primary_scss + ): void { + // Check if file has a wrapper class + if ($wrapper_class === null) { + $this->add_violation( + $file, + 1, + "SCSS file must be fully enclosed in a single class rule (e.g., .My_Component { ... })", + basename($file), + $this->build_no_wrapper_suggestion($file), + 'critical' + ); + return; + } + + // Check if wrapper class matches a Component or Blade @rsx_id + $matched_filename = null; + $match_type = null; + + if (isset($components[$wrapper_class])) { + $matched_filename = $components[$wrapper_class]; + $match_type = 'Component'; + } elseif (isset($blade_ids[$wrapper_class])) { + $matched_filename = $blade_ids[$wrapper_class]; + $match_type = 'Blade @rsx_id'; + } + + if ($matched_filename === null) { + $this->add_violation( + $file, + 1, + "SCSS wrapper class '{$wrapper_class}' does not match any Component class or Blade @rsx_id", + ".{$wrapper_class} { ... }", + $this->build_no_match_suggestion($file, $wrapper_class), + 'critical' + ); + return; + } + + // Check if filename matches + if (strcasecmp($scss_filename, $matched_filename) !== 0) { + // Allow supplemental SCSS files if a primary file already exists for this wrapper class + if (isset($wrapper_classes_with_primary_scss[$wrapper_class])) { + // This is a supplemental file - filename mismatch is allowed + return; + } + + $this->add_violation( + $file, + 1, + "SCSS filename '{$scss_filename}.scss' must match associated {$match_type} file '{$matched_filename}'. " . + "Supplemental files with different names are allowed only if a primary file ('{$matched_filename}.scss') exists. " . + "See: php artisan rsx:man scss", + basename($file), + $this->build_filename_mismatch_suggestion($file, $scss_filename, $matched_filename, $match_type), + 'critical' + ); + } + } + + /** + * Build suggestion for files without a wrapper class + */ + private function build_no_wrapper_suggestion(string $file): string + { + $lines = []; + $lines[] = "SCSS files in rsx/app/ and rsx/theme/components/ must be fully enclosed"; + $lines[] = "in a single top-level class selector that matches their associated"; + $lines[] = "action, layout, component, or Blade view."; + $lines[] = ""; + $lines[] = "This prevents CSS conflicts and ensures styles are scoped to the"; + $lines[] = "element they are intended to style."; + $lines[] = ""; + $lines[] = "VALID ASSOCIATIONS:"; + $lines[] = " - Spa_Action class (e.g., Frontend_Dashboard extends Spa_Action)"; + $lines[] = " - Spa_Layout class (e.g., Frontend_Layout extends Spa_Layout)"; + $lines[] = " - Component class (e.g., Sidebar_Nav extends Component)"; + $lines[] = " - Blade @rsx_id (e.g., @rsx_id('Login_Index'))"; + $lines[] = ""; + $lines[] = "TO FIX:"; + $lines[] = " Wrap ALL rules in the associated class:"; + $lines[] = ""; + $lines[] = " .My_Component {"; + $lines[] = " // ALL styles go here"; + $lines[] = " .card { ... }"; + $lines[] = " .button { ... }"; + $lines[] = " }"; + $lines[] = ""; + $lines[] = "NO EXEMPTIONS are allowed in these directories. If this file cannot"; + $lines[] = "be associated to an action/layout/component/view, it may need to be"; + $lines[] = "moved to rsx/theme/ (outside components/) - but this is rare and"; + $lines[] = "requires explicit developer approval."; + $lines[] = ""; + $lines[] = "See: php artisan rsx:man scss"; + + return implode("\n", $lines); + } + + /** + * Build suggestion for wrapper class that doesn't match anything + */ + private function build_no_match_suggestion(string $file, string $wrapper_class): string + { + $lines = []; + $lines[] = "The wrapper class '{$wrapper_class}' does not match any known"; + $lines[] = "action, layout, component, or Blade view."; + $lines[] = ""; + $lines[] = "VALID ASSOCIATIONS:"; + $lines[] = " - Spa_Action class (e.g., Frontend_Dashboard extends Spa_Action)"; + $lines[] = " - Spa_Layout class (e.g., Frontend_Layout extends Spa_Layout)"; + $lines[] = " - Component class (e.g., Sidebar_Nav extends Component)"; + $lines[] = " - Blade @rsx_id (e.g., @rsx_id('Login_Index'))"; + $lines[] = ""; + $lines[] = "TO FIX:"; + $lines[] = " 1. Rename the wrapper class to match your action/layout/component/view"; + $lines[] = " 2. Or create the missing .js or .blade.php file with this class/id"; + $lines[] = ""; + $lines[] = "NO EXEMPTIONS are allowed. If this file provides shared styles that"; + $lines[] = "cannot be scoped to a single element, it may need to be moved to"; + $lines[] = "rsx/theme/ (outside components/) - but this is rare and requires"; + $lines[] = "explicit developer approval."; + $lines[] = ""; + $lines[] = "See: php artisan rsx:man scss"; + + return implode("\n", $lines); + } + + /** + * Build suggestion for filename mismatch + */ + private function build_filename_mismatch_suggestion( + string $file, + string $scss_filename, + string $expected_filename, + string $match_type + ): string { + $lines = []; + $lines[] = "SCSS filename must match the associated {$match_type} file."; + $lines[] = ""; + $lines[] = "Current: {$scss_filename}.scss"; + $lines[] = "Expected: {$expected_filename}.scss"; + $lines[] = ""; + $lines[] = "TO FIX:"; + $lines[] = " Rename the SCSS file:"; + $lines[] = " mv {$scss_filename}.scss {$expected_filename}.scss"; + $lines[] = ""; + $lines[] = "See: php artisan rsx:man scss"; + + return implode("\n", $lines); + } +} diff --git a/app/RSpade/Integrations/Scss/Scss_ManifestModule.php b/app/RSpade/Integrations/Scss/Scss_ManifestModule.php index c73cfeead..fcea05709 100755 --- a/app/RSpade/Integrations/Scss/Scss_ManifestModule.php +++ b/app/RSpade/Integrations/Scss/Scss_ManifestModule.php @@ -209,17 +209,33 @@ class Scss_ManifestModule extends ManifestModule_Abstract } /** - * Detect if SCSS file has a single top-level class that qualifies as an ID + * Detect if SCSS file has a single top-level class wrapper * - * The SCSS file gets an 'id' if: - * 1. All rules are contained within a single top-level class selector - * 2. The class name matches a Blade view ID, JS class extending Component, or jqhtml template - * 3. No other SCSS file already has this ID + * Sets scss_wrapper_class if all rules are contained within a single top-level + * class selector (e.g., .Frontend_Dashboard { ... }). + * + * SCSS variable declarations ($var: value;) are allowed outside the wrapper + * and are stripped before checking. Files containing only variables/comments + * are considered valid (scss_variables_only = true). + * + * Additionally sets 'id' if the wrapper class matches a Blade view ID, + * JS class extending Component, or jqhtml template in the manifest. */ protected function detect_scss_id(string $clean_content, array &$metadata): void { + // Remove SCSS variable declarations ($var: value;) - these are allowed outside wrapper + // This regex matches: $variable-name: any value until semicolon; + $content_without_vars = preg_replace('/\$[a-zA-Z_][\w-]*\s*:[^;]+;/', '', $clean_content); + // Remove all whitespace and newlines for easier parsing - $compact = preg_replace('/\s+/', ' ', trim($clean_content)); + $compact = preg_replace('/\s+/', ' ', trim($content_without_vars)); + + // If content is empty after removing variables (only variables and comments), + // mark as variables-only file - no wrapper needed + if (empty($compact)) { + $metadata['scss_variables_only'] = true; + return; + } // Check if content starts with a single class selector and everything is inside it // Pattern: .ClassName { ... everything ... } @@ -240,7 +256,10 @@ class Scss_ManifestModule extends ManifestModule_Abstract return; } - // Now check if this class name matches something in the manifest + // Always set scss_wrapper_class when file is fully enclosed in a single class + $metadata['scss_wrapper_class'] = $class_name; + + // Now check if this class name matches something in the manifest for setting 'id' // During build, we need to access the in-memory manifest data // The get_all() method returns the cached data, not the in-progress build // We need a different approach - access the static data directly diff --git a/app/RSpade/man/scss.txt b/app/RSpade/man/scss.txt new file mode 100755 index 000000000..98f9b762d --- /dev/null +++ b/app/RSpade/man/scss.txt @@ -0,0 +1,352 @@ +NAME + scss - SCSS file organization and class scoping conventions + +SYNOPSIS + Action-scoped SCSS for SPA actions and Blade views: + + // rsx/app/frontend/dashboard/dashboard_index_action.scss + .Dashboard_Index_Action { + .card { ... } + .stats-grid { ... } + } + + Component-scoped SCSS for theme components: + + // rsx/theme/components/sidebar/sidebar_nav.scss + .Sidebar_Nav { + .nav-item { ... } + .nav-link { ... } + } + + Variables may be declared outside the wrapper for sharing: + + // rsx/app/frontend/frontend_spa_layout.scss + $sidebar-width: 215px; + $header-height: 57px; + + .Frontend_Spa_Layout { + .sidebar { width: $sidebar-width; } + } + +DESCRIPTION + RSX enforces a class-scoping convention for SCSS files to prevent CSS + conflicts and ensure styles are self-contained. Every SCSS file in + rsx/app/ or rsx/theme/components/ must wrap ALL rules inside a single + top-level class selector that matches its associated JavaScript class + or Blade view ID. + + This is essentially manual CSS scoping - like CSS Modules but enforced + by convention. The benefit is predictable specificity, no conflicts + between pages/components, and self-documenting file organization. + + Key principle: The SCSS filename must match the filename of its + associated .js (action/component) or .blade.php file. + +SCOPING RULES + + Files in rsx/app/**/*.scss and rsx/theme/components/**/*.scss: + + Must be fully enclosed in a class matching either: + - A Component subclass (Spa_Action, Spa_Layout, or direct Component) + - A Blade view's @rsx_id value (server-rendered page styles) + + The SCSS filename must match the associated JS or Blade file's + filename (with .scss extension instead of .js/.blade.php). + + Example - SPA Action: + rsx/app/frontend/dashboard/Dashboard_Index_Action.js + rsx/app/frontend/dashboard/dashboard_index_action.scss + + // dashboard_index_action.scss + .Dashboard_Index_Action { + // ALL styles nested here + } + + Example - SPA Layout: + rsx/app/frontend/Frontend_Spa_Layout.js + rsx/app/frontend/frontend_spa_layout.scss + + // frontend_spa_layout.scss + .Frontend_Spa_Layout { + // ALL layout styles nested here + .app-sidebar { ... } + .app-content { ... } + } + + Example - Blade View: + rsx/app/login/login_index.blade.php // has @rsx_id('Login_Index') + rsx/app/login/login_index.scss + + // login_index.scss + .Login_Index { + // ALL styles nested here + } + + Example - Theme Component: + rsx/theme/components/sidebar/sidebar_nav.js + rsx/theme/components/sidebar/sidebar_nav.scss + + // sidebar_nav.scss + .Sidebar_Nav { + // ALL styles nested here + } + + Files elsewhere: + SCSS files outside these paths are not validated by this rule + and can be organized as needed (e.g., global utilities, variables + in rsx/theme/base/). + +SCSS VARIABLES + + SCSS variable declarations ($var: value;) are allowed OUTSIDE the + wrapper class. This enables variables to be shared when the file is + imported by other SCSS files. + + Example - Variables outside wrapper: + + // frontend_spa_layout.scss + $sidebar-width: 215px; + $header-height: 57px; + $mobile-breakpoint: 991.98px; + + .Frontend_Spa_Layout { + .sidebar { + width: $sidebar-width; + } + .header { + height: $header-height; + } + } + + The manifest scanner strips variable declarations before checking + for the wrapper class, so they do not cause validation failures. + +VARIABLES-ONLY FILES + + Files containing ONLY variable declarations and comments (no actual + CSS rules or selectors) are considered valid without a wrapper class. + These are typically partial files intended to be imported by others. + + Example - Variables-only file (valid): + + // _variables.scss + $primary-color: #0d6efd; + $secondary-color: #6c757d; + $border-radius: 0.375rem; + + Such files are marked with scss_variables_only in the manifest and + skip wrapper validation entirely. + +SUPPLEMENTAL SCSS FILES + + When a single SCSS file becomes unwieldy, you can split styles into + multiple files. Supplemental SCSS files may have different filenames + as long as: + + 1. A primary SCSS file exists with the matching filename (e.g., + frontend_spa_layout.scss for Frontend_Spa_Layout) + 2. The supplemental file uses the SAME wrapper class as the primary + + This allows organizing styles by breakpoint, feature, or logical + grouping while maintaining the scoping convention. + + Example - Splitting by breakpoint: + + rsx/app/frontend/ + frontend_spa_layout.scss // Primary file (required) + frontend_spa_layout_mobile.scss // Supplemental - mobile styles + frontend_spa_layout_print.scss // Supplemental - print styles + + // frontend_spa_layout.scss (primary) + .Frontend_Spa_Layout { + .sidebar { width: 215px; } + .header { height: 57px; } + } + + // frontend_spa_layout_mobile.scss (supplemental) + .Frontend_Spa_Layout { + @media (max-width: 768px) { + .sidebar { width: 100%; } + .header { height: 48px; } + } + } + + // frontend_spa_layout_print.scss (supplemental) + .Frontend_Spa_Layout { + @media print { + .sidebar { display: none; } + .no-print { display: none; } + } + } + + The primary file MUST exist first. Without it, supplemental files + will fail validation with a filename mismatch error. + +BENEFITS + + No CSS Conflicts: + .notice-item in Dashboard_Index_Action won't affect .notice-item + in Calendar_Index_Action because they're in different scope wrappers. + + Self-Documenting: + File name tells you exactly which action/component it styles. + Delete the action -> delete its SCSS -> no orphaned styles. + + Simple Class Names: + Use .team-grid instead of .dashboard-index-action__team-grid. + The wrapper provides the scoping automatically. + + Predictable Specificity: + All page/component styles get the same specificity boost from + being nested under their wrapper class. + + Safe Refactoring: + Moving or renaming an action means moving/renaming its SCSS. + No hunting through global stylesheets for related rules. + +HOW IT WORKS + + jqhtml components and Spa_Action classes automatically add their + class name to the root DOM element. For example, a component defined + as will have class="Sidebar_Nav" on its root. + + Blade views use @rsx_id('View_Name') which can be output to the DOM + for the same scoping effect. + + The manifest scanner detects if an SCSS file is fully enclosed in + a single class rule by: + 1. Removing comments + 2. Stripping SCSS variable declarations ($var: value;) + 3. Checking if remaining content matches pattern: .ClassName { ... } + 4. Verifying bracket balance (all content inside the wrapper) + + A code quality rule then validates: + 1. The wrapper class exists (or file is variables-only) + 2. It matches a valid Component subclass or Blade @rsx_id + 3. The filename matches the associated file + +NO EXEMPTIONS + + There are NO exemptions to this rule for files in rsx/app/ or + rsx/theme/components/. Every SCSS file in these directories must + be scoped to its associated action, layout, component, or view. + + If a file cannot be associated with any of these (extremely rare), + it likely belongs elsewhere: + - rsx/theme/base/ for global utilities and variables + - rsx/theme/layouts/ for shared layout styles + - A dedicated partial imported via @use + + Moving files outside the enforced directories requires explicit + developer approval and should be carefully considered. In 99% of + cases, the SCSS file should be properly scoped. + +VALIDATION + + The scoping rule is enforced at manifest build time. Violations + produce errors like: + + SCSS file 'rsx/app/frontend/dashboard/dashboard.scss' must be + fully enclosed in a single class rule matching a Component + or Blade @rsx_id. + + Expected: .Dashboard_Index_Action { ... } + Found: No wrapper class detected + + Or for wrapper class mismatches: + + SCSS wrapper class 'Frontend_Dashboard' does not match any + Component class or Blade @rsx_id + + Or for filename mismatches: + + SCSS filename 'styles.scss' must match associated Component + file 'dashboard_index_action' + +EXAMPLES + + Correct - SPA Action Styles: + + // rsx/app/frontend/invoices/invoices_view_action.scss + .Invoices_View_Action { + .invoice-header { + display: flex; + justify-content: space-between; + } + + .line-items { + .item-row { + border-bottom: 1px solid #eee; + } + } + + @media (max-width: 768px) { + .invoice-header { + flex-direction: column; + } + } + } + + Correct - Layout with Variables: + + // rsx/app/frontend/frontend_spa_layout.scss + $sidebar-width: 215px; + $header-height: 57px; + + .Frontend_Spa_Layout { + .app-sidebar { + width: $sidebar-width; + position: fixed; + } + + .app-header { + height: $header-height; + } + } + + Correct - Component Styles: + + // rsx/theme/components/modal/rsx_modal.scss + .Rsx_Modal { + .modal-header { + border-bottom: 1px solid var(--border-color); + } + + .modal-body { + padding: 1.5rem; + } + + &.modal-lg { + .modal-dialog { + max-width: 800px; + } + } + } + + Incorrect - Multiple Top-Level Rules: + + // BAD: Multiple selectors at top level + .Dashboard_Index_Action { + .card { ... } + } + + .sidebar { // ERROR: This is outside the wrapper + width: 200px; + } + + Incorrect - No Wrapper: + + // BAD: No wrapper class + .card { + padding: 1rem; + } + + .stats-grid { + display: grid; + } + +SEE ALSO + spa - SPA routing and actions + jqhtml - Component template system + coding_standards - General naming conventions + code_quality - Code quality rule system