Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
253
src/Framework/Design/Analyzer/ColorAnalysisResult.php
Normal file
253
src/Framework/Design/Analyzer/ColorAnalysisResult.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
/**
|
||||
* Ergebnis der Farbanalyse
|
||||
*/
|
||||
final readonly class ColorAnalysisResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $totalColors,
|
||||
public array $colorsByFormat,
|
||||
public array $colorPalette,
|
||||
public array $contrastAnalysis,
|
||||
public array $duplicateColors,
|
||||
public array $recommendations
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Farbdiversität-Score zurück (0-100)
|
||||
*/
|
||||
public function getDiversityScore(): int
|
||||
{
|
||||
if ($this->totalColors === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$categoryDistribution = [
|
||||
'primary' => count($this->colorPalette['primary_colors']),
|
||||
'neutral' => count($this->colorPalette['neutral_colors']),
|
||||
'accent' => count($this->colorPalette['accent_colors']),
|
||||
'semantic' => count($this->colorPalette['semantic_colors']),
|
||||
];
|
||||
|
||||
$nonZeroCategories = count(array_filter($categoryDistribution));
|
||||
$maxCategories = 4;
|
||||
|
||||
// Basis-Score für Kategorien-Abdeckung
|
||||
$categoryScore = ($nonZeroCategories / $maxCategories) * 50;
|
||||
|
||||
// Bonus für ausgewogene Verteilung
|
||||
$total = array_sum($categoryDistribution);
|
||||
if ($total > 0) {
|
||||
$entropy = 0;
|
||||
foreach ($categoryDistribution as $count) {
|
||||
if ($count > 0) {
|
||||
$probability = $count / $total;
|
||||
$entropy -= $probability * log($probability, 2);
|
||||
}
|
||||
}
|
||||
$maxEntropy = log($nonZeroCategories, 2);
|
||||
$balanceScore = $maxEntropy > 0 ? ($entropy / $maxEntropy) * 50 : 0;
|
||||
} else {
|
||||
$balanceScore = 0;
|
||||
}
|
||||
|
||||
return min(100, (int) round($categoryScore + $balanceScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Konsistenz-Score zurück (0-100)
|
||||
*/
|
||||
public function getConsistencyScore(): int
|
||||
{
|
||||
if ($this->totalColors === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$score = 100;
|
||||
|
||||
// Abzug für Duplikate
|
||||
$totalDuplicates = array_sum(array_column($this->duplicateColors, 'potential_savings'));
|
||||
$duplicatePercentage = ($totalDuplicates / max(1, $this->totalColors)) * 100;
|
||||
$score -= min(30, $duplicatePercentage * 2);
|
||||
|
||||
// Abzug für zu viele Formate
|
||||
$formatCount = count($this->colorsByFormat);
|
||||
if ($formatCount > 3) {
|
||||
$score -= ($formatCount - 3) * 10;
|
||||
}
|
||||
|
||||
return max(0, (int) $score);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Kontrast-Compliance-Score zurück (0-100)
|
||||
*/
|
||||
public function getContrastComplianceScore(): int
|
||||
{
|
||||
if (empty($this->contrastAnalysis)) {
|
||||
return 100; // Kein Kontrast zu prüfen
|
||||
}
|
||||
|
||||
$totalPairs = count($this->contrastAnalysis);
|
||||
$wcagAAPassing = count(array_filter($this->contrastAnalysis, fn ($c) => $c['wcag_aa']));
|
||||
|
||||
return (int) round(($wcagAAPassing / $totalPairs) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die häufigsten Farbformate zurück
|
||||
*/
|
||||
public function getMostUsedFormats(): array
|
||||
{
|
||||
$formatUsage = [];
|
||||
|
||||
foreach ($this->colorsByFormat as $format => $colors) {
|
||||
$formatUsage[$format] = count($colors);
|
||||
}
|
||||
|
||||
arsort($formatUsage);
|
||||
|
||||
return $formatUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Farbpaletten-Zusammenfassung zurück
|
||||
*/
|
||||
public function getPaletteSummary(): array
|
||||
{
|
||||
return [
|
||||
'total_colors' => $this->totalColors,
|
||||
'primary_colors' => count($this->colorPalette['primary_colors']),
|
||||
'neutral_colors' => count($this->colorPalette['neutral_colors']),
|
||||
'accent_colors' => count($this->colorPalette['accent_colors']),
|
||||
'semantic_colors' => count($this->colorPalette['semantic_colors']),
|
||||
'diversity_score' => $this->getDiversityScore(),
|
||||
'consistency_score' => $this->getConsistencyScore(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die problematischsten Farbkombinationen zurück
|
||||
*/
|
||||
public function getWorstContrastPairs(): array
|
||||
{
|
||||
$lowContrastPairs = array_filter(
|
||||
$this->contrastAnalysis,
|
||||
fn ($contrast) => ! $contrast['wcag_aa']
|
||||
);
|
||||
|
||||
// Sortiere nach niedrigstem Kontrast
|
||||
usort($lowContrastPairs, fn ($a, $b) => $a['contrast_ratio'] <=> $b['contrast_ratio']);
|
||||
|
||||
return array_slice($lowContrastPairs, 0, 5); // Top 5 problematischste
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt priorisierte Verbesserungs-Aktionen zurück
|
||||
*/
|
||||
public function getPrioritizedActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
// Hohe Priorität: Accessibility-Probleme
|
||||
$badContrastPairs = $this->getWorstContrastPairs();
|
||||
if (! empty($badContrastPairs)) {
|
||||
$actions[] = [
|
||||
'priority' => 'high',
|
||||
'category' => 'accessibility',
|
||||
'action' => 'Fix ' . count($badContrastPairs) . ' low-contrast color combinations',
|
||||
'impact' => 'WCAG compliance and readability',
|
||||
];
|
||||
}
|
||||
|
||||
// Mittlere Priorität: Duplikate
|
||||
if (! empty($this->duplicateColors)) {
|
||||
$totalDuplicates = array_sum(array_column($this->duplicateColors, 'potential_savings'));
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'category' => 'optimization',
|
||||
'action' => "Consolidate {$totalDuplicates} duplicate colors",
|
||||
'impact' => 'CSS size reduction and maintenance',
|
||||
];
|
||||
}
|
||||
|
||||
// Niedrige Priorität: Palette-Optimierung
|
||||
if ($this->getDiversityScore() < 60) {
|
||||
$actions[] = [
|
||||
'priority' => 'low',
|
||||
'category' => 'design_system',
|
||||
'action' => 'Improve color palette diversity and semantic naming',
|
||||
'impact' => 'Design system consistency',
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Format-Empfehlungen zurück
|
||||
*/
|
||||
public function getFormatRecommendations(): array
|
||||
{
|
||||
$recommendations = [];
|
||||
$formats = $this->getMostUsedFormats();
|
||||
|
||||
// Zu viele Formate
|
||||
if (count($formats) > 3) {
|
||||
$recommendations[] = [
|
||||
'type' => 'standardization',
|
||||
'message' => 'Multiple color formats detected. Consider standardizing on 2-3 formats.',
|
||||
'suggestion' => 'Use hex for static colors, custom properties for theme colors, OKLCH for advanced color spaces.',
|
||||
];
|
||||
}
|
||||
|
||||
// OKLCH Empfehlung für größere Paletten
|
||||
if (! isset($formats['oklch']) && $this->totalColors > 15) {
|
||||
$recommendations[] = [
|
||||
'type' => 'modern_format',
|
||||
'message' => 'Consider OKLCH format for better color consistency and interpolation.',
|
||||
'suggestion' => 'OKLCH provides more perceptually uniform color space, especially useful for generated color scales.',
|
||||
];
|
||||
}
|
||||
|
||||
// Custom Properties Empfehlung
|
||||
if (! isset($formats['custom_property']) && $this->totalColors > 8) {
|
||||
$recommendations[] = [
|
||||
'type' => 'design_tokens',
|
||||
'message' => 'Consider using CSS custom properties for theme colors.',
|
||||
'suggestion' => 'Custom properties enable theming, better maintainability, and runtime color changes.',
|
||||
];
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_colors' => $this->totalColors,
|
||||
'colors_by_format' => $this->colorsByFormat,
|
||||
'color_palette' => $this->colorPalette,
|
||||
'contrast_analysis' => $this->contrastAnalysis,
|
||||
'duplicate_colors' => $this->duplicateColors,
|
||||
'recommendations' => $this->recommendations,
|
||||
'diversity_score' => $this->getDiversityScore(),
|
||||
'consistency_score' => $this->getConsistencyScore(),
|
||||
'contrast_compliance_score' => $this->getContrastComplianceScore(),
|
||||
'palette_summary' => $this->getPaletteSummary(),
|
||||
'worst_contrast_pairs' => $this->getWorstContrastPairs(),
|
||||
'prioritized_actions' => $this->getPrioritizedActions(),
|
||||
'format_recommendations' => $this->getFormatRecommendations(),
|
||||
'most_used_formats' => $this->getMostUsedFormats(),
|
||||
];
|
||||
}
|
||||
}
|
||||
610
src/Framework/Design/Analyzer/ColorAnalyzer.php
Normal file
610
src/Framework/Design/Analyzer/ColorAnalyzer.php
Normal file
@@ -0,0 +1,610 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Core\ValueObjects\RGBColor;
|
||||
use App\Framework\Design\ValueObjects\CssColor;
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Design\ValueObjects\CssPropertyCategory;
|
||||
|
||||
/**
|
||||
* Analysiert Farben im Design System
|
||||
*/
|
||||
final readonly class ColorAnalyzer
|
||||
{
|
||||
/**
|
||||
* Analysiert alle Farben im CSS
|
||||
*/
|
||||
public function analyzeColors(CssParseResult $parseResult): ColorAnalysisResult
|
||||
{
|
||||
// Sammle alle Farben
|
||||
$colors = $this->collectAllColors($parseResult);
|
||||
|
||||
if (empty($colors)) {
|
||||
return new ColorAnalysisResult(
|
||||
totalColors: 0,
|
||||
colorsByFormat: [],
|
||||
colorPalette: [],
|
||||
contrastAnalysis: [],
|
||||
duplicateColors: [],
|
||||
recommendations: ['No colors found in CSS. Consider implementing a color system with design tokens.']
|
||||
);
|
||||
}
|
||||
|
||||
// Analysiere Farbpalette
|
||||
$palette = $this->generateColorPalette($colors);
|
||||
|
||||
// Gruppiere nach Format
|
||||
$colorsByFormat = $this->groupColorsByFormat($colors);
|
||||
|
||||
// Analysiere Kontraste (vereinfacht)
|
||||
$contrastAnalysis = $this->analyzeContrasts($colors);
|
||||
|
||||
// Finde Duplikate
|
||||
$duplicates = $this->findDuplicateColors($colors);
|
||||
|
||||
// Generiere Empfehlungen
|
||||
$recommendations = $this->generateRecommendations($colors, $palette, $duplicates, $colorsByFormat);
|
||||
|
||||
return new ColorAnalysisResult(
|
||||
totalColors: count($colors),
|
||||
colorsByFormat: $colorsByFormat,
|
||||
colorPalette: $palette,
|
||||
contrastAnalysis: $contrastAnalysis,
|
||||
duplicateColors: $duplicates,
|
||||
recommendations: $recommendations
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert spezifische Farbharmonien
|
||||
*/
|
||||
public function analyzeColorHarmony(array $colors): array
|
||||
{
|
||||
$harmony = [
|
||||
'monochromatic' => [],
|
||||
'complementary' => [],
|
||||
'triadic' => [],
|
||||
'analogous' => [],
|
||||
'split_complementary' => [],
|
||||
];
|
||||
|
||||
foreach ($colors as $color) {
|
||||
$rgb = $color['color']->toRGB();
|
||||
if (! $rgb) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hsl = $this->rgbToHsl($rgb);
|
||||
$hue = $hsl['h'];
|
||||
|
||||
// Finde harmonische Farben
|
||||
foreach ($colors as $otherColor) {
|
||||
if ($color === $otherColor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$otherRgb = $otherColor['color']->toRGB();
|
||||
if (! $otherRgb) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$otherHsl = $this->rgbToHsl($otherRgb);
|
||||
$otherHue = $otherHsl['h'];
|
||||
|
||||
$hueDiff = abs($hue - $otherHue);
|
||||
$hueDiff = min($hueDiff, 360 - $hueDiff); // Zirkuläre Distanz
|
||||
|
||||
// Klassifiziere Harmonie-Typ
|
||||
if ($hueDiff < 30) {
|
||||
$harmony['analogous'][] = [$color, $otherColor];
|
||||
} elseif ($hueDiff > 150 && $hueDiff < 210) {
|
||||
$harmony['complementary'][] = [$color, $otherColor];
|
||||
} elseif (abs($hueDiff - 120) < 30 || abs($hueDiff - 240) < 30) {
|
||||
$harmony['triadic'][] = [$color, $otherColor];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $harmony;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet Farbzugänglichkeit
|
||||
*/
|
||||
public function analyzeAccessibility(CssParseResult $parseResult): array
|
||||
{
|
||||
$accessibilityIssues = [];
|
||||
$colorProperties = $parseResult->getPropertiesByCategory(CssPropertyCategory::COLOR);
|
||||
|
||||
// Sammle Text/Background-Kombinationen
|
||||
$textBackgroundPairs = [];
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
$textColor = null;
|
||||
$backgroundColor = null;
|
||||
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->name === 'color') {
|
||||
$textColor = $property->toColor();
|
||||
} elseif ($property->name === 'background-color') {
|
||||
$backgroundColor = $property->toColor();
|
||||
}
|
||||
}
|
||||
|
||||
if ($textColor && $backgroundColor) {
|
||||
$contrast = $this->calculateContrast($textColor, $backgroundColor);
|
||||
|
||||
if ($contrast < 4.5) { // WCAG AA Standard
|
||||
$accessibilityIssues[] = [
|
||||
'type' => 'low_contrast',
|
||||
'severity' => $contrast < 3.0 ? 'high' : 'medium',
|
||||
'contrast_ratio' => round($contrast, 2),
|
||||
'text_color' => $textColor->originalValue,
|
||||
'background_color' => $backgroundColor->originalValue,
|
||||
'selectors' => $rule->getSelectorStrings(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analysiere reine Farbkommunikation
|
||||
$colorOnlyIndicators = $this->findColorOnlyIndicators($parseResult);
|
||||
|
||||
return [
|
||||
'contrast_issues' => $accessibilityIssues,
|
||||
'color_only_indicators' => $colorOnlyIndicators,
|
||||
'total_issues' => count($accessibilityIssues) + count($colorOnlyIndicators),
|
||||
'accessibility_score' => $this->calculateAccessibilityScore($accessibilityIssues, $colorOnlyIndicators),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt alle Farben aus dem CSS
|
||||
*/
|
||||
private function collectAllColors(CssParseResult $parseResult): array
|
||||
{
|
||||
$colors = [];
|
||||
|
||||
// Farben aus Properties
|
||||
$colorProperties = $parseResult->getPropertiesByCategory(CssPropertyCategory::COLOR);
|
||||
|
||||
foreach ($colorProperties as $property) {
|
||||
$color = $property->toColor();
|
||||
if ($color) {
|
||||
$colors[] = [
|
||||
'color' => $color,
|
||||
'property' => $property->name,
|
||||
'context' => 'property',
|
||||
'usage_count' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Farben aus Design Tokens
|
||||
foreach ($parseResult->customProperties as $token) {
|
||||
if ($token->hasValueType('color')) {
|
||||
$color = $token->getValueAs('color');
|
||||
if ($color instanceof CssColor) {
|
||||
$colors[] = [
|
||||
'color' => $color,
|
||||
'property' => $token->name,
|
||||
'context' => 'token',
|
||||
'usage_count' => 0, // TODO: Track usage
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eine strukturierte Farbpalette
|
||||
*/
|
||||
private function generateColorPalette(array $colors): array
|
||||
{
|
||||
$palette = [
|
||||
'primary_colors' => [],
|
||||
'neutral_colors' => [],
|
||||
'accent_colors' => [],
|
||||
'semantic_colors' => [],
|
||||
];
|
||||
|
||||
foreach ($colors as $colorData) {
|
||||
$color = $colorData['color'];
|
||||
$property = $colorData['property'];
|
||||
|
||||
// Kategorisiere Farben basierend auf Namen und Eigenschaften
|
||||
if ($this->isPrimaryColor($property, $color)) {
|
||||
$palette['primary_colors'][] = $colorData;
|
||||
} elseif ($this->isNeutralColor($property, $color)) {
|
||||
$palette['neutral_colors'][] = $colorData;
|
||||
} elseif ($this->isSemanticColor($property)) {
|
||||
$palette['semantic_colors'][] = $colorData;
|
||||
} else {
|
||||
$palette['accent_colors'][] = $colorData;
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere jede Kategorie nach Helligkeit
|
||||
foreach ($palette as $category => &$categoryColors) {
|
||||
usort($categoryColors, function ($a, $b) {
|
||||
$rgbA = $a['color']->toRGB();
|
||||
$rgbB = $b['color']->toRGB();
|
||||
|
||||
if (! $rgbA || ! $rgbB) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$lightnessA = $this->calculateLightness($rgbA);
|
||||
$lightnessB = $this->calculateLightness($rgbB);
|
||||
|
||||
return $lightnessA <=> $lightnessB;
|
||||
});
|
||||
}
|
||||
|
||||
return $palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Farben nach Format
|
||||
*/
|
||||
private function groupColorsByFormat(array $colors): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($colors as $colorData) {
|
||||
$format = $colorData['color']->format->value;
|
||||
|
||||
if (! isset($grouped[$format])) {
|
||||
$grouped[$format] = [];
|
||||
}
|
||||
|
||||
$grouped[$format][] = $colorData;
|
||||
}
|
||||
|
||||
// Sortiere nach Häufigkeit
|
||||
uasort($grouped, fn ($a, $b) => count($b) <=> count($a));
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Farbkontraste
|
||||
*/
|
||||
private function analyzeContrasts(array $colors): array
|
||||
{
|
||||
$contrasts = [];
|
||||
|
||||
// Erstelle Kontrast-Matrix für häufige Kombinationen
|
||||
foreach ($colors as $i => $colorA) {
|
||||
foreach ($colors as $j => $colorB) {
|
||||
if ($i >= $j) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$contrast = $this->calculateContrast($colorA['color'], $colorB['color']);
|
||||
|
||||
if ($contrast !== null) {
|
||||
$contrasts[] = [
|
||||
'color_a' => $colorA,
|
||||
'color_b' => $colorB,
|
||||
'contrast_ratio' => round($contrast, 2),
|
||||
'wcag_aa' => $contrast >= 4.5,
|
||||
'wcag_aaa' => $contrast >= 7.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere nach Kontrast-Ratio
|
||||
usort($contrasts, fn ($a, $b) => $b['contrast_ratio'] <=> $a['contrast_ratio']);
|
||||
|
||||
return array_slice($contrasts, 0, 20); // Limitiere auf Top 20
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet doppelte Farben
|
||||
*/
|
||||
private function findDuplicateColors(array $colors): array
|
||||
{
|
||||
$duplicates = [];
|
||||
$rgbMap = [];
|
||||
|
||||
foreach ($colors as $colorData) {
|
||||
$rgb = $colorData['color']->toRGB();
|
||||
if (! $rgb) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rgbKey = $rgb->red . ',' . $rgb->green . ',' . $rgb->blue;
|
||||
|
||||
if (! isset($rgbMap[$rgbKey])) {
|
||||
$rgbMap[$rgbKey] = [];
|
||||
}
|
||||
|
||||
$rgbMap[$rgbKey][] = $colorData;
|
||||
}
|
||||
|
||||
// Finde Gruppen mit mehr als einem Eintrag
|
||||
foreach ($rgbMap as $rgbKey => $colorGroup) {
|
||||
if (count($colorGroup) > 1) {
|
||||
$duplicates[] = [
|
||||
'rgb_values' => $rgbKey,
|
||||
'colors' => $colorGroup,
|
||||
'count' => count($colorGroup),
|
||||
'potential_savings' => count($colorGroup) - 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere nach Anzahl der Duplikate
|
||||
usort($duplicates, fn ($a, $b) => $b['count'] <=> $a['count']);
|
||||
|
||||
return $duplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Kontrast zwischen zwei Farben
|
||||
*/
|
||||
private function calculateContrast(CssColor $colorA, CssColor $colorB): ?float
|
||||
{
|
||||
$rgbA = $colorA->toRGB();
|
||||
$rgbB = $colorB->toRGB();
|
||||
|
||||
if (! $rgbA || ! $rgbB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$luminanceA = $this->calculateRelativeLuminance($rgbA);
|
||||
$luminanceB = $this->calculateRelativeLuminance($rgbB);
|
||||
|
||||
$lighter = max($luminanceA, $luminanceB);
|
||||
$darker = min($luminanceA, $luminanceB);
|
||||
|
||||
return ($lighter + 0.05) / ($darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet relative Luminanz
|
||||
*/
|
||||
private function calculateRelativeLuminance(RGBColor $rgb): float
|
||||
{
|
||||
$r = $this->linearizeColorComponent($rgb->red / 255);
|
||||
$g = $this->linearizeColorComponent($rgb->green / 255);
|
||||
$b = $this->linearizeColorComponent($rgb->blue / 255);
|
||||
|
||||
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearisiert Farbkomponente für Luminanz-Berechnung
|
||||
*/
|
||||
private function linearizeColorComponent(float $component): float
|
||||
{
|
||||
if ($component <= 0.03928) {
|
||||
return $component / 12.92;
|
||||
} else {
|
||||
return pow(($component + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert RGB zu HSL
|
||||
*/
|
||||
private function rgbToHsl(RGBColor $rgb): array
|
||||
{
|
||||
$r = $rgb->red / 255;
|
||||
$g = $rgb->green / 255;
|
||||
$b = $rgb->blue / 255;
|
||||
|
||||
$max = max($r, $g, $b);
|
||||
$min = min($r, $g, $b);
|
||||
$diff = $max - $min;
|
||||
|
||||
// Lightness
|
||||
$l = ($max + $min) / 2;
|
||||
|
||||
if ($diff === 0.0) {
|
||||
$h = $s = 0; // Achromatic
|
||||
} else {
|
||||
// Saturation
|
||||
$s = $l > 0.5 ? $diff / (2 - $max - $min) : $diff / ($max + $min);
|
||||
|
||||
// Hue
|
||||
switch ($max) {
|
||||
case $r:
|
||||
$h = (($g - $b) / $diff + ($g < $b ? 6 : 0)) / 6;
|
||||
|
||||
break;
|
||||
case $g:
|
||||
$h = (($b - $r) / $diff + 2) / 6;
|
||||
|
||||
break;
|
||||
case $b:
|
||||
$h = (($r - $g) / $diff + 4) / 6;
|
||||
|
||||
break;
|
||||
default:
|
||||
$h = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'h' => $h * 360,
|
||||
's' => $s * 100,
|
||||
'l' => $l * 100,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Helligkeit einer Farbe
|
||||
*/
|
||||
private function calculateLightness(RGBColor $rgb): float
|
||||
{
|
||||
return ($rgb->red * 0.299 + $rgb->green * 0.587 + $rgb->blue * 0.114) / 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es eine primäre Farbe ist
|
||||
*/
|
||||
private function isPrimaryColor(string $property, CssColor $color): bool
|
||||
{
|
||||
return str_contains(strtolower($property), 'primary') ||
|
||||
str_contains(strtolower($property), 'brand') ||
|
||||
str_contains(strtolower($property), 'main');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es eine neutrale Farbe ist
|
||||
*/
|
||||
private function isNeutralColor(string $property, CssColor $color): bool
|
||||
{
|
||||
if (str_contains(strtolower($property), 'gray') ||
|
||||
str_contains(strtolower($property), 'grey') ||
|
||||
str_contains(strtolower($property), 'neutral')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prüfe RGB-Werte für Grautöne
|
||||
$rgb = $color->toRGB();
|
||||
if ($rgb) {
|
||||
$maxDiff = max($rgb->red, $rgb->green, $rgb->blue) - min($rgb->red, $rgb->green, $rgb->blue);
|
||||
|
||||
return $maxDiff < 20; // Sehr niedrige Sättigung
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob es eine semantische Farbe ist
|
||||
*/
|
||||
private function isSemanticColor(string $property): bool
|
||||
{
|
||||
$semanticKeywords = ['success', 'error', 'warning', 'info', 'danger', 'alert'];
|
||||
|
||||
foreach ($semanticKeywords as $keyword) {
|
||||
if (str_contains(strtolower($property), $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet reine Farbindikatoren (Accessibility Problem)
|
||||
*/
|
||||
private function findColorOnlyIndicators(CssParseResult $parseResult): array
|
||||
{
|
||||
$indicators = [];
|
||||
|
||||
// Suche nach Patterns die nur Farbe für Information nutzen
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
$hasColorChange = false;
|
||||
$hasOtherIndicator = false;
|
||||
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->getCategory() === CssPropertyCategory::COLOR) {
|
||||
$hasColorChange = true;
|
||||
}
|
||||
|
||||
if (in_array($property->name, ['border', 'text-decoration', 'font-weight', 'background-image'])) {
|
||||
$hasOtherIndicator = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasColorChange && ! $hasOtherIndicator) {
|
||||
// Prüfe ob es sich um Status-Indikatoren handelt
|
||||
foreach ($rule->selectors as $selector) {
|
||||
if (preg_match('/\.(error|success|warning|info|active|selected|invalid)/', $selector->value)) {
|
||||
$indicators[] = [
|
||||
'selector' => $selector->value,
|
||||
'issue' => 'Uses color only for status indication',
|
||||
'suggestion' => 'Add icons, borders, or text indicators alongside color',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $indicators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Accessibility Score
|
||||
*/
|
||||
private function calculateAccessibilityScore(array $contrastIssues, array $colorOnlyIndicators): int
|
||||
{
|
||||
$baseScore = 100;
|
||||
|
||||
// Abzug für Kontrast-Probleme
|
||||
foreach ($contrastIssues as $issue) {
|
||||
$deduction = match($issue['severity']) {
|
||||
'high' => 15,
|
||||
'medium' => 10,
|
||||
default => 5
|
||||
};
|
||||
$baseScore -= $deduction;
|
||||
}
|
||||
|
||||
// Abzug für reine Farbindikatoren
|
||||
$baseScore -= count($colorOnlyIndicators) * 5;
|
||||
|
||||
return max(0, $baseScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen
|
||||
*/
|
||||
private function generateRecommendations(array $colors, array $palette, array $duplicates, array $colorsByFormat): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Duplikate-Empfehlungen
|
||||
if (count($duplicates) > 0) {
|
||||
$totalDuplicates = array_sum(array_column($duplicates, 'potential_savings'));
|
||||
$recommendations[] = "Remove {$totalDuplicates} duplicate colors to reduce CSS complexity and improve maintainability.";
|
||||
}
|
||||
|
||||
// Format-Empfehlungen
|
||||
$formatCounts = array_map('count', $colorsByFormat);
|
||||
if (count($formatCounts) > 3) {
|
||||
$recommendations[] = 'Multiple color formats detected. Consider standardizing on fewer formats (e.g., hex for static colors, custom properties for theme colors).';
|
||||
}
|
||||
|
||||
// OKLCH-Empfehlung
|
||||
if (! isset($colorsByFormat['oklch']) && count($colors) > 10) {
|
||||
$recommendations[] = 'Consider using OKLCH format for more perceptually uniform color interpolation and better color consistency.';
|
||||
}
|
||||
|
||||
// Palette-Empfehlungen
|
||||
$totalColors = count($colors);
|
||||
if ($totalColors > 50) {
|
||||
$recommendations[] = "Large color palette detected ({$totalColors} colors). Consider consolidating similar colors into a smaller, more manageable palette.";
|
||||
}
|
||||
|
||||
if (count($palette['primary_colors']) === 0 && $totalColors > 5) {
|
||||
$recommendations[] = 'No primary brand colors identified. Consider establishing clear primary colors for brand consistency.';
|
||||
}
|
||||
|
||||
if (count($palette['neutral_colors']) < 3 && $totalColors > 10) {
|
||||
$recommendations[] = 'Limited neutral color palette. Consider adding more gray tones for better design flexibility.';
|
||||
}
|
||||
|
||||
// Semantic Color Empfehlungen
|
||||
if (count($palette['semantic_colors']) === 0 && $totalColors > 8) {
|
||||
$recommendations[] = 'No semantic colors found. Consider adding success, error, warning, and info colors for UI feedback.';
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'Color system is well organized. Consider documenting color usage guidelines and accessibility standards.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
}
|
||||
166
src/Framework/Design/Analyzer/ComponentDetectionResult.php
Normal file
166
src/Framework/Design/Analyzer/ComponentDetectionResult.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\ComponentPattern;
|
||||
|
||||
/**
|
||||
* Ergebnis der Component-Pattern-Erkennung
|
||||
*/
|
||||
final readonly class ComponentDetectionResult
|
||||
{
|
||||
/**
|
||||
* @param ComponentPattern[] $bemComponents
|
||||
* @param ComponentPattern[] $utilityComponents
|
||||
* @param ComponentPattern[] $traditionalComponents
|
||||
*/
|
||||
public function __construct(
|
||||
public int $totalComponents,
|
||||
public array $bemComponents,
|
||||
public array $utilityComponents,
|
||||
public array $traditionalComponents,
|
||||
public array $patternStatistics,
|
||||
public array $recommendations
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die dominante Pattern-Methodik zurück
|
||||
*/
|
||||
public function getDominantPattern(): string
|
||||
{
|
||||
$counts = [
|
||||
'bem' => count($this->bemComponents),
|
||||
'utility' => count($this->utilityComponents),
|
||||
'traditional' => count($this->traditionalComponents),
|
||||
];
|
||||
|
||||
$max = max($counts);
|
||||
if ($max === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return array_search($max, $counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Pattern-Diversity-Score zurück (0-100)
|
||||
*/
|
||||
public function getPatternDiversity(): int
|
||||
{
|
||||
$patterns = [$this->bemComponents, $this->utilityComponents, $this->traditionalComponents];
|
||||
$nonEmptyPatterns = array_filter($patterns, fn ($p) => ! empty($p));
|
||||
|
||||
if (empty($nonEmptyPatterns)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Je mehr verschiedene Pattern-Typen verwendet werden, desto höher die Diversity
|
||||
$diversityScore = (count($nonEmptyPatterns) / 3) * 100;
|
||||
|
||||
return (int) round($diversityScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Konsistenz-Score zurück (0-100)
|
||||
*/
|
||||
public function getConsistencyScore(): int
|
||||
{
|
||||
$totalComponents = $this->totalComponents;
|
||||
|
||||
if ($totalComponents === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
$dominantCount = max(
|
||||
count($this->bemComponents),
|
||||
count($this->utilityComponents),
|
||||
count($this->traditionalComponents)
|
||||
);
|
||||
|
||||
// Je höher der Anteil der dominanten Methodik, desto konsistenter
|
||||
$consistencyScore = ($dominantCount / $totalComponents) * 100;
|
||||
|
||||
return (int) round($consistencyScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt empfohlene Aktionen basierend auf der Analyse zurück
|
||||
*/
|
||||
public function getActionableRecommendations(): array
|
||||
{
|
||||
$actions = [];
|
||||
|
||||
$consistency = $this->getConsistencyScore();
|
||||
$diversity = $this->getPatternDiversity();
|
||||
|
||||
if ($consistency < 60 && $diversity > 80) {
|
||||
$actions[] = [
|
||||
'priority' => 'high',
|
||||
'action' => 'standardize_methodology',
|
||||
'description' => 'Standardize on one primary CSS methodology to improve consistency.',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($this->bemComponents) > 5 && count($this->utilityComponents) < 3) {
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'action' => 'add_utilities',
|
||||
'description' => 'Add utility classes for common spacing, colors, and typography.',
|
||||
];
|
||||
}
|
||||
|
||||
if (count($this->traditionalComponents) > 10) {
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'action' => 'refactor_to_bem',
|
||||
'description' => 'Consider refactoring traditional components to BEM methodology.',
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Pattern-Verteilung zurück
|
||||
*/
|
||||
public function getPatternDistribution(): array
|
||||
{
|
||||
$total = $this->totalComponents;
|
||||
|
||||
if ($total === 0) {
|
||||
return [
|
||||
'bem' => 0,
|
||||
'utility' => 0,
|
||||
'traditional' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'bem' => round((count($this->bemComponents) / $total) * 100, 1),
|
||||
'utility' => round((count($this->utilityComponents) / $total) * 100, 1),
|
||||
'traditional' => round((count($this->traditionalComponents) / $total) * 100, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_components' => $this->totalComponents,
|
||||
'bem_components' => array_map(fn (ComponentPattern $p) => $p->toArray(), $this->bemComponents),
|
||||
'utility_components' => array_map(fn (ComponentPattern $p) => $p->toArray(), $this->utilityComponents),
|
||||
'traditional_components' => array_map(fn (ComponentPattern $p) => $p->toArray(), $this->traditionalComponents),
|
||||
'pattern_statistics' => $this->patternStatistics,
|
||||
'recommendations' => $this->recommendations,
|
||||
'dominant_pattern' => $this->getDominantPattern(),
|
||||
'pattern_diversity' => $this->getPatternDiversity(),
|
||||
'consistency_score' => $this->getConsistencyScore(),
|
||||
'actionable_recommendations' => $this->getActionableRecommendations(),
|
||||
];
|
||||
}
|
||||
}
|
||||
397
src/Framework/Design/Analyzer/ComponentDetector.php
Normal file
397
src/Framework/Design/Analyzer/ComponentDetector.php
Normal file
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\Parser\ClassNameParser;
|
||||
use App\Framework\Design\ValueObjects\ComponentPatternType;
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
|
||||
/**
|
||||
* Erkennt Component-Patterns in CSS
|
||||
*/
|
||||
final readonly class ComponentDetector
|
||||
{
|
||||
public function __construct(
|
||||
private ClassNameParser $classNameParser = new ClassNameParser()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt alle Components im CSS
|
||||
*/
|
||||
public function detectComponents(CssParseResult $parseResult): ComponentDetectionResult
|
||||
{
|
||||
$classNames = array_values($parseResult->classNames);
|
||||
|
||||
if (empty($classNames)) {
|
||||
return new ComponentDetectionResult(
|
||||
totalComponents: 0,
|
||||
bemComponents: [],
|
||||
utilityComponents: [],
|
||||
traditionalComponents: [],
|
||||
patternStatistics: [],
|
||||
recommendations: ['No CSS classes found to analyze for component patterns.']
|
||||
);
|
||||
}
|
||||
|
||||
// Pattern-Erkennung
|
||||
$patterns = $this->classNameParser->detectPatterns($classNames);
|
||||
|
||||
// Gruppierung nach Pattern-Typ
|
||||
$bemComponents = [];
|
||||
$utilityComponents = [];
|
||||
$traditionalComponents = [];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
switch ($pattern->type) {
|
||||
case ComponentPatternType::BEM:
|
||||
$bemComponents[] = $pattern;
|
||||
|
||||
break;
|
||||
case ComponentPatternType::UTILITY:
|
||||
$utilityComponents[] = $pattern;
|
||||
|
||||
break;
|
||||
case ComponentPatternType::COMPONENT:
|
||||
$traditionalComponents[] = $pattern;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Statistiken erstellen
|
||||
$statistics = $this->generatePatternStatistics($patterns, $classNames);
|
||||
|
||||
// Empfehlungen generieren
|
||||
$recommendations = $this->generateRecommendations($bemComponents, $utilityComponents, $traditionalComponents, $classNames);
|
||||
|
||||
return new ComponentDetectionResult(
|
||||
totalComponents: count($patterns),
|
||||
bemComponents: $bemComponents,
|
||||
utilityComponents: $utilityComponents,
|
||||
traditionalComponents: $traditionalComponents,
|
||||
patternStatistics: $statistics,
|
||||
recommendations: $recommendations
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert spezifisch BEM-Patterns
|
||||
*/
|
||||
public function analyzeBemPatterns(CssParseResult $parseResult): array
|
||||
{
|
||||
$bemClasses = $parseResult->getBemClasses();
|
||||
$analysis = [];
|
||||
|
||||
foreach ($bemClasses as $className) {
|
||||
$block = $className->getBemBlock();
|
||||
|
||||
if (! isset($analysis[$block])) {
|
||||
$analysis[$block] = [
|
||||
'block' => $block,
|
||||
'elements' => [],
|
||||
'modifiers' => [],
|
||||
'total_classes' => 0,
|
||||
'completeness' => 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
$analysis[$block]['total_classes']++;
|
||||
|
||||
if ($className->isBemElement()) {
|
||||
$element = $className->getBemElement();
|
||||
if ($element && ! in_array($element, $analysis[$block]['elements'])) {
|
||||
$analysis[$block]['elements'][] = $element;
|
||||
}
|
||||
}
|
||||
|
||||
if ($className->isBemModifier()) {
|
||||
$modifier = $className->getBemModifier();
|
||||
if ($modifier && ! in_array($modifier, $analysis[$block]['modifiers'])) {
|
||||
$analysis[$block]['modifiers'][] = $modifier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completeness-Bewertung
|
||||
foreach ($analysis as $block => &$data) {
|
||||
$hasElements = ! empty($data['elements']);
|
||||
$hasModifiers = ! empty($data['modifiers']);
|
||||
|
||||
if (! $hasElements && ! $hasModifiers) {
|
||||
$data['completeness'] = 'block_only';
|
||||
} elseif ($hasElements && ! $hasModifiers) {
|
||||
$data['completeness'] = 'block_with_elements';
|
||||
} elseif (! $hasElements && $hasModifiers) {
|
||||
$data['completeness'] = 'block_with_modifiers';
|
||||
} else {
|
||||
$data['completeness'] = 'full_bem';
|
||||
}
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Utility-Class-Patterns
|
||||
*/
|
||||
public function analyzeUtilityPatterns(CssParseResult $parseResult): array
|
||||
{
|
||||
$utilityClasses = $parseResult->getUtilityClasses();
|
||||
$analysis = [];
|
||||
|
||||
foreach ($utilityClasses as $className) {
|
||||
$category = $className->getUtilityCategory();
|
||||
|
||||
if (! $category) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($analysis[$category])) {
|
||||
$analysis[$category] = [
|
||||
'category' => $category,
|
||||
'classes' => [],
|
||||
'count' => 0,
|
||||
'coverage' => 'unknown',
|
||||
];
|
||||
}
|
||||
|
||||
$analysis[$category]['classes'][] = $className->name;
|
||||
$analysis[$category]['count']++;
|
||||
}
|
||||
|
||||
// Coverage-Bewertung für verschiedene Kategorien
|
||||
foreach ($analysis as $category => &$data) {
|
||||
$data['coverage'] = $this->assessUtilityCoverage($category, $data['classes']);
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet potenzielle Component-Candidates
|
||||
*/
|
||||
public function findComponentCandidates(CssParseResult $parseResult): array
|
||||
{
|
||||
$candidates = [];
|
||||
$selectorFrequency = [];
|
||||
|
||||
// Analysiere Selektor-Häufigkeiten
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
if ($selector->getType()->value === 'class') {
|
||||
$classes = $selector->extractClasses();
|
||||
|
||||
foreach ($classes as $class) {
|
||||
if (! isset($selectorFrequency[$class])) {
|
||||
$selectorFrequency[$class] = 0;
|
||||
}
|
||||
$selectorFrequency[$class]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finde Klassen mit hoher Verwendung (potenzielle Components)
|
||||
foreach ($selectorFrequency as $className => $frequency) {
|
||||
if ($frequency >= 3) { // Threshold für Component-Candidate
|
||||
$candidates[] = [
|
||||
'class' => $className,
|
||||
'frequency' => $frequency,
|
||||
'potential' => $this->assessComponentPotential($className, $frequency),
|
||||
'suggestions' => $this->getComponentSuggestions($className, $frequency),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sortiere nach Häufigkeit
|
||||
usort($candidates, fn ($a, $b) => $b['frequency'] <=> $a['frequency']);
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Pattern-Statistiken
|
||||
*/
|
||||
private function generatePatternStatistics(array $patterns, array $classNames): array
|
||||
{
|
||||
$totalClasses = count($classNames);
|
||||
$statistics = [
|
||||
'total_patterns' => count($patterns),
|
||||
'total_classes' => $totalClasses,
|
||||
'pattern_distribution' => [],
|
||||
'complexity_analysis' => [],
|
||||
];
|
||||
|
||||
$patternTypes = [];
|
||||
foreach ($patterns as $pattern) {
|
||||
$type = $pattern->type->value;
|
||||
|
||||
if (! isset($patternTypes[$type])) {
|
||||
$patternTypes[$type] = 0;
|
||||
}
|
||||
$patternTypes[$type]++;
|
||||
}
|
||||
|
||||
foreach ($patternTypes as $type => $count) {
|
||||
$percentage = $totalClasses > 0 ? round(($count / $totalClasses) * 100, 1) : 0;
|
||||
$statistics['pattern_distribution'][$type] = [
|
||||
'count' => $count,
|
||||
'percentage' => $percentage,
|
||||
];
|
||||
}
|
||||
|
||||
// Complexity Analysis
|
||||
foreach ($patterns as $pattern) {
|
||||
$complexity = $pattern->getComplexity();
|
||||
|
||||
if (! isset($statistics['complexity_analysis'][$complexity])) {
|
||||
$statistics['complexity_analysis'][$complexity] = 0;
|
||||
}
|
||||
$statistics['complexity_analysis'][$complexity]++;
|
||||
}
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen
|
||||
*/
|
||||
private function generateRecommendations(array $bemComponents, array $utilityComponents, array $traditionalComponents, array $classNames): array
|
||||
{
|
||||
$recommendations = [];
|
||||
$totalClasses = count($classNames);
|
||||
|
||||
// BEM-Empfehlungen
|
||||
if (empty($bemComponents) && $totalClasses > 10) {
|
||||
$recommendations[] = 'Consider adopting BEM methodology for better component organization and naming consistency.';
|
||||
} elseif (! empty($bemComponents)) {
|
||||
$blocksWithoutElements = 0;
|
||||
foreach ($bemComponents as $component) {
|
||||
if (empty($component->metadata['elements'])) {
|
||||
$blocksWithoutElements++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($blocksWithoutElements > count($bemComponents) / 2) {
|
||||
$recommendations[] = 'Many BEM blocks lack elements. Consider breaking down components into smaller, reusable parts.';
|
||||
}
|
||||
}
|
||||
|
||||
// Utility-Empfehlungen
|
||||
if (! empty($utilityComponents)) {
|
||||
$utilityCount = array_sum(array_map(fn ($comp) => $comp->metadata['class_count'], $utilityComponents));
|
||||
$utilityPercentage = round(($utilityCount / $totalClasses) * 100, 1);
|
||||
|
||||
if ($utilityPercentage > 70) {
|
||||
$recommendations[] = 'High percentage of utility classes detected. Consider consolidating repetitive patterns into components.';
|
||||
} elseif ($utilityPercentage < 10 && $totalClasses > 20) {
|
||||
$recommendations[] = 'Low usage of utility classes. Consider adding utility classes for common spacing, colors, and typography.';
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed Pattern Empfehlungen
|
||||
$hasMultiplePatterns = (! empty($bemComponents) ? 1 : 0) +
|
||||
(! empty($utilityComponents) ? 1 : 0) +
|
||||
(! empty($traditionalComponents) ? 1 : 0);
|
||||
|
||||
if ($hasMultiplePatterns >= 3) {
|
||||
$recommendations[] = 'Multiple CSS methodologies detected. Consider standardizing on one primary approach for consistency.';
|
||||
}
|
||||
|
||||
// Spezifische Pattern-Empfehlungen
|
||||
if (! empty($traditionalComponents)) {
|
||||
$componentTypes = array_unique(array_map(fn ($comp) => $comp->metadata['component_type'], $traditionalComponents));
|
||||
|
||||
if (in_array('button', $componentTypes) && in_array('form', $componentTypes)) {
|
||||
$recommendations[] = 'Consider creating a design system with consistent button and form component patterns.';
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'Component patterns look well organized. Consider documenting the design system for team consistency.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet Utility-Coverage
|
||||
*/
|
||||
private function assessUtilityCoverage(string $category, array $classes): string
|
||||
{
|
||||
$expectedCoverage = [
|
||||
'spacing' => ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
'color' => ['primary', 'secondary', 'success', 'warning', 'error'],
|
||||
'typography' => ['xs', 'sm', 'base', 'lg', 'xl'],
|
||||
'sizing' => ['sm', 'md', 'lg', 'full'],
|
||||
];
|
||||
|
||||
if (! isset($expectedCoverage[$category])) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
$expected = $expectedCoverage[$category];
|
||||
$hasExpected = 0;
|
||||
|
||||
foreach ($expected as $expectedClass) {
|
||||
foreach ($classes as $actualClass) {
|
||||
if (str_contains($actualClass, $expectedClass)) {
|
||||
$hasExpected++;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$percentage = count($expected) > 0 ? ($hasExpected / count($expected)) : 0;
|
||||
|
||||
if ($percentage >= 0.8) {
|
||||
return 'excellent';
|
||||
} elseif ($percentage >= 0.6) {
|
||||
return 'good';
|
||||
} elseif ($percentage >= 0.4) {
|
||||
return 'fair';
|
||||
} else {
|
||||
return 'poor';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet Component-Potenzial
|
||||
*/
|
||||
private function assessComponentPotential(string $className, int $frequency): string
|
||||
{
|
||||
if ($frequency >= 10) {
|
||||
return 'high';
|
||||
} elseif ($frequency >= 6) {
|
||||
return 'medium';
|
||||
} else {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Component-Verbesserungsvorschläge
|
||||
*/
|
||||
private function getComponentSuggestions(string $className, int $frequency): array
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
if ($frequency >= 8) {
|
||||
$suggestions[] = 'Consider extracting into a reusable component or design token.';
|
||||
}
|
||||
|
||||
if (str_contains($className, 'btn') || str_contains($className, 'button')) {
|
||||
$suggestions[] = 'Consider creating button variants (primary, secondary, outline).';
|
||||
}
|
||||
|
||||
if (str_contains($className, 'card')) {
|
||||
$suggestions[] = 'Consider standardizing card padding, borders, and shadows.';
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
}
|
||||
179
src/Framework/Design/Analyzer/ConventionCheckResult.php
Normal file
179
src/Framework/Design/Analyzer/ConventionCheckResult.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
/**
|
||||
* Ergebnis der Convention-Prüfung
|
||||
*/
|
||||
final readonly class ConventionCheckResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $overallScore,
|
||||
public array $categoryScores,
|
||||
public array $violations,
|
||||
public array $recommendations,
|
||||
public string $conformanceLevel
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Violations nach Schweregrad
|
||||
*/
|
||||
public function getViolationsBySeverity(): array
|
||||
{
|
||||
$grouped = [
|
||||
'high' => [],
|
||||
'medium' => [],
|
||||
'low' => [],
|
||||
];
|
||||
|
||||
foreach ($this->violations as $violation) {
|
||||
$severity = $violation['severity'] ?? 'low';
|
||||
$grouped[$severity][] = $violation;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die schlimmsten Problembereiche zurück
|
||||
*/
|
||||
public function getWorstAreas(): array
|
||||
{
|
||||
$sortedScores = $this->categoryScores;
|
||||
asort($sortedScores);
|
||||
|
||||
return array_slice($sortedScores, 0, 3, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die besten Bereiche zurück
|
||||
*/
|
||||
public function getBestAreas(): array
|
||||
{
|
||||
$sortedScores = $this->categoryScores;
|
||||
arsort($sortedScores);
|
||||
|
||||
return array_slice($sortedScores, 0, 3, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt priorisierte Action Items zurück
|
||||
*/
|
||||
public function getPrioritizedActions(): array
|
||||
{
|
||||
$actions = [];
|
||||
$violationsBySeverity = $this->getViolationsBySeverity();
|
||||
|
||||
// High-Priority Violations zuerst
|
||||
foreach ($violationsBySeverity['high'] as $violation) {
|
||||
$actions[] = [
|
||||
'priority' => 'high',
|
||||
'category' => $violation['type'],
|
||||
'action' => $violation['suggestion'],
|
||||
'impact' => 'accessibility_maintainability',
|
||||
];
|
||||
}
|
||||
|
||||
// Medium-Priority basierend auf schlechtesten Scores
|
||||
$worstAreas = $this->getWorstAreas();
|
||||
foreach ($worstAreas as $area => $score) {
|
||||
if ($score < 70) {
|
||||
$actions[] = [
|
||||
'priority' => 'medium',
|
||||
'category' => $area,
|
||||
'action' => $this->getAreaImprovementAction($area),
|
||||
'impact' => 'code_quality',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Verbesserungs-Potenzial
|
||||
*/
|
||||
public function getImprovementPotential(): array
|
||||
{
|
||||
$potential = [];
|
||||
|
||||
foreach ($this->categoryScores as $category => $score) {
|
||||
$maxImprovement = 100 - $score;
|
||||
$effort = $this->getEstimatedEffort($category, $score);
|
||||
|
||||
$potential[$category] = [
|
||||
'current_score' => $score,
|
||||
'max_improvement' => $maxImprovement,
|
||||
'estimated_effort' => $effort,
|
||||
'roi_score' => $maxImprovement / max(1, $effort), // Return on Investment
|
||||
];
|
||||
}
|
||||
|
||||
// Sortiere nach ROI
|
||||
uasort($potential, fn ($a, $b) => $b['roi_score'] <=> $a['roi_score']);
|
||||
|
||||
return $potential;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'overall_score' => $this->overallScore,
|
||||
'conformance_level' => $this->conformanceLevel,
|
||||
'category_scores' => $this->categoryScores,
|
||||
'violations' => $this->violations,
|
||||
'violations_by_severity' => $this->getViolationsBySeverity(),
|
||||
'recommendations' => $this->recommendations,
|
||||
'worst_areas' => $this->getWorstAreas(),
|
||||
'best_areas' => $this->getBestAreas(),
|
||||
'prioritized_actions' => $this->getPrioritizedActions(),
|
||||
'improvement_potential' => $this->getImprovementPotential(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt bereichsspezifische Verbesserungs-Actions
|
||||
*/
|
||||
private function getAreaImprovementAction(string $area): string
|
||||
{
|
||||
return match($area) {
|
||||
'naming' => 'Standardize naming conventions across all CSS classes',
|
||||
'specificity' => 'Refactor high-specificity selectors and reduce !important usage',
|
||||
'organization' => 'Reorganize CSS properties within rules for better readability',
|
||||
'custom_properties' => 'Review and improve custom property naming and organization',
|
||||
'accessibility' => 'Add missing focus styles and improve keyboard navigation support',
|
||||
default => "Improve {$area} conventions and standards"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schätzt Aufwand für Verbesserungen
|
||||
*/
|
||||
private function getEstimatedEffort(string $category, int|float $score): int
|
||||
{
|
||||
$baseEffort = match($category) {
|
||||
'naming' => 3, // Moderate effort - mostly renaming
|
||||
'specificity' => 4, // Higher effort - requires structural changes
|
||||
'organization' => 2, // Lower effort - mostly reordering
|
||||
'custom_properties' => 2, // Lower effort - renaming and reorganization
|
||||
'accessibility' => 3, // Moderate effort - adding new styles
|
||||
default => 3
|
||||
};
|
||||
|
||||
// Adjust based on how bad the score is
|
||||
$severityMultiplier = match(true) {
|
||||
$score >= 80 => 0.5,
|
||||
$score >= 60 => 1.0,
|
||||
$score >= 40 => 1.5,
|
||||
default => 2.0
|
||||
};
|
||||
|
||||
return (int) ceil($baseEffort * $severityMultiplier);
|
||||
}
|
||||
}
|
||||
442
src/Framework/Design/Analyzer/ConventionChecker.php
Normal file
442
src/Framework/Design/Analyzer/ConventionChecker.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
|
||||
/**
|
||||
* Prüft CSS-Naming-Conventions und Code-Standards
|
||||
*/
|
||||
final readonly class ConventionChecker
|
||||
{
|
||||
/**
|
||||
* Prüft alle Conventions im CSS
|
||||
*/
|
||||
public function checkConventions(CssParseResult $parseResult): ConventionCheckResult
|
||||
{
|
||||
$violations = [];
|
||||
$scores = [];
|
||||
|
||||
// Naming Convention Check
|
||||
$namingResult = $this->checkNamingConventions($parseResult);
|
||||
$scores['naming'] = $namingResult['score'];
|
||||
if (! empty($namingResult['violations'])) {
|
||||
$violations = array_merge($violations, $namingResult['violations']);
|
||||
}
|
||||
|
||||
// Selector Specificity Check
|
||||
$specificityResult = $this->checkSelectorSpecificity($parseResult);
|
||||
$scores['specificity'] = $specificityResult['score'];
|
||||
if (! empty($specificityResult['violations'])) {
|
||||
$violations = array_merge($violations, $specificityResult['violations']);
|
||||
}
|
||||
|
||||
// Property Organization Check
|
||||
$organizationResult = $this->checkPropertyOrganization($parseResult);
|
||||
$scores['organization'] = $organizationResult['score'];
|
||||
if (! empty($organizationResult['violations'])) {
|
||||
$violations = array_merge($violations, $organizationResult['violations']);
|
||||
}
|
||||
|
||||
// Custom Property Convention Check
|
||||
$customPropResult = $this->checkCustomPropertyConventions($parseResult);
|
||||
$scores['custom_properties'] = $customPropResult['score'];
|
||||
if (! empty($customPropResult['violations'])) {
|
||||
$violations = array_merge($violations, $customPropResult['violations']);
|
||||
}
|
||||
|
||||
// Accessibility Check
|
||||
$accessibilityResult = $this->checkAccessibilityPatterns($parseResult);
|
||||
$scores['accessibility'] = $accessibilityResult['score'];
|
||||
if (! empty($accessibilityResult['violations'])) {
|
||||
$violations = array_merge($violations, $accessibilityResult['violations']);
|
||||
}
|
||||
|
||||
// Berechne Gesamt-Score
|
||||
$overallScore = array_sum($scores) / count($scores);
|
||||
|
||||
// Generiere Empfehlungen
|
||||
$recommendations = $this->generateRecommendations($scores, $violations);
|
||||
|
||||
return new ConventionCheckResult(
|
||||
overallScore: (int) round($overallScore),
|
||||
categoryScores: $scores,
|
||||
violations: $violations,
|
||||
recommendations: $recommendations,
|
||||
conformanceLevel: $this->getConformanceLevel($overallScore)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Naming Conventions
|
||||
*/
|
||||
private function checkNamingConventions(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$analysis = $parseResult->getNamingConventionAnalysis();
|
||||
|
||||
$totalClasses = $analysis['total_classes'];
|
||||
$dominantConvention = $analysis['dominant_convention'];
|
||||
|
||||
if ($totalClasses === 0) {
|
||||
return ['score' => 100, 'violations' => []];
|
||||
}
|
||||
|
||||
// Prüfe Konsistenz
|
||||
$dominantCount = $analysis[$dominantConvention] ?? 0;
|
||||
$consistencyPercentage = ($dominantCount / $totalClasses) * 100;
|
||||
|
||||
if ($consistencyPercentage < 70) {
|
||||
$violations[] = [
|
||||
'type' => 'naming_inconsistency',
|
||||
'severity' => 'medium',
|
||||
'message' => "Mixed naming conventions detected. {$dominantConvention} is dominant with {$consistencyPercentage}% usage.",
|
||||
'suggestion' => 'Standardize on one naming convention across your CSS.',
|
||||
];
|
||||
}
|
||||
|
||||
// Prüfe spezifische Convention-Violations
|
||||
foreach ($analysis['violations'] as $violation) {
|
||||
$violations[] = [
|
||||
'type' => 'naming_violation',
|
||||
'severity' => 'low',
|
||||
'message' => "Class '{$violation}' doesn't follow standard naming conventions.",
|
||||
'suggestion' => 'Use consistent casing and separators (kebab-case, camelCase, or BEM).',
|
||||
];
|
||||
}
|
||||
|
||||
// BEM-spezifische Checks
|
||||
if ($dominantConvention === 'bem') {
|
||||
$bemViolations = $this->checkBemConventions($parseResult);
|
||||
$violations = array_merge($violations, $bemViolations);
|
||||
}
|
||||
|
||||
$score = max(0, 100 - (count($violations) * 10));
|
||||
|
||||
return ['score' => $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Selector-Spezifität
|
||||
*/
|
||||
private function checkSelectorSpecificity(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$highSpecificityCount = 0;
|
||||
$totalSelectors = 0;
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
$totalSelectors++;
|
||||
$specificity = $selector->calculateSpecificity();
|
||||
|
||||
// Sehr hohe Spezifität (>100)
|
||||
if ($specificity > 100) {
|
||||
$highSpecificityCount++;
|
||||
$violations[] = [
|
||||
'type' => 'high_specificity',
|
||||
'severity' => 'medium',
|
||||
'message' => "Selector '{$selector->value}' has high specificity ({$specificity}).",
|
||||
'suggestion' => 'Reduce specificity by avoiding IDs and deeply nested selectors.',
|
||||
];
|
||||
}
|
||||
|
||||
// Sehr niedrige Spezifität mit !important
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->isImportant() && $specificity < 10) {
|
||||
$violations[] = [
|
||||
'type' => 'important_overuse',
|
||||
'severity' => 'high',
|
||||
'message' => "Property '{$property->name}' uses !important with low specificity selector.",
|
||||
'suggestion' => 'Increase selector specificity instead of using !important.',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$highSpecificityPercentage = $totalSelectors > 0 ? ($highSpecificityCount / $totalSelectors) * 100 : 0;
|
||||
$score = max(0, 100 - ($highSpecificityPercentage * 2));
|
||||
|
||||
return ['score' => (int) $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Property-Organisation
|
||||
*/
|
||||
private function checkPropertyOrganization(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$propertyOrderViolations = 0;
|
||||
$totalRules = count($parseResult->rules);
|
||||
|
||||
// Erwartete Property-Reihenfolge (grob)
|
||||
$expectedOrder = [
|
||||
'position', 'top', 'right', 'bottom', 'left', 'z-index',
|
||||
'display', 'flex', 'grid', 'float', 'clear',
|
||||
'width', 'height', 'margin', 'padding',
|
||||
'border', 'background', 'color',
|
||||
'font', 'text', 'line-height',
|
||||
'transform', 'transition', 'animation',
|
||||
];
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
if (count($rule->properties) < 3) {
|
||||
continue; // Skip kleine Rules
|
||||
}
|
||||
|
||||
$propertyOrder = array_map(fn ($prop) => $prop->name, $rule->properties);
|
||||
|
||||
// Vereinfachte Reihenfolgen-Prüfung
|
||||
$lastOrderIndex = -1;
|
||||
$orderViolation = false;
|
||||
|
||||
foreach ($propertyOrder as $property) {
|
||||
$orderIndex = $this->findPropertyOrderIndex($property, $expectedOrder);
|
||||
|
||||
if ($orderIndex !== -1 && $orderIndex < $lastOrderIndex) {
|
||||
$orderViolation = true;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if ($orderIndex !== -1) {
|
||||
$lastOrderIndex = $orderIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if ($orderViolation) {
|
||||
$propertyOrderViolations++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($propertyOrderViolations > 0 && $totalRules > 0) {
|
||||
$violationPercentage = ($propertyOrderViolations / $totalRules) * 100;
|
||||
|
||||
if ($violationPercentage > 30) {
|
||||
$violations[] = [
|
||||
'type' => 'property_order',
|
||||
'severity' => 'low',
|
||||
'message' => "{$violationPercentage}% of rules have suboptimal property ordering.",
|
||||
'suggestion' => 'Consider organizing properties by type: positioning, box model, visual, typography.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$score = max(0, 100 - ($propertyOrderViolations * 5));
|
||||
|
||||
return ['score' => $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Custom Property Conventions
|
||||
*/
|
||||
private function checkCustomPropertyConventions(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$customProperties = $parseResult->customProperties;
|
||||
|
||||
if (empty($customProperties)) {
|
||||
return ['score' => 80, 'violations' => []]; // Neutral score when no custom properties
|
||||
}
|
||||
|
||||
foreach ($customProperties as $token) {
|
||||
$name = $token->name;
|
||||
|
||||
// Prüfe Naming Convention
|
||||
if (! preg_match('/^[a-z][a-z0-9-]*$/', $name)) {
|
||||
$violations[] = [
|
||||
'type' => 'custom_property_naming',
|
||||
'severity' => 'medium',
|
||||
'message' => "Custom property '--{$name}' doesn't follow kebab-case convention.",
|
||||
'suggestion' => 'Use lowercase letters and hyphens for custom property names.',
|
||||
];
|
||||
}
|
||||
|
||||
// Prüfe semantische Namen
|
||||
if (preg_match('/^(red|blue|green|yellow)-\d+$/', $name)) {
|
||||
$violations[] = [
|
||||
'type' => 'semantic_naming',
|
||||
'severity' => 'low',
|
||||
'message' => "Custom property '--{$name}' uses literal color naming.",
|
||||
'suggestion' => 'Consider semantic names like --color-primary, --color-error instead.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$score = max(0, 100 - (count($violations) * 15));
|
||||
|
||||
return ['score' => $score, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft Accessibility Patterns
|
||||
*/
|
||||
private function checkAccessibilityPatterns(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$accessibilityScore = 100;
|
||||
|
||||
// Prüfe auf focus-Styles
|
||||
$hasFocusStyles = false;
|
||||
$hasColorOnlyIndicators = false;
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->selectors as $selector) {
|
||||
if (str_contains($selector->value, ':focus')) {
|
||||
$hasFocusStyles = true;
|
||||
}
|
||||
|
||||
// Prüfe auf outline: none ohne Alternative
|
||||
if (str_contains($selector->value, ':focus')) {
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->name === 'outline' && $property->value === 'none') {
|
||||
$hasAlternativeFocus = false;
|
||||
|
||||
foreach ($rule->properties as $altProp) {
|
||||
if (in_array($altProp->name, ['border', 'box-shadow', 'background'])) {
|
||||
$hasAlternativeFocus = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $hasAlternativeFocus) {
|
||||
$violations[] = [
|
||||
'type' => 'focus_accessibility',
|
||||
'severity' => 'high',
|
||||
'message' => 'Focus outline removed without alternative focus indicator.',
|
||||
'suggestion' => 'Provide alternative focus indicators when removing default outline.',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $hasFocusStyles && count($parseResult->rules) > 5) {
|
||||
$violations[] = [
|
||||
'type' => 'missing_focus_styles',
|
||||
'severity' => 'medium',
|
||||
'message' => 'No focus styles detected in CSS.',
|
||||
'suggestion' => 'Add focus styles for interactive elements to improve keyboard accessibility.',
|
||||
];
|
||||
$accessibilityScore -= 20;
|
||||
}
|
||||
|
||||
return ['score' => $accessibilityScore, 'violations' => $violations];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft BEM-spezifische Conventions
|
||||
*/
|
||||
private function checkBemConventions(CssParseResult $parseResult): array
|
||||
{
|
||||
$violations = [];
|
||||
$bemClasses = $parseResult->getBemClasses();
|
||||
|
||||
foreach ($bemClasses as $className) {
|
||||
$name = $className->name;
|
||||
|
||||
// Prüfe BEM Syntax-Korrektheit
|
||||
if ($className->isBemElement() && substr_count($name, '__') > 1) {
|
||||
$violations[] = [
|
||||
'type' => 'bem_syntax',
|
||||
'severity' => 'medium',
|
||||
'message' => "Class '{$name}' has multiple element separators (__). BEM allows only one level of elements.",
|
||||
'suggestion' => 'Use only one level of elements in BEM: block__element--modifier.',
|
||||
];
|
||||
}
|
||||
|
||||
if ($className->isBemModifier() && substr_count($name, '--') > 1) {
|
||||
$violations[] = [
|
||||
'type' => 'bem_syntax',
|
||||
'severity' => 'medium',
|
||||
'message' => "Class '{$name}' has multiple modifier separators (--). Use single modifier per class.",
|
||||
'suggestion' => 'Use single modifiers: block__element--modifier.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet Property-Order-Index
|
||||
*/
|
||||
private function findPropertyOrderIndex(string $property, array $order): int
|
||||
{
|
||||
foreach ($order as $index => $orderProperty) {
|
||||
if (str_starts_with($property, $orderProperty)) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen
|
||||
*/
|
||||
private function generateRecommendations(array $scores, array $violations): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Basiere Empfehlungen auf Scores
|
||||
foreach ($scores as $category => $score) {
|
||||
if ($score < 70) {
|
||||
$recommendations[] = $this->getCategoryRecommendation($category, $score);
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritäts-basierte Empfehlungen
|
||||
$highSeverityCount = count(array_filter($violations, fn ($v) => $v['severity'] === 'high'));
|
||||
$mediumSeverityCount = count(array_filter($violations, fn ($v) => $v['severity'] === 'medium'));
|
||||
|
||||
if ($highSeverityCount > 0) {
|
||||
$recommendations[] = "Address {$highSeverityCount} high-priority violations first for accessibility and maintainability.";
|
||||
}
|
||||
|
||||
if ($mediumSeverityCount > 3) {
|
||||
$recommendations[] = "Consider refactoring to address {$mediumSeverityCount} medium-priority issues for better code quality.";
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'CSS conventions are well maintained. Consider documenting your standards for team consistency.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt kategorie-spezifische Empfehlungen
|
||||
*/
|
||||
private function getCategoryRecommendation(string $category, int $score): string
|
||||
{
|
||||
return match($category) {
|
||||
'naming' => "Naming conventions need improvement (Score: {$score}/100). Standardize on one naming methodology.",
|
||||
'specificity' => "Selector specificity issues detected (Score: {$score}/100). Avoid overly specific selectors and !important.",
|
||||
'organization' => "Property organization could be better (Score: {$score}/100). Group related properties together.",
|
||||
'custom_properties' => "Custom property conventions need attention (Score: {$score}/100). Use consistent, semantic naming.",
|
||||
'accessibility' => "Accessibility patterns missing (Score: {$score}/100). Add focus styles and ensure color contrast.",
|
||||
default => "Category '{$category}' needs improvement (Score: {$score}/100)."
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt Conformance Level
|
||||
*/
|
||||
private function getConformanceLevel(float $score): string
|
||||
{
|
||||
return match(true) {
|
||||
$score >= 90 => 'excellent',
|
||||
$score >= 80 => 'good',
|
||||
$score >= 70 => 'fair',
|
||||
$score >= 60 => 'needs_improvement',
|
||||
default => 'poor'
|
||||
};
|
||||
}
|
||||
}
|
||||
387
src/Framework/Design/Analyzer/DesignSystemAnalysis.php
Normal file
387
src/Framework/Design/Analyzer/DesignSystemAnalysis.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Vollständige Design System Analyse - Haupt-Ergebnis-Klasse
|
||||
*/
|
||||
final readonly class DesignSystemAnalysis
|
||||
{
|
||||
/**
|
||||
* @param FilePath[] $sourceFiles
|
||||
* @param CssParseResult[] $parseResults
|
||||
*/
|
||||
public function __construct(
|
||||
public array $sourceFiles,
|
||||
public array $parseResults,
|
||||
public CssParseResult $combinedResult,
|
||||
public TokenAnalysisResult $tokenAnalysis,
|
||||
public ComponentDetectionResult $componentAnalysis,
|
||||
public ConventionCheckResult $conventionAnalysis,
|
||||
public ColorAnalysisResult $colorAnalysis,
|
||||
public array $overallStatistics
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Gesamt-Design-System-Score (0-100)
|
||||
*/
|
||||
public function getOverallDesignSystemScore(): int
|
||||
{
|
||||
$scores = [
|
||||
'tokens' => $this->getTokenScore(),
|
||||
'components' => $this->getComponentScore(),
|
||||
'conventions' => $this->conventionAnalysis->overallScore,
|
||||
'colors' => $this->getColorScore(),
|
||||
];
|
||||
|
||||
// Gewichtung der verschiedenen Bereiche
|
||||
$weights = [
|
||||
'tokens' => 0.25,
|
||||
'components' => 0.25,
|
||||
'conventions' => 0.30,
|
||||
'colors' => 0.20,
|
||||
];
|
||||
|
||||
$weightedScore = 0;
|
||||
foreach ($scores as $area => $score) {
|
||||
$weightedScore += $score * $weights[$area];
|
||||
}
|
||||
|
||||
return (int) round($weightedScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Reife-Stufe des Design Systems zurück
|
||||
*/
|
||||
public function getMaturityLevel(): string
|
||||
{
|
||||
$score = $this->getOverallDesignSystemScore();
|
||||
$tokenUsage = $this->tokenAnalysis->getTokenCoverage()['usage_percentage'];
|
||||
$hasComponents = $this->componentAnalysis->totalComponents > 0;
|
||||
$colorConsistency = $this->colorAnalysis->getConsistencyScore();
|
||||
|
||||
return match(true) {
|
||||
$score >= 90 && $tokenUsage >= 80 && $hasComponents && $colorConsistency >= 80 => 'mature',
|
||||
$score >= 75 && $tokenUsage >= 60 && $hasComponents => 'established',
|
||||
$score >= 60 && ($tokenUsage >= 40 || $hasComponents) => 'developing',
|
||||
$score >= 40 => 'emerging',
|
||||
default => 'basic'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt kritische Problembereiche zurück
|
||||
*/
|
||||
public function getCriticalIssues(): array
|
||||
{
|
||||
$issues = [];
|
||||
|
||||
// Token-Probleme
|
||||
if ($this->tokenAnalysis->totalTokens === 0) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'tokens',
|
||||
'issue' => 'No design tokens found',
|
||||
'impact' => 'Inconsistent styling and difficult maintenance',
|
||||
'recommendation' => 'Implement CSS custom properties for colors, spacing, and typography',
|
||||
];
|
||||
} elseif (count($this->tokenAnalysis->unusedTokens) > $this->tokenAnalysis->totalTokens * 0.5) {
|
||||
$issues[] = [
|
||||
'severity' => 'medium',
|
||||
'category' => 'tokens',
|
||||
'issue' => 'Many unused design tokens',
|
||||
'impact' => 'Bloated CSS and confusing token system',
|
||||
'recommendation' => 'Remove unused tokens and audit token usage',
|
||||
];
|
||||
}
|
||||
|
||||
// Component-Probleme
|
||||
if ($this->componentAnalysis->getConsistencyScore() < 50) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'components',
|
||||
'issue' => 'Inconsistent component patterns',
|
||||
'impact' => 'Poor maintainability and team confusion',
|
||||
'recommendation' => 'Standardize on one CSS methodology (BEM, Utility-first, etc.)',
|
||||
];
|
||||
}
|
||||
|
||||
// Convention-Probleme
|
||||
$highSeverityViolations = $this->conventionAnalysis->getViolationsBySeverity()['high'];
|
||||
if (! empty($highSeverityViolations)) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'conventions',
|
||||
'issue' => count($highSeverityViolations) . ' high-severity convention violations',
|
||||
'impact' => 'Accessibility and usability problems',
|
||||
'recommendation' => 'Address accessibility issues, especially focus styles and contrast',
|
||||
];
|
||||
}
|
||||
|
||||
// Farb-Probleme
|
||||
$worstContrastPairs = $this->colorAnalysis->getWorstContrastPairs();
|
||||
if (! empty($worstContrastPairs)) {
|
||||
$issues[] = [
|
||||
'severity' => 'high',
|
||||
'category' => 'colors',
|
||||
'issue' => count($worstContrastPairs) . ' color combinations fail WCAG contrast requirements',
|
||||
'impact' => 'Poor accessibility for users with visual impairments',
|
||||
'recommendation' => 'Improve color contrast ratios to meet WCAG AA standards',
|
||||
];
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Quick-Win-Verbesserungen zurück
|
||||
*/
|
||||
public function getQuickWins(): array
|
||||
{
|
||||
$quickWins = [];
|
||||
|
||||
// Duplikate entfernen
|
||||
if (! empty($this->colorAnalysis->duplicateColors)) {
|
||||
$totalDuplicates = array_sum(array_column($this->colorAnalysis->duplicateColors, 'potential_savings'));
|
||||
$quickWins[] = [
|
||||
'effort' => 'low',
|
||||
'impact' => 'medium',
|
||||
'action' => "Remove {$totalDuplicates} duplicate colors",
|
||||
'benefit' => 'Smaller CSS size and cleaner color palette',
|
||||
'estimated_time' => '1-2 hours',
|
||||
];
|
||||
}
|
||||
|
||||
// Unbenutzte Tokens
|
||||
if (count($this->tokenAnalysis->unusedTokens) > 0) {
|
||||
$unusedCount = count($this->tokenAnalysis->unusedTokens);
|
||||
$quickWins[] = [
|
||||
'effort' => 'low',
|
||||
'impact' => 'low',
|
||||
'action' => "Remove {$unusedCount} unused design tokens",
|
||||
'benefit' => 'Cleaner token system and reduced CSS complexity',
|
||||
'estimated_time' => '30 minutes',
|
||||
];
|
||||
}
|
||||
|
||||
// Naming-Konventionen
|
||||
if ($this->conventionAnalysis->categoryScores['naming'] < 70) {
|
||||
$quickWins[] = [
|
||||
'effort' => 'medium',
|
||||
'impact' => 'high',
|
||||
'action' => 'Standardize CSS class naming conventions',
|
||||
'benefit' => 'Better maintainability and team consistency',
|
||||
'estimated_time' => '2-4 hours',
|
||||
];
|
||||
}
|
||||
|
||||
// Fehlende Standard-Tokens
|
||||
if (! empty($this->tokenAnalysis->missingTokens)) {
|
||||
$quickWins[] = [
|
||||
'effort' => 'medium',
|
||||
'impact' => 'high',
|
||||
'action' => 'Add missing standard design tokens',
|
||||
'benefit' => 'More complete design system and better consistency',
|
||||
'estimated_time' => '1-3 hours',
|
||||
];
|
||||
}
|
||||
|
||||
return $quickWins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Entwicklungsroadmap zurück
|
||||
*/
|
||||
public function getDevelopmentRoadmap(): array
|
||||
{
|
||||
$maturity = $this->getMaturityLevel();
|
||||
|
||||
$roadmap = match($maturity) {
|
||||
'basic' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Foundation Setup',
|
||||
'duration' => '1-2 weeks',
|
||||
'tasks' => [
|
||||
'Establish CSS custom properties for core colors',
|
||||
'Define basic spacing scale',
|
||||
'Standardize naming conventions',
|
||||
'Create utility classes for common patterns',
|
||||
],
|
||||
],
|
||||
'phase_2' => [
|
||||
'title' => 'Component Library',
|
||||
'duration' => '2-3 weeks',
|
||||
'tasks' => [
|
||||
'Build core component patterns',
|
||||
'Document component usage',
|
||||
'Implement accessibility standards',
|
||||
'Create component naming guidelines',
|
||||
],
|
||||
],
|
||||
],
|
||||
'emerging' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Standardization',
|
||||
'duration' => '1 week',
|
||||
'tasks' => [
|
||||
'Fix critical accessibility issues',
|
||||
'Consolidate duplicate colors',
|
||||
'Improve token usage consistency',
|
||||
'Document existing patterns',
|
||||
],
|
||||
],
|
||||
'phase_2' => [
|
||||
'title' => 'Enhancement',
|
||||
'duration' => '2 weeks',
|
||||
'tasks' => [
|
||||
'Expand design token system',
|
||||
'Improve component consistency',
|
||||
'Add semantic color tokens',
|
||||
'Create design guidelines',
|
||||
],
|
||||
],
|
||||
],
|
||||
'developing' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Optimization',
|
||||
'duration' => '3-5 days',
|
||||
'tasks' => [
|
||||
'Remove unused tokens and styles',
|
||||
'Improve color contrast compliance',
|
||||
'Optimize component patterns',
|
||||
'Enhance documentation',
|
||||
],
|
||||
],
|
||||
],
|
||||
'established' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Advanced Features',
|
||||
'duration' => '1 week',
|
||||
'tasks' => [
|
||||
'Implement advanced color formats (OKLCH)',
|
||||
'Create automated design token generation',
|
||||
'Add theme switching capabilities',
|
||||
'Implement design system automation',
|
||||
],
|
||||
],
|
||||
],
|
||||
'mature' => [
|
||||
'phase_1' => [
|
||||
'title' => 'Maintenance & Innovation',
|
||||
'duration' => 'Ongoing',
|
||||
'tasks' => [
|
||||
'Monitor design system metrics',
|
||||
'Implement advanced automation',
|
||||
'Research emerging CSS features',
|
||||
'Mentor other teams',
|
||||
],
|
||||
],
|
||||
]
|
||||
};
|
||||
|
||||
return [
|
||||
'current_maturity' => $maturity,
|
||||
'next_level' => $this->getNextMaturityLevel($maturity),
|
||||
'phases' => $roadmap,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert vollständigen Report
|
||||
*/
|
||||
public function exportReport(): array
|
||||
{
|
||||
return [
|
||||
'meta' => [
|
||||
'generated_at' => date('c'),
|
||||
'analyzed_files' => count($this->sourceFiles),
|
||||
'total_css_size' => $this->overallStatistics['total_content_size'],
|
||||
'analysis_version' => '1.0',
|
||||
],
|
||||
'summary' => [
|
||||
'overall_score' => $this->getOverallDesignSystemScore(),
|
||||
'maturity_level' => $this->getMaturityLevel(),
|
||||
'critical_issues_count' => count($this->getCriticalIssues()),
|
||||
'quick_wins_count' => count($this->getQuickWins()),
|
||||
],
|
||||
'detailed_analysis' => [
|
||||
'token_analysis' => $this->tokenAnalysis->toArray(),
|
||||
'component_analysis' => $this->componentAnalysis->toArray(),
|
||||
'convention_analysis' => $this->conventionAnalysis->toArray(),
|
||||
'color_analysis' => $this->colorAnalysis->toArray(),
|
||||
],
|
||||
'recommendations' => [
|
||||
'critical_issues' => $this->getCriticalIssues(),
|
||||
'quick_wins' => $this->getQuickWins(),
|
||||
'development_roadmap' => $this->getDevelopmentRoadmap(),
|
||||
],
|
||||
'statistics' => $this->overallStatistics,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Token-Score
|
||||
*/
|
||||
private function getTokenScore(): int
|
||||
{
|
||||
if ($this->tokenAnalysis->totalTokens === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$coverage = $this->tokenAnalysis->getTokenCoverage()['usage_percentage'];
|
||||
$missingTokenPenalty = count($this->tokenAnalysis->missingTokens) * 5;
|
||||
|
||||
return max(0, (int) ($coverage - $missingTokenPenalty));
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Component-Score
|
||||
*/
|
||||
private function getComponentScore(): int
|
||||
{
|
||||
if ($this->componentAnalysis->totalComponents === 0) {
|
||||
return 20; // Basis-Score für keine Components
|
||||
}
|
||||
|
||||
$consistency = $this->componentAnalysis->getConsistencyScore();
|
||||
$diversity = 100 - $this->componentAnalysis->getPatternDiversity(); // Weniger Diversity ist besser für Konsistenz
|
||||
|
||||
return (int) (($consistency * 0.7) + ($diversity * 0.3));
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Color-Score
|
||||
*/
|
||||
private function getColorScore(): int
|
||||
{
|
||||
if ($this->colorAnalysis->totalColors === 0) {
|
||||
return 30; // Basis-Score für keine Farben
|
||||
}
|
||||
|
||||
$consistency = $this->colorAnalysis->getConsistencyScore();
|
||||
$contrastCompliance = $this->colorAnalysis->getContrastComplianceScore();
|
||||
|
||||
return (int) (($consistency * 0.4) + ($contrastCompliance * 0.6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt nächste Reife-Stufe
|
||||
*/
|
||||
private function getNextMaturityLevel(string $current): string
|
||||
{
|
||||
return match($current) {
|
||||
'basic' => 'emerging',
|
||||
'emerging' => 'developing',
|
||||
'developing' => 'established',
|
||||
'established' => 'mature',
|
||||
'mature' => 'mature', // Already at top level
|
||||
default => 'emerging'
|
||||
};
|
||||
}
|
||||
}
|
||||
174
src/Framework/Design/Analyzer/DesignSystemAnalyzer.php
Normal file
174
src/Framework/Design/Analyzer/DesignSystemAnalyzer.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\Parser\CssParser;
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Filesystem\FilePath;
|
||||
|
||||
/**
|
||||
* Haupt-Analyzer für das Design System
|
||||
*/
|
||||
final readonly class DesignSystemAnalyzer
|
||||
{
|
||||
public function __construct(
|
||||
private CssParser $cssParser = new CssParser(),
|
||||
private TokenAnalyzer $tokenAnalyzer = new TokenAnalyzer(),
|
||||
private ComponentDetector $componentDetector = new ComponentDetector(),
|
||||
private ConventionChecker $conventionChecker = new ConventionChecker(),
|
||||
private ColorAnalyzer $colorAnalyzer = new ColorAnalyzer()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert ein komplettes Design System
|
||||
*/
|
||||
public function analyzeDesignSystem(string|array $cssFiles): DesignSystemAnalysis
|
||||
{
|
||||
$files = is_array($cssFiles) ? $cssFiles : [$cssFiles];
|
||||
$parseResults = [];
|
||||
|
||||
// Parse alle CSS-Dateien
|
||||
foreach ($files as $file) {
|
||||
$filePath = $file instanceof FilePath ? $file : FilePath::create($file);
|
||||
$parseResults[] = $this->cssParser->parseFile($filePath);
|
||||
}
|
||||
|
||||
// Kombiniere alle Ergebnisse
|
||||
$combinedResult = $this->combineParseResults($parseResults);
|
||||
|
||||
// Durchführung der verschiedenen Analysen
|
||||
$tokenAnalysis = $this->tokenAnalyzer->analyze($combinedResult);
|
||||
$componentAnalysis = $this->componentDetector->detectComponents($combinedResult);
|
||||
$conventionAnalysis = $this->conventionChecker->checkConventions($combinedResult);
|
||||
$colorAnalysis = $this->colorAnalyzer->analyzeColors($combinedResult);
|
||||
|
||||
return new DesignSystemAnalysis(
|
||||
sourceFiles: array_map(fn ($result) => $result->sourceFile, $parseResults),
|
||||
parseResults: $parseResults,
|
||||
combinedResult: $combinedResult,
|
||||
tokenAnalysis: $tokenAnalysis,
|
||||
componentAnalysis: $componentAnalysis,
|
||||
conventionAnalysis: $conventionAnalysis,
|
||||
colorAnalysis: $colorAnalysis,
|
||||
overallStatistics: $this->calculateOverallStatistics($parseResults)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert ein einzelnes CSS-Verzeichnis
|
||||
*/
|
||||
public function analyzeDirectory(string $directory, bool $recursive = true): DesignSystemAnalysis
|
||||
{
|
||||
$parseResults = $this->cssParser->parseDirectory($directory, $recursive);
|
||||
|
||||
if (empty($parseResults)) {
|
||||
throw new \InvalidArgumentException("No CSS files found in directory: $directory");
|
||||
}
|
||||
|
||||
$combinedResult = $this->combineParseResults($parseResults);
|
||||
|
||||
return new DesignSystemAnalysis(
|
||||
sourceFiles: array_map(fn ($result) => $result->sourceFile, $parseResults),
|
||||
parseResults: $parseResults,
|
||||
combinedResult: $combinedResult,
|
||||
tokenAnalysis: $this->tokenAnalyzer->analyze($combinedResult),
|
||||
componentAnalysis: $this->componentDetector->detectComponents($combinedResult),
|
||||
conventionAnalysis: $this->conventionChecker->checkConventions($combinedResult),
|
||||
colorAnalysis: $this->colorAnalyzer->analyzeColors($combinedResult),
|
||||
overallStatistics: $this->calculateOverallStatistics($parseResults)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnelle Analyse nur für Statistiken
|
||||
*/
|
||||
public function quickAnalyze(string|array $cssFiles): array
|
||||
{
|
||||
$files = is_array($cssFiles) ? $cssFiles : [$cssFiles];
|
||||
$statistics = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filePath = $file instanceof FilePath ? $file : FilePath::create($file);
|
||||
$result = $this->cssParser->parseFile($filePath);
|
||||
$statistics[] = $result->getStatistics();
|
||||
}
|
||||
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kombiniert mehrere Parse-Ergebnisse zu einem Gesamt-Ergebnis
|
||||
*/
|
||||
private function combineParseResults(array $parseResults): CssParseResult
|
||||
{
|
||||
if (empty($parseResults)) {
|
||||
throw new \InvalidArgumentException('No parse results to combine');
|
||||
}
|
||||
|
||||
if (count($parseResults) === 1) {
|
||||
return $parseResults[0];
|
||||
}
|
||||
|
||||
$allRules = [];
|
||||
$allCustomProperties = [];
|
||||
$allClassNames = [];
|
||||
$combinedContent = '';
|
||||
|
||||
foreach ($parseResults as $result) {
|
||||
$allRules = array_merge($allRules, $result->rules);
|
||||
$allCustomProperties = array_merge($allCustomProperties, $result->customProperties);
|
||||
$allClassNames = array_merge($allClassNames, $result->classNames);
|
||||
$combinedContent .= $result->rawContent . "\n";
|
||||
}
|
||||
|
||||
return new CssParseResult(
|
||||
sourceFile: null, // Combined result has no single source
|
||||
rules: $allRules,
|
||||
customProperties: $allCustomProperties,
|
||||
classNames: $allClassNames,
|
||||
rawContent: $combinedContent
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet Gesamt-Statistiken für alle Dateien
|
||||
*/
|
||||
private function calculateOverallStatistics(array $parseResults): array
|
||||
{
|
||||
$totalStats = [
|
||||
'total_files' => count($parseResults),
|
||||
'total_rules' => 0,
|
||||
'total_selectors' => 0,
|
||||
'total_properties' => 0,
|
||||
'total_design_tokens' => 0,
|
||||
'total_class_names' => 0,
|
||||
'total_content_size' => 0,
|
||||
'file_breakdown' => [],
|
||||
];
|
||||
|
||||
foreach ($parseResults as $result) {
|
||||
$stats = $result->getStatistics();
|
||||
|
||||
$totalStats['total_rules'] += $stats['total_rules'];
|
||||
$totalStats['total_selectors'] += $stats['total_selectors'];
|
||||
$totalStats['total_properties'] += $stats['total_properties'];
|
||||
$totalStats['total_design_tokens'] += $stats['design_tokens'];
|
||||
$totalStats['total_class_names'] += $stats['class_names'];
|
||||
$totalStats['total_content_size'] += $stats['content_size_bytes'];
|
||||
|
||||
$totalStats['file_breakdown'][] = $stats;
|
||||
}
|
||||
|
||||
// Durchschnittliche Werte berechnen
|
||||
$fileCount = max(1, count($parseResults));
|
||||
$totalStats['avg_rules_per_file'] = round($totalStats['total_rules'] / $fileCount, 2);
|
||||
$totalStats['avg_properties_per_file'] = round($totalStats['total_properties'] / $fileCount, 2);
|
||||
$totalStats['avg_tokens_per_file'] = round($totalStats['total_design_tokens'] / $fileCount, 2);
|
||||
$totalStats['avg_classes_per_file'] = round($totalStats['total_class_names'] / $fileCount, 2);
|
||||
|
||||
return $totalStats;
|
||||
}
|
||||
}
|
||||
93
src/Framework/Design/Analyzer/TokenAnalysisResult.php
Normal file
93
src/Framework/Design/Analyzer/TokenAnalysisResult.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
/**
|
||||
* Ergebnis der Token-Analyse
|
||||
*/
|
||||
final readonly class TokenAnalysisResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $totalTokens,
|
||||
public array $tokensByType,
|
||||
public array $tokenHierarchy,
|
||||
public array $unusedTokens,
|
||||
public array $missingTokens,
|
||||
public array $tokenUsage,
|
||||
public array $recommendations,
|
||||
public array $usedTokens = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die häufigsten Token-Typen zurück
|
||||
*/
|
||||
public function getMostUsedTokenTypes(): array
|
||||
{
|
||||
$usage = [];
|
||||
|
||||
foreach ($this->tokensByType as $type => $data) {
|
||||
$usage[$type] = $data['count'];
|
||||
}
|
||||
|
||||
arsort($usage);
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Token-Coverage-Statistiken zurück
|
||||
*/
|
||||
public function getTokenCoverage(): array
|
||||
{
|
||||
$usedTokens = count($this->tokenUsage);
|
||||
$totalTokens = $this->totalTokens;
|
||||
|
||||
return [
|
||||
'used_tokens' => $usedTokens,
|
||||
'unused_tokens' => count($this->unusedTokens),
|
||||
'total_tokens' => $totalTokens,
|
||||
'usage_percentage' => $totalTokens > 0 ? round(($usedTokens / $totalTokens) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt Empfehlungs-Prioritäten zurück
|
||||
*/
|
||||
public function getRecommendationPriorities(): array
|
||||
{
|
||||
$priorities = [];
|
||||
|
||||
foreach ($this->recommendations as $recommendation) {
|
||||
if (str_contains($recommendation, 'Remove')) {
|
||||
$priorities[] = ['level' => 'high', 'text' => $recommendation];
|
||||
} elseif (str_contains($recommendation, 'Consider adding')) {
|
||||
$priorities[] = ['level' => 'medium', 'text' => $recommendation];
|
||||
} else {
|
||||
$priorities[] = ['level' => 'low', 'text' => $recommendation];
|
||||
}
|
||||
}
|
||||
|
||||
return $priorities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert zu Array für Export
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'total_tokens' => $this->totalTokens,
|
||||
'tokens_by_type' => $this->tokensByType,
|
||||
'token_hierarchy' => $this->tokenHierarchy,
|
||||
'unused_tokens' => array_map(fn ($token) => $token->toArray(), $this->unusedTokens),
|
||||
'missing_tokens' => $this->missingTokens,
|
||||
'token_usage' => $this->tokenUsage,
|
||||
'recommendations' => $this->recommendations,
|
||||
'coverage' => $this->getTokenCoverage(),
|
||||
'priorities' => $this->getRecommendationPriorities(),
|
||||
];
|
||||
}
|
||||
}
|
||||
320
src/Framework/Design/Analyzer/TokenAnalyzer.php
Normal file
320
src/Framework/Design/Analyzer/TokenAnalyzer.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Design\Analyzer;
|
||||
|
||||
use App\Framework\Design\ValueObjects\CssParseResult;
|
||||
use App\Framework\Design\ValueObjects\DesignToken;
|
||||
use App\Framework\Design\ValueObjects\DesignTokenType;
|
||||
|
||||
/**
|
||||
* Analyzer für Design Tokens
|
||||
*/
|
||||
final readonly class TokenAnalyzer
|
||||
{
|
||||
/**
|
||||
* Analysiert Design Tokens im CSS
|
||||
*/
|
||||
public function analyze(CssParseResult $parseResult): TokenAnalysisResult
|
||||
{
|
||||
$tokens = $parseResult->customProperties;
|
||||
|
||||
if (empty($tokens)) {
|
||||
return new TokenAnalysisResult(
|
||||
totalTokens: 0,
|
||||
tokensByType: [],
|
||||
tokenHierarchy: [],
|
||||
unusedTokens: [],
|
||||
missingTokens: [],
|
||||
tokenUsage: [],
|
||||
recommendations: ['No design tokens found. Consider implementing CSS custom properties for better maintainability.']
|
||||
);
|
||||
}
|
||||
|
||||
// Analysiere Token-Typen
|
||||
$tokensByType = $this->groupTokensByType($tokens);
|
||||
|
||||
// Analysiere Token-Hierarchie
|
||||
$tokenHierarchy = $this->analyzeTokenHierarchy($tokens);
|
||||
|
||||
// Analysiere Token-Usage
|
||||
$tokenUsage = $this->analyzeTokenUsage($parseResult, $tokens);
|
||||
|
||||
// Finde unbenutzte Tokens
|
||||
$unusedTokens = $this->findUnusedTokens($tokens, $tokenUsage);
|
||||
|
||||
// Finde verwendete Tokens (alle Tokens minus unbenutzte)
|
||||
$usedTokens = array_filter($tokens, fn($token) => !in_array($token, $unusedTokens, true));
|
||||
|
||||
// Finde fehlende Standard-Tokens
|
||||
$missingTokens = $this->findMissingStandardTokens($tokensByType);
|
||||
|
||||
// Generiere Empfehlungen
|
||||
$recommendations = $this->generateRecommendations($tokensByType, $unusedTokens, $missingTokens);
|
||||
|
||||
return new TokenAnalysisResult(
|
||||
totalTokens: count($tokens),
|
||||
tokensByType: $tokensByType,
|
||||
tokenHierarchy: $tokenHierarchy,
|
||||
unusedTokens: $unusedTokens,
|
||||
missingTokens: $missingTokens,
|
||||
tokenUsage: $tokenUsage,
|
||||
recommendations: $recommendations,
|
||||
usedTokens: $usedTokens
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert Tokens nach Typ
|
||||
*/
|
||||
private function groupTokensByType(array $tokens): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$type = $token->type->value;
|
||||
|
||||
if (! isset($grouped[$type])) {
|
||||
$grouped[$type] = [
|
||||
'count' => 0,
|
||||
'tokens' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$grouped[$type]['count']++;
|
||||
$grouped[$type]['tokens'][] = $token;
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Token-Hierarchie (basierend auf Naming)
|
||||
*/
|
||||
private function analyzeTokenHierarchy(array $tokens): array
|
||||
{
|
||||
$hierarchy = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
$nameParts = explode('-', $token->name);
|
||||
$current = &$hierarchy;
|
||||
|
||||
foreach ($nameParts as $part) {
|
||||
if (! isset($current[$part])) {
|
||||
$current[$part] = [
|
||||
'tokens' => [],
|
||||
'children' => [],
|
||||
];
|
||||
}
|
||||
$current = &$current[$part]['children'];
|
||||
}
|
||||
|
||||
// Token zur letzten Ebene hinzufügen
|
||||
$lastPart = end($nameParts);
|
||||
if (isset($hierarchy[$lastPart])) {
|
||||
$hierarchy[$lastPart]['tokens'][] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->cleanupHierarchy($hierarchy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analysiert Token-Usage in CSS Rules
|
||||
*/
|
||||
private function analyzeTokenUsage(CssParseResult $parseResult, array $tokens): array
|
||||
{
|
||||
$usage = [];
|
||||
$tokenNames = array_map(fn (DesignToken $token) => $token->name, $tokens);
|
||||
|
||||
foreach ($parseResult->rules as $rule) {
|
||||
foreach ($rule->properties as $property) {
|
||||
if ($property->usesCustomProperty()) {
|
||||
$refs = $property->getCustomPropertyReferences();
|
||||
|
||||
foreach ($refs as $ref) {
|
||||
if (in_array($ref, $tokenNames)) {
|
||||
if (! isset($usage[$ref])) {
|
||||
$usage[$ref] = [
|
||||
'count' => 0,
|
||||
'properties' => [],
|
||||
'selectors' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$usage[$ref]['count']++;
|
||||
$usage[$ref]['properties'][] = $property->name;
|
||||
$usage[$ref]['selectors'] = array_merge(
|
||||
$usage[$ref]['selectors'],
|
||||
$rule->getSelectorStrings()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate arrays
|
||||
foreach ($usage as $tokenName => $data) {
|
||||
$usage[$tokenName]['properties'] = array_unique($data['properties']);
|
||||
$usage[$tokenName]['selectors'] = array_unique($data['selectors']);
|
||||
}
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet unbenutzte Tokens
|
||||
*/
|
||||
private function findUnusedTokens(array $tokens, array $usage): array
|
||||
{
|
||||
$unused = [];
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (! isset($usage[$token->name])) {
|
||||
$unused[] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
return $unused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet fehlende Standard-Tokens
|
||||
*/
|
||||
private function findMissingStandardTokens(array $tokensByType): array
|
||||
{
|
||||
$standardTokens = [
|
||||
DesignTokenType::COLOR->value => [
|
||||
'primary', 'secondary', 'success', 'warning', 'error', 'info',
|
||||
'background', 'surface', 'text-primary', 'text-secondary',
|
||||
],
|
||||
DesignTokenType::SPACING->value => [
|
||||
'xs', 'sm', 'md', 'lg', 'xl', '2xl',
|
||||
'gap-xs', 'gap-sm', 'gap-md', 'gap-lg',
|
||||
],
|
||||
DesignTokenType::TYPOGRAPHY->value => [
|
||||
'font-primary', 'font-secondary',
|
||||
'text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl',
|
||||
'weight-normal', 'weight-bold',
|
||||
],
|
||||
DesignTokenType::RADIUS->value => [
|
||||
'radius-sm', 'radius-md', 'radius-lg', 'radius-full',
|
||||
],
|
||||
DesignTokenType::SHADOW->value => [
|
||||
'shadow-sm', 'shadow-md', 'shadow-lg',
|
||||
],
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($standardTokens as $type => $expectedTokens) {
|
||||
if (! isset($tokensByType[$type])) {
|
||||
$missing[$type] = $expectedTokens;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingNames = array_map(
|
||||
fn (DesignToken $token) => $token->name,
|
||||
$tokensByType[$type]['tokens']
|
||||
);
|
||||
|
||||
$missingInType = array_diff($expectedTokens, $existingNames);
|
||||
|
||||
if (! empty($missingInType)) {
|
||||
$missing[$type] = $missingInType;
|
||||
}
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert Empfehlungen basierend auf der Analyse
|
||||
*/
|
||||
private function generateRecommendations(array $tokensByType, array $unusedTokens, array $missingTokens): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Empfehlungen für unbenutzte Tokens
|
||||
if (! empty($unusedTokens)) {
|
||||
$recommendations[] = sprintf(
|
||||
'Remove %d unused design tokens to reduce CSS complexity.',
|
||||
count($unusedTokens)
|
||||
);
|
||||
}
|
||||
|
||||
// Empfehlungen für fehlende Tokens
|
||||
if (! empty($missingTokens)) {
|
||||
foreach ($missingTokens as $type => $tokens) {
|
||||
$recommendations[] = sprintf(
|
||||
'Consider adding %s tokens: %s',
|
||||
$type,
|
||||
implode(', ', array_slice($tokens, 0, 3)) . (count($tokens) > 3 ? '...' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Empfehlungen für Token-Balance
|
||||
$totalTokens = array_sum(array_column($tokensByType, 'count'));
|
||||
if ($totalTokens > 0) {
|
||||
foreach ($tokensByType as $type => $data) {
|
||||
$percentage = round(($data['count'] / $totalTokens) * 100, 1);
|
||||
|
||||
if ($type === 'color' && $percentage > 50) {
|
||||
$recommendations[] = 'High percentage of color tokens detected. Consider consolidating similar colors.';
|
||||
}
|
||||
|
||||
if ($type === 'spacing' && $percentage < 10 && $totalTokens > 10) {
|
||||
$recommendations[] = 'Low percentage of spacing tokens. Consider adding more consistent spacing values.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empfehlungen für Naming Conventions
|
||||
if (isset($tokensByType[DesignTokenType::COLOR->value])) {
|
||||
$colorTokens = $tokensByType[DesignTokenType::COLOR->value]['tokens'];
|
||||
$semanticNames = 0;
|
||||
$literalNames = 0;
|
||||
|
||||
foreach ($colorTokens as $token) {
|
||||
if (preg_match('/^(primary|secondary|success|warning|error|info|background|surface|text)/', $token->name)) {
|
||||
$semanticNames++;
|
||||
} elseif (preg_match('/^(red|blue|green|yellow|orange|purple|gray|black|white)/', $token->name)) {
|
||||
$literalNames++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($literalNames > $semanticNames) {
|
||||
$recommendations[] = 'Consider using more semantic color names (primary, secondary) instead of literal names (red, blue).';
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($recommendations)) {
|
||||
$recommendations[] = 'Design token structure looks good! Consider documenting token usage guidelines.';
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt die Hierarchie von leeren Einträgen
|
||||
*/
|
||||
private function cleanupHierarchy(array $hierarchy): array
|
||||
{
|
||||
$cleaned = [];
|
||||
|
||||
foreach ($hierarchy as $key => $value) {
|
||||
if (! empty($value['tokens']) || ! empty($value['children'])) {
|
||||
$cleaned[$key] = [
|
||||
'tokens' => $value['tokens'],
|
||||
'children' => $this->cleanupHierarchy($value['children']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $cleaned;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user