Spec 190: tighten baseline compare matrix scanability (#222)

## Summary
- tighten the baseline compare matrix working surface with active filter scope summaries and clearer visible-set disclosure
- improve matrix scanability with a sticky subject column, calmer attention-first cell styling, and Filament form-based filter controls
- replace the misleading perpetual refresh loading state with a passive auto-refresh note and add focused regression coverage

## Testing
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php`

## Notes
- this PR only contains the Spec 190 implementation changes on `190-baseline-compare-matrix`
- follow-up spec drafting for high-density operator mode was intentionally left out of this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #222
This commit is contained in:
ahmido 2026-04-11 12:32:10 +00:00
parent eca19819d1
commit 65e10a2020
5 changed files with 597 additions and 377 deletions

View File

@ -23,12 +23,19 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord; use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page; use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Schema;
class BaselineCompareMatrix extends Page class BaselineCompareMatrix extends Page implements HasForms
{ {
use InteractsWithForms;
use InteractsWithRecord; use InteractsWithRecord;
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
@ -83,6 +90,82 @@ public function mount(int|string $record): void
$this->record = $this->resolveRecord($record); $this->record = $this->resolveRecord($record);
$this->hydrateFiltersFromRequest(); $this->hydrateFiltersFromRequest();
$this->refreshMatrix(); $this->refreshMatrix();
$this->form->fill($this->filterFormState());
}
public function form(Schema $schema): Schema
{
return $schema
->schema([
Grid::make([
'default' => 1,
'xl' => 2,
])
->schema([
Grid::make([
'default' => 1,
'lg' => 5,
])
->schema([
CheckboxList::make('selectedPolicyTypes')
->label('Policy types')
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.'
: null)
->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter',
])
->columns(1)
->columnSpan([
'lg' => 2,
])
->live(),
CheckboxList::make('selectedStates')
->label('Technical states')
->options(fn (): array => $this->matrixOptions('stateOptions'))
->columnSpan([
'lg' => 2,
])
->columns(1)
->live(),
CheckboxList::make('selectedSeverities')
->label('Severity')
->options(fn (): array => $this->matrixOptions('severityOptions'))
->columns(1)
->live(),
])
->columnSpan([
'xl' => 1,
]),
Grid::make([
'default' => 1,
'md' => 2,
'xl' => 1,
])
->schema([
Select::make('tenantSort')
->label('Tenant sort')
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
->default('tenant_name')
->native(false)
->live()
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
Select::make('subjectSort')
->label('Subject sort')
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
->default('deviation_breadth')
->native(false)
->live()
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
])
->columnSpan([
'xl' => 1,
]),
]),
]);
} }
protected function authorizeAccess(): void protected function authorizeAccess(): void
@ -168,6 +251,11 @@ public function refreshMatrix(): void
]); ]);
} }
public function pollMatrix(): void
{
$this->refreshMatrix();
}
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
{ {
$tenant = $this->tenant($tenantId); $tenant = $this->tenant($tenantId);
@ -254,6 +342,40 @@ public function updatedFocusedSubjectKey(): void
$this->refreshMatrix(); $this->refreshMatrix();
} }
public function activeFilterCount(): int
{
return count($this->selectedPolicyTypes)
+ count($this->selectedStates)
+ count($this->selectedSeverities)
+ ($this->focusedSubjectKey !== null ? 1 : 0);
}
/**
* @return array<string, int|string>
*/
public function activeFilterSummary(): array
{
$summary = [];
if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes);
}
if ($this->selectedStates !== []) {
$summary['Technical states'] = count($this->selectedStates);
}
if ($this->selectedSeverities !== []) {
$summary['Severity'] = count($this->selectedSeverities);
}
if ($this->focusedSubjectKey !== null) {
$summary['Focused subject'] = $this->focusedSubjectKey;
}
return $summary;
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -283,6 +405,30 @@ private function hydrateFiltersFromRequest(): void
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null; $this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
} }
/**
* @return array<string, mixed>
*/
private function filterFormState(): array
{
return [
'selectedPolicyTypes' => $this->selectedPolicyTypes,
'selectedStates' => $this->selectedStates,
'selectedSeverities' => $this->selectedSeverities,
'tenantSort' => $this->tenantSort,
'subjectSort' => $this->subjectSort,
];
}
/**
* @return array<string, string>
*/
private function matrixOptions(string $key): array
{
$options = $this->matrix[$key] ?? null;
return is_array($options) ? $options : [];
}
/** /**
* @return list<string> * @return list<string>
*/ */

View File

@ -1,6 +1,6 @@
<x-filament::page> <x-filament::page>
@if (($hasActiveRuns ?? false) === true) @if (($hasActiveRuns ?? false) === true)
<div wire:poll.5s="refreshMatrix"></div> <div wire:poll.5s="pollMatrix"></div>
@endif @endif
@php @php
@ -19,6 +19,9 @@
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : []; $currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
$referenceReady = ($reference['referenceState'] ?? null) === 'ready'; $referenceReady = ($reference['referenceState'] ?? null) === 'ready';
$matrixSourceNavigation = is_array($navigationContext ?? null) ? $navigationContext : null; $matrixSourceNavigation = is_array($navigationContext ?? null) ? $navigationContext : null;
$activeFilterCount = $this->activeFilterCount();
$activeFilterSummary = $this->activeFilterSummary();
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - (int) ($reference['visibleTenantCount'] ?? 0));
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value); $stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value); $freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
@ -55,6 +58,12 @@
Snapshot #{{ (int) $reference['referenceSnapshotId'] }} Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
</x-filament::badge> </x-filament::badge>
@endif @endif
@if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="info" icon="heroicon-m-eye-slash" size="sm">
{{ $hiddenAssignedTenantCount }} hidden by access scope
</x-filament::badge>
@endif
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
@ -75,6 +84,12 @@
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }} Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
</p> </p>
@endif @endif
@if ($hiddenAssignedTenantCount > 0)
<p class="text-sm text-gray-500 dark:text-gray-400">
Showing only the visible assigned set for your current access scope. Hidden tenants are excluded from summaries, rows, and drilldowns.
</p>
@endif
</div> </div>
</div> </div>
@ -115,165 +130,139 @@
Narrow the matrix by policy type, technical state, severity, or one focused subject. Only the visible tenant set contributes to the rendered counts, rows, and drilldowns. Narrow the matrix by policy type, technical state, severity, or one focused subject. Only the visible tenant set contributes to the rendered counts, rows, and drilldowns.
</x-slot> </x-slot>
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]" data-testid="baseline-compare-matrix-filters"> <div class="space-y-4" data-testid="baseline-compare-matrix-filters">
<div class="grid gap-6 lg:grid-cols-3"> <div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="matrix-active-filters">
<div class="space-y-3"> <div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Policy types</div> <div class="space-y-1 min-w-0">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current matrix scope</div>
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
@if ($activeFilterCount === 0)
No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.
@else
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are narrowing the matrix before you scan drift and follow-up links.
@endif
</p>
</div>
@if ($policyTypeOptions !== []) <div class="flex flex-wrap items-center gap-2 lg:justify-end">
<div class="space-y-2"> <x-filament::badge :color="$activeFilterCount === 0 ? 'gray' : 'info'" icon="heroicon-m-funnel" size="sm">
@foreach ($policyTypeOptions as $value => $label) @if ($activeFilterCount === 0)
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200"> All visible results
<input @else
type="checkbox" {{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
value="{{ $value }}" @endif
wire:model.live="selectedPolicyTypes" </x-filament::badge>
data-testid="matrix-filter-policy-type-{{ \Illuminate\Support\Str::slug($value, '-') }}"
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900" @if ($hiddenAssignedTenantCount > 0)
/> <x-filament::badge color="gray" size="sm">
<span>{{ $label }}</span> Visible-set only
</label> </x-filament::badge>
@endif
</div>
</div>
@if ($activeFilterSummary !== [])
<div class="mt-2 flex flex-wrap gap-2">
@foreach ($activeFilterSummary as $label => $value)
<x-filament::badge color="info" size="sm">
{{ $label }}: {{ $value }}
</x-filament::badge>
@endforeach @endforeach
</div> </div>
@else
<p class="text-sm text-gray-500 dark:text-gray-400">Policy type filters appear after a usable reference snapshot is available.</p>
@endif @endif
</div> </div>
<div class="space-y-3"> <form wire:submit.prevent="refreshMatrix">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Technical states</div> {{ $this->form }}
</form>
<div class="space-y-2"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
@foreach ($stateOptions as $value => $label) <div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-2.5 dark:border-gray-700 dark:bg-gray-900/40">
@php <div class="flex flex-wrap items-center gap-2 text-sm">
$spec = $stateBadge($value); <span class="font-semibold text-gray-950 dark:text-white">Focused subject</span>
@endphp
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
<input
type="checkbox"
value="{{ $value }}"
wire:model.live="selectedStates"
data-testid="matrix-filter-state-{{ \Illuminate\Support\Str::slug($value, '-') }}"
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
/>
<span class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
{{ $label }}
</x-filament::badge>
</span>
</label>
@endforeach
</div>
</div>
<div class="space-y-3">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Severity</div>
<div class="space-y-2">
@foreach ($severityOptions as $value => $label)
@php
$spec = $severityBadge($value);
@endphp
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
<input
type="checkbox"
value="{{ $value }}"
wire:model.live="selectedSeverities"
data-testid="matrix-filter-severity-{{ \Illuminate\Support\Str::slug($value, '-') }}"
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
/>
<span class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
{{ $label }}
</x-filament::badge>
</span>
</label>
@endforeach
</div>
</div>
</div>
<div class="space-y-4">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
<label class="space-y-2 text-sm">
<span class="font-semibold text-gray-950 dark:text-white">Tenant sort</span>
<select wire:model.live="tenantSort" data-testid="matrix-tenant-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
@foreach ($tenantSortOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</label>
<label class="space-y-2 text-sm">
<span class="font-semibold text-gray-950 dark:text-white">Subject sort</span>
<select wire:model.live="subjectSort" data-testid="matrix-subject-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
@foreach ($subjectSortOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</label>
</div>
<div class="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
<div class="text-sm font-semibold text-gray-950 dark:text-white">Focused subject</div>
@if (filled($currentFilters['subject_key'] ?? null)) @if (filled($currentFilters['subject_key'] ?? null))
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm"> <x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
{{ $currentFilters['subject_key'] }} {{ $currentFilters['subject_key'] }}
</x-filament::badge> </x-filament::badge>
<x-filament::link :href="$this->clearSubjectFocusUrl()" color="gray" size="sm"> <x-filament::button tag="a" :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
Clear subject focus Clear subject focus
</x-filament::link> </x-filament::button>
</div>
@else @else
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Focus a single row from the matrix when you want a subject-first drilldown.</p> <span class="text-gray-500 dark:text-gray-400">None set yet. Use Focus subject from a row when you want a subject-first drilldown.</span>
@endif @endif
</div> </div>
</div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3 lg:shrink-0">
<x-filament::link :href="\App\Filament\Resources\BaselineProfileResource::compareMatrixUrl($profile)" color="gray" size="sm"> @if ($activeFilterCount > 0)
<x-filament::button tag="a" :href="\App\Filament\Resources\BaselineProfileResource::compareMatrixUrl($profile)" color="gray" size="sm">
Clear all filters Clear all filters
</x-filament::link> </x-filament::button>
@else
<x-filament::badge color="gray" size="sm">
No filter reset needed
</x-filament::badge>
@endif
</div> </div>
</div> </div>
<x-filament-actions::modals />
</div> </div>
</x-filament::section> </x-filament::section>
<div class="grid gap-4 xl:grid-cols-3"> <div class="relative" data-testid="baseline-compare-matrix-results">
<x-filament::section heading="State legend"> @if (($hasActiveRuns ?? false) === true)
<div class="flex flex-wrap gap-2"> <div class="mb-2 flex justify-end" data-testid="baseline-compare-matrix-auto-refresh-note">
<div class="flex items-center gap-2 rounded-full border border-gray-200 bg-white/95 px-3 py-1.5 text-xs shadow-sm dark:border-gray-800 dark:bg-gray-900/95">
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
Auto-refresh every 5 seconds while compare runs are queued or running.
</x-filament::badge>
</div>
</div>
@endif
<div class="space-y-6">
<x-filament::section heading="Matrix signal legend">
<div class="grid gap-3 xl:grid-cols-3">
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
<div class="flex flex-wrap items-center gap-2">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
@foreach ($stateLegend as $item) @foreach ($stateLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm"> <x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }} {{ $item['label'] }}
</x-filament::badge> </x-filament::badge>
@endforeach @endforeach
</div> </div>
</x-filament::section> </div>
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
<div class="flex flex-wrap items-center gap-2">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
<x-filament::section heading="Freshness legend">
<div class="flex flex-wrap gap-2">
@foreach ($freshnessLegend as $item) @foreach ($freshnessLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm"> <x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }} {{ $item['label'] }}
</x-filament::badge> </x-filament::badge>
@endforeach @endforeach
</div> </div>
</x-filament::section> </div>
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
<div class="flex flex-wrap items-center gap-2">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
<x-filament::section heading="Trust legend">
<div class="flex flex-wrap gap-2">
@foreach ($trustLegend as $item) @foreach ($trustLegend as $item)
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm"> <x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
{{ $item['label'] }} {{ $item['label'] }}
</x-filament::badge> </x-filament::badge>
@endforeach @endforeach
</div> </div>
</x-filament::section>
</div> </div>
</div>
</x-filament::section>
@if ($emptyState !== null) @if ($emptyState !== null)
<x-filament::section> <x-filament::section>
@ -371,15 +360,15 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
<x-filament::section heading="Subject-by-tenant matrix"> <x-filament::section heading="Subject-by-tenant matrix">
<x-slot name="description"> <x-slot name="description">
Row click is intentionally disabled. Use the explicit subject, tenant, finding, and run links inside the matrix cells. Row click is intentionally disabled. The subject column stays pinned while you scan across visible tenants.
</x-slot> </x-slot>
<div class="overflow-x-auto" data-testid="baseline-compare-matrix-grid"> <div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
<div class="min-w-[72rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800"> <div class="min-w-[72rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800"> <table class="min-w-full border-separate border-spacing-0">
<thead class="bg-gray-50 dark:bg-gray-950/70"> <thead class="bg-gray-50 dark:bg-gray-950/70">
<tr> <tr>
<th class="w-80 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="sticky left-0 z-20 w-[26rem] border-r border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:bg-gray-950/70 dark:text-gray-400">
Baseline subject Baseline subject
</th> </th>
@ -387,7 +376,7 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
@php @php
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null); $freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
@endphp @endphp
<th class="min-w-64 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="min-w-72 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div> <div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@ -401,17 +390,21 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900/60"> <tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($rows as $row) @foreach ($rows as $row)
@php @php
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : []; $subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : []; $cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null; $subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null); $subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
$rowKey = (string) ($subject['subjectKey'] ?? 'subject-'.$loop->index);
$rowSurfaceClasses = $loop->even
? 'bg-gray-50/70 dark:bg-gray-950/20'
: 'bg-white dark:bg-gray-900/60';
@endphp @endphp
<tr data-testid="baseline-compare-matrix-row"> <tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" data-testid="baseline-compare-matrix-row" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}">
<td class="px-4 py-4 align-top"> <td class="sticky left-0 z-10 border-r border-gray-200 px-4 py-4 align-top dark:border-gray-800 {{ $rowSurfaceClasses }}">
<div class="space-y-3"> <div class="space-y-3">
<div class="space-y-1"> <div class="space-y-1">
<div class="text-sm font-semibold text-gray-950 dark:text-white"> <div class="text-sm font-semibold text-gray-950 dark:text-white">
@ -462,6 +455,27 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
$cellStateSpec = $stateBadge($cell['state'] ?? null); $cellStateSpec = $stateBadge($cell['state'] ?? null);
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null); $cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null; $cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
$cellState = (string) ($cell['state'] ?? '');
$cellSeverity = is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null;
$cellNeedsAttention = in_array($cellState, ['differ', 'missing', 'ambiguous'], true)
|| in_array($cellSeverity, ['critical', 'high'], true);
$cellNeedsRefresh = in_array($cellState, ['stale_result', 'not_compared'], true);
$cellLooksHealthy = $cellState === 'match' && $cellNeedsAttention === false && $cellNeedsRefresh === false;
$cellSurfaceClasses = $cellNeedsAttention
? 'border-danger-300 bg-danger-50/80 ring-1 ring-danger-200/70 dark:border-danger-900/70 dark:bg-danger-950/15 dark:ring-danger-900/40'
: ($cellNeedsRefresh
? 'border-warning-300 bg-warning-50/80 ring-1 ring-warning-200/70 dark:border-warning-900/70 dark:bg-warning-950/15 dark:ring-warning-900/40'
: ($cellLooksHealthy
? 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10'
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40'));
$cellPriorityLabel = $cellNeedsAttention
? 'Needs attention'
: ($cellNeedsRefresh ? 'Refresh recommended' : ($cellLooksHealthy ? 'Aligned' : 'Review'));
$cellPriorityClasses = $cellNeedsAttention
? 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300'
: ($cellNeedsRefresh
? 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300'
: 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300');
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null); $subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
$tenantId = (int) ($cell['tenantId'] ?? 0); $tenantId = (int) ($cell['tenantId'] ?? 0);
$tenantCompareUrl = $tenantId > 0 ? $this->tenantCompareUrl($tenantId, $subjectKey) : null; $tenantCompareUrl = $tenantId > 0 ? $this->tenantCompareUrl($tenantId, $subjectKey) : null;
@ -473,15 +487,13 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
: null; : null;
@endphp @endphp
<td class="px-4 py-4 align-top"> <td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
<div class="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950/40"> <div class="space-y-3 rounded-xl border p-3 transition-colors group-hover:border-primary-200 group-hover:bg-white dark:group-hover:border-primary-900 dark:group-hover:bg-gray-950/60 {{ $cellSurfaceClasses }}">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm"> <x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
{{ $cellStateSpec->label }} {{ $cellStateSpec->label }}
</x-filament::badge> </x-filament::badge>
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
{{ $cellTrustSpec->label }}
</x-filament::badge>
@if ($cellSeveritySpec) @if ($cellSeveritySpec)
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm"> <x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
{{ $cellSeveritySpec->label }} {{ $cellSeveritySpec->label }}
@ -489,6 +501,17 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
@endif @endif
</div> </div>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $cellPriorityClasses }}">
{{ $cellPriorityLabel }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
{{ $cellTrustSpec->label }}
</x-filament::badge>
</div>
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300"> <div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
@if (filled($cell['reasonCode'] ?? null)) @if (filled($cell['reasonCode'] ?? null))
<div> <div>
@ -535,4 +558,6 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
</div> </div>
</x-filament::section> </x-filament::section>
@endif @endif
</div>
</div>
</x-filament::page> </x-filament::page>

View File

@ -48,21 +48,34 @@
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->waitForText('Visible-set baseline') ->waitForText('Visible-set baseline')
->assertSee('Reference overview') ->assertSee('Reference overview')
->assertSee('No narrowing filters are active')
->assertSee('Subject-by-tenant matrix') ->assertSee('Subject-by-tenant matrix')
->assertSee('WiFi Corp Profile') ->assertSee('WiFi Corp Profile')
->assertSee('Windows Compliance') ->assertSee('Windows Compliance')
->assertSee('Open finding'); ->assertSee('Open finding');
$page->script(<<<'JS' $page->script(<<<'JS'
const input = document.querySelector('[data-testid="matrix-filter-state-differ"]'); const input = Array.from(document.querySelectorAll('input[type="checkbox"]')).find((element) => {
if (input instanceof HTMLInputElement) { if (element.getAttribute('aria-label') === 'Drift detected') {
return true;
}
const label = element.closest('label');
return label instanceof HTMLLabelElement && label.innerText.includes('Drift detected');
});
if (! (input instanceof HTMLInputElement)) {
throw new Error('Drift detected checkbox not found.');
}
input.click(); input.click();
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true }));
}
JS); JS);
$page $page
->wait(1)
->waitForText('Open finding') ->waitForText('Open finding')
->assertDontSee('Windows Compliance') ->assertDontSee('Windows Compliance')
->click('Open finding') ->click('Open finding')

View File

@ -39,14 +39,21 @@
->assertOk() ->assertOk()
->assertSee('Visible-set baseline') ->assertSee('Visible-set baseline')
->assertSee('Reference overview') ->assertSee('Reference overview')
->assertSee('fi-fo-checkbox-list', false)
->assertSee('fi-fo-select', false)
->assertSee('State legend') ->assertSee('State legend')
->assertSee('Tenant summaries') ->assertSee('Tenant summaries')
->assertSee('Subject-by-tenant matrix') ->assertSee('Subject-by-tenant matrix')
->assertSee('No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.')
->assertSee('1 hidden by access scope')
->assertSee('WiFi Corp Profile') ->assertSee('WiFi Corp Profile')
->assertSee((string) $fixture['visibleTenant']->name) ->assertSee((string) $fixture['visibleTenant']->name)
->assertSee((string) $fixture['visibleTenantTwo']->name) ->assertSee((string) $fixture['visibleTenantTwo']->name)
->assertSee('Needs attention')
->assertSee('Open finding') ->assertSee('Open finding')
->assertSee('Open tenant compare'); ->assertSee('Open tenant compare')
->assertSee('data-testid="matrix-active-filters"', false)
->assertSee('sticky left-0', false);
}); });
it('keeps query-backed filters and subject focus on the matrix route and drilldown links', function (): void { it('keeps query-backed filters and subject focus on the matrix route and drilldown links', function (): void {
@ -82,6 +89,9 @@
]) ])
->actingAs($fixture['user']) ->actingAs($fixture['user'])
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]) ->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
->assertSee('4 active filters')
->assertSee('Policy types: 1')
->assertSee('Focused subject: wifi-corp-profile')
->assertSee('Clear subject focus') ->assertSee('Clear subject focus')
->assertDontSee('Windows Compliance'); ->assertDontSee('Windows Compliance');
@ -139,6 +149,31 @@
->assertSee('No visible assigned tenants'); ->assertSee('No visible assigned tenants');
}); });
it('renders a passive auto-refresh note instead of a perpetual loading state while compare runs remain active', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture();
$this->makeBaselineCompareMatrixRun(
$fixture['visibleTenant'],
$fixture['profile'],
$fixture['snapshot'],
attributes: [
'status' => \App\Support\OperationRunStatus::Queued->value,
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
'completed_at' => null,
'started_at' => now(),
],
);
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
$this->withSession($session)
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
->assertOk()
->assertSee('Auto-refresh every 5 seconds while compare runs are queued or running.')
->assertSee('wire:poll.5s="pollMatrix"', false)
->assertDontSee('Refreshing matrix');
});
it('renders an empty state when no rows match the current filters', function (): void { it('renders an empty state when no rows match the current filters', function (): void {
$fixture = $this->makeBaselineCompareMatrixFixture(); $fixture = $this->makeBaselineCompareMatrixFixture();

View File

@ -139,6 +139,7 @@ ## Phase 7: Polish & Cross-Cutting Concerns
- [X] T036 [P] Add no-ad-hoc-badge and no-diagnostic-warning guard coverage for matrix state surfaces in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php` - [X] T036 [P] Add no-ad-hoc-badge and no-diagnostic-warning guard coverage for matrix state surfaces in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
- [X] T037 [P] Add browser smoke coverage for matrix render, one filter interaction, and one drilldown or compare affordance in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` - [X] T037 [P] Add browser smoke coverage for matrix render, one filter interaction, and one drilldown or compare affordance in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- [X] T038 [P] Review `Verb + Object`, `visible-set only`, and `simulation only` copy in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` - [X] T038 [P] Review `Verb + Object`, `visible-set only`, and `simulation only` copy in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
- [X] T041 [P] Tighten matrix scanability with active-filter scope summaries, visible-set scope disclosure, non-blocking refresh feedback, sticky subject-column treatment, and focused UI regression coverage in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- [X] T039 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/190-baseline-compare-matrix/quickstart.md` - [X] T039 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/190-baseline-compare-matrix/quickstart.md`
- [ ] T040 Run the focused verification pack from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` - [ ] T040 Run the focused verification pack from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`