From 4c92ec1e64fd2f585509c8cb23da0a85929b4183 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 11 Apr 2026 14:33:08 +0200 Subject: [PATCH 1/5] spec: draft baseline compare operator mode --- .../checklists/requirements.md | 13 ++ .../plan.md | 160 +++++++++++++ .../spec.md | 214 ++++++++++++++++++ .../tasks.md | 146 ++++++++++++ 4 files changed, 533 insertions(+) create mode 100644 specs/191-baseline-compare-operator-mode/checklists/requirements.md create mode 100644 specs/191-baseline-compare-operator-mode/plan.md create mode 100644 specs/191-baseline-compare-operator-mode/spec.md create mode 100644 specs/191-baseline-compare-operator-mode/tasks.md diff --git a/specs/191-baseline-compare-operator-mode/checklists/requirements.md b/specs/191-baseline-compare-operator-mode/checklists/requirements.md new file mode 100644 index 00000000..b8f11547 --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist: Baseline Compare Matrix: High-Density Operator Mode + +- [x] Spec candidate check is complete and scores the candidate before approval. +- [x] The spec is explicitly scoped as a follow-up to the existing workspace matrix rather than a new domain truth. +- [x] Multi-tenant dense mode is defined as the primary operator-density gain. +- [x] Single-tenant compact mode is defined as a separate adaptive presentation path. +- [x] Filters, legends, actions, and refresh surfaces are explicitly compressed as supporting context. +- [x] Visible-set-only semantics and existing RBAC rules are preserved. +- [x] No new persisted artifact, state family, or generalized UI framework is introduced. +- [x] Manual presentation override is local to the route and not stored as domain truth. +- [x] Functional requirements include mode selection, action calming, filter workflow, and last-updated visibility. +- [x] Definition of done is testable and aligned with operator scanability rather than generic visual polish. +- [x] Tasks are grouped by user story and include focused verification work. \ No newline at end of file diff --git a/specs/191-baseline-compare-operator-mode/plan.md b/specs/191-baseline-compare-operator-mode/plan.md new file mode 100644 index 00000000..34499099 --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/plan.md @@ -0,0 +1,160 @@ +# Implementation Plan: Baseline Compare Matrix: High-Density Operator Mode + +**Branch**: `191-baseline-compare-operator-mode` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md` + +## Summary + +Rework the existing baseline compare matrix route into an operator-density follow-up to Spec 190. The route stays workspace-scoped and fully derived, but gains adaptive presentation rules: dense multi-tenant scanning when several visible tenants are present, compact single-tenant comparison when only one visible tenant remains, and calmer filter, legend, action, and refresh surfaces. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns +**Storage**: Existing PostgreSQL truth only; no new tables or artifacts +**Testing**: Pest feature tests and one browser smoke path through Sail +**Target Platform**: Laravel monolith web application under `apps/platform` +**Project Type**: web application +**Performance Goals**: Improve operator scan throughput without adding more data queries than Spec 190; keep heavy filter changes explicit rather than chatty +**Constraints**: No compare-logic changes, no new persistence, no hidden-tenant leakage, no generalized UI framework, no Filament provider changes +**Scale/Scope**: One existing matrix page, one existing view, one existing builder, and focused test coverage updates + +## Constitution Check + +*GATE: Passed before design. No new source-of-truth or persistence changes are expected.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| Inventory-first / snapshots-second | PASS | The feature changes presentation only and keeps Spec 190 truth sources intact. | +| Read/write separation | PASS | `Compare assigned tenants` remains the only mutation and already exists. | +| Workspace + tenant isolation | PASS | Visible-set-only behavior remains unchanged. | +| RBAC-UX | PASS | Existing `404` vs `403` semantics stay intact; only presentation changes. | +| Ops-UX 3-surface feedback | PASS | Refresh and polling surfaces are clarified visually without changing run semantics. | +| Proportionality / anti-bloat | PASS | No new persistence, enum, framework, or cross-domain abstraction is introduced. | +| UI semantics / few layers | PASS | Dense and compact modes reuse existing badge and compare semantics rather than inventing new status taxonomies. | +| Filament v5 / Livewire v4 compliance | PASS | Work remains on the existing Filament page and Livewire-backed route. | +| Provider registration location | PASS | No provider changes; registration remains in `bootstrap/providers.php`. | +| Global search hard rule | PASS | No new global-searchable resource or page is introduced. | +| Destructive action safety | PASS | No destructive action is added by this spec. | +| Asset strategy | PASS | No new panel assets or shared assets are required. Existing deployment use of `filament:assets` remains unchanged. | + +## Filament-Specific Compliance Notes + +- **Livewire v4.0+ compliance**: This plan stays on the existing Filament v5 + Livewire v4 page stack and does not introduce legacy APIs. +- **Provider registration location**: No panel/provider work is needed. Laravel 11+ provider registration remains in `bootstrap/providers.php`. +- **Global search**: This spec does not add a new globally searchable resource. Existing baseline-resource search behavior is unchanged. +- **Destructive actions**: No new destructive action is introduced. Existing compare-start actions remain confirmation-gated where already defined. +- **Asset strategy**: No new global or on-demand asset registration is planned. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged. +- **Testing plan**: Extend the existing matrix feature and browser suites to cover presentation mode, density, compact controls, and non-blocking status surfaces. + +## Project Structure + +### Documentation + +```text +specs/191-baseline-compare-operator-mode/ +├── spec.md +├── plan.md +├── tasks.md +└── checklists/ + └── requirements.md +``` + +### Source Code + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ └── Pages/ +│ │ └── BaselineCompareMatrix.php +│ └── Support/ +│ └── Baselines/ +│ └── BaselineCompareMatrixBuilder.php +├── resources/views/filament/pages/ +│ └── baseline-compare-matrix.blade.php +└── tests/ + ├── Browser/ + │ └── Spec190BaselineCompareMatrixSmokeTest.php + ├── Feature/ + │ ├── Filament/ + │ │ └── BaselineCompareMatrixPageTest.php + │ └── Guards/ + │ └── ActionSurfaceContractTest.php + └── Feature/Baselines/ + └── BaselineCompareMatrixBuilderTest.php +``` + +**Structure Decision**: Keep the work inside the existing Spec 190 implementation surface. This follow-up spec is a refactor of one page and its supporting builder/view behavior, not a new domain slice. + +## Key Design Decisions + +### D-001 — Keep the route and truth model unchanged + +This spec modifies the existing `/compare-matrix` route only. No second route, second matrix artifact, or separate dense-report model is created. + +### D-002 — Use adaptive presentation, not separate features + +`auto` mode is the canonical default. `dense` and `compact` exist as local operator overrides, but the product concept remains one matrix page with adaptive presentation. + +### D-003 — Keep dense cells state-first + +Dense mode cells must prioritize compare state, trust, freshness, and attention. Detailed reasons and multiple navigation targets become secondary reveals rather than permanent default chrome. + +### D-004 — Treat controls as supporting context + +Filters, legends, and refresh hints remain available but must become visibly subordinate to the matrix body. The page should read as a working surface, not a form-first screen. + +### D-005 — Keep single-tenant mode honest + +If only one visible tenant remains, the operator should see a compact comparison surface rather than an artificially wide matrix. The page should not preserve multi-tenant structure when it no longer helps. + +## Implementation Strategy + +### Phase A — Presentation Mode Contract + +- Add `auto`, `dense`, and `compact` mode state to the page. +- Keep override state local to the route and compatible with existing drilldown URLs. +- Reuse the current derived matrix bundle instead of adding a second persisted view model. + +### Phase B — Dense Multi-Tenant Surface + +- Reduce per-cell chrome and prioritize state/trust/freshness. +- Keep the subject axis sticky and readable across horizontal scroll. +- Move repeated actions into compact secondary affordances where necessary. + +### Phase C — Compact Single-Tenant Surface + +- Replace pseudo-matrix presentation with a shorter, calmer list optimized for one visible tenant. +- Remove repeated tenant headers and duplicated labels. +- Preserve subject focus and drilldowns. + +### Phase D — Supporting Context Compression + +- Convert heavy filters to an apply/reset workflow. +- Compress legends into grouped or collapsible supporting blocks. +- Clarify background polling, manual refresh, and last-updated status without using blocking loading surfaces. + +### Phase E — Verification + +- Extend feature coverage for mode selection and density rules. +- Extend browser coverage for one dense-mode path and one compact-mode path. +- Keep existing Spec 190 truth and RBAC guarantees intact. + +## Risks & Mitigations + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| Dense mode becomes another framework | Medium | Low | Keep presentation logic local to the matrix page and view. | +| Compact mode hides too much drilldown value | Medium | Medium | Keep one clear follow-up path per subject and preserve existing drilldowns. | +| Apply/reset feels stale compared with live filters | Medium | Medium | Make staged filter state obvious and keep reset immediate. | +| Manual override confuses operators | Low | Medium | Keep `auto` as default and label override state clearly. | + +## Test Strategy + +- Extend feature tests for mode resolution based on visible tenant count. +- Add assertions for dense multi-tenant sticky subject behavior and reduced visible action noise. +- Add assertions for compact single-tenant rendering and shorter supporting chrome. +- Add coverage for explicit filter apply/reset behavior, grouped legends, and page-level last-updated messaging. +- Reuse existing browser smoke coverage and extend it for one dense path plus one compact-mode path. +- Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and the focused matrix-related Pest suite before sign-off. \ No newline at end of file diff --git a/specs/191-baseline-compare-operator-mode/spec.md b/specs/191-baseline-compare-operator-mode/spec.md new file mode 100644 index 00000000..5676a6af --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/spec.md @@ -0,0 +1,214 @@ +# Feature Specification: Baseline Compare Matrix: High-Density Operator Mode + +**Feature Branch**: `191-baseline-compare-operator-mode` +**Created**: 2026-04-11 +**Status**: Draft +**Input**: User description: "Spec Candidate 190b — Baseline Compare Matrix: High-Density Operator Mode" + +## Spec Candidate Check *(mandatory — SPEC-GATE-001)* + +- **Problem**: The current baseline compare matrix is semantically strong but still too visually heavy for repeat operator use, especially when several visible tenants must be scanned quickly. +- **Today's failure**: Operators reach the right truth, but the page spends too much space on supporting context, repeated actions, and vertically expensive cells. Multi-tenant comparison is slower than it should be, and single-tenant viewing still feels like a stretched matrix instead of a compact operator surface. +- **User-visible improvement**: The same matrix route becomes faster to scan, calmer to use, and more obviously centered on drift detection. Multi-tenant work gets a true dense scan mode, while single-tenant work gets a compact compare list. +- **Smallest enterprise-capable version**: Rework the existing matrix route with adaptive presentation only: `auto` mode picks dense multi-tenant view for more than one visible tenant and compact single-tenant view for one visible tenant, while filters, legends, actions, and refresh feedback are compressed without changing compare logic. +- **Explicit non-goals**: No change to compare truth, no new finding semantics, no new persisted matrix artifact, no generalized table engine, no mobile-first redesign, no broader design-system rewrite. +- **Permanent complexity imported**: One page-level presentation-mode contract, denser cell-layout rules, compact control behavior, route/query persistence for local mode override, and focused regression coverage for the new operator surface behavior. +- **Why now**: Spec 190 established the truthful workspace compare surface. The next real bottleneck is not domain correctness but operator throughput and scan efficiency on the page that now exists. +- **Why not local**: Small CSS-only tweaks will not solve the actual product problem because the core issue is presentation mode, action hierarchy, and default information density rather than isolated spacing bugs. +- **Approval class**: Workflow Compression +- **Red flags triggered**: `New Meta-Infrastructure` risk if presentation-mode work grows into a reusable UI framework. Defense: this spec keeps all mode logic page-local to the existing baseline compare matrix and forbids a generalized density framework. +- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12** +- **Decision**: approve + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: + - `/admin/baseline-profiles/{record}/compare-matrix` as the existing workspace matrix route that gains dense and compact operator modes + - `/admin/baseline-profiles/{record}` as the existing baseline profile detail that remains the canonical entry point into the matrix + - `/admin/t/{tenant}/baseline-compare` as the existing tenant drilldown destination + - `/admin/findings` and finding detail as the existing follow-up destinations + - Monitoring run-detail routes as existing compare-run drilldowns +- **Data Ownership**: + - Workspace-owned baseline profile, snapshot, and assignment truth remain unchanged. + - Tenant-owned compare runs and findings remain unchanged. + - Presentation mode, filter compaction, and dense cell rendering remain derived UI behavior only and introduce no new persisted truth. +- **RBAC**: + - Matrix access remains gated by workspace membership plus `WORKSPACE_BASELINES_VIEW`. + - `Compare assigned tenants` remains gated by `WORKSPACE_BASELINES_MANAGE`. + - Tenant and finding drilldowns continue to enforce their existing tenant-scope capabilities such as `TENANT_VIEW` and `TENANT_FINDINGS_VIEW`. + - Presentation-mode changes MUST NOT widen visibility, leak hidden tenants, or relax `404` vs `403` semantics already established in Spec 190. + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type | +|---|---|---|---|---|---|---|---|---|---|---|---| +| Workspace baseline compare matrix | Workspace matrix / operator surface | Explicit subject, cell, and tenant drilldown controls | forbidden | Header controls, compact cell action slot, focused subject utilities | none | `/admin/baseline-profiles/{record}/compare-matrix` | same route with filter and presentation state | Active workspace, baseline profile, visible tenant count, active filter count, presentation mode, last updated | Baseline compare matrix | Drift hotspots, trust, freshness, and next follow-up path | dense-grid + compact-single-tenant exception | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---| +| Workspace baseline compare matrix | Workspace operator | Matrix / triage surface | Where is the meaningful drift across the visible tenant set, how trustworthy is it, and where should I go next? | Subject-by-tenant state, trust, freshness, severity or attention signal, visible-set filter scope, mode, last updated | Raw reason codes, run identifiers, detailed evidence gaps, low-level compare metadata | compare state, freshness, trust, severity/attention | `simulation only` for compare start; otherwise read-only | Compare assigned tenants, apply or reset filters, switch presentation mode, focus subject, drill into compare/finding/run | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: no +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning. +- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant use cases, so supporting context, cell chrome, and repeated actions stay too heavy for both. +- **Narrowest correct implementation**: Keep the same route, same truth sources, same drilldowns, and same compare semantics while adding one adaptive presentation contract and denser default rendering. +- **Ownership cost**: More page-view branching, additional view-state tests, and stricter UI regression coverage for density, action noise, and status visibility. +- **Alternative intentionally rejected**: A generalized dense-table framework or a second persisted reporting artifact was rejected because this need is local to the baseline compare matrix. +- **Release truth**: current-release operator workflow compression + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1) + +As a workspace operator, I want the matrix to switch into a true high-density view when multiple visible tenants are in scope so I can read drift patterns quickly without losing the subject axis. + +**Why this priority**: Multi-tenant scanning is the core operator value of the matrix. If this remains visually slow, the page does not earn its workspace-level role. + +**Independent Test**: Open the matrix for a baseline profile with multiple visible tenants and verify that one subject row and one tenant column remain readable in a dense layout with a sticky subject column and compact cell states. + +**Acceptance Scenarios**: + +1. **Given** a baseline profile with more than one visible assigned tenant, **When** the operator opens the matrix route in auto mode, **Then** the page renders the dense multi-tenant mode with one subject row per baseline subject and one tenant column per visible tenant. +2. **Given** the operator scrolls horizontally in dense mode, **When** the matrix remains wider than the viewport, **Then** the first subject column stays visible and anchored for cross-tenant reading. +3. **Given** a dense-mode cell represents a visible tenant and subject, **When** the page renders it, **Then** the primary visible signal is the technical state plus condensed trust and freshness rather than a block of repeated links or prose. + +--- + +### User Story 2 - Work a single visible tenant in compact mode (Priority: P2) + +As a workspace operator, I want the matrix to stop pretending to be multi-tenant when only one visible tenant remains so the screen becomes shorter and calmer. + +**Why this priority**: A single-tenant compare surface should not spend horizontal and vertical space simulating columns that do not exist. + +**Independent Test**: Open the matrix for a baseline profile where only one tenant is visible and verify that the page uses a compact compare-list mode instead of the dense cross-tenant layout. + +**Acceptance Scenarios**: + +1. **Given** exactly one visible assigned tenant after RBAC scoping, **When** the operator opens the matrix in auto mode, **Then** the page renders compact single-tenant mode instead of dense mode. +2. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface. + +--- + +### User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2) + +As a workspace operator, I want supporting controls to stay available but compact so the matrix remains the primary working surface above the fold. + +**Why this priority**: Filtering, legends, and refresh status are necessary, but they should support the matrix rather than compete with it. + +**Independent Test**: Open the matrix, apply policy-type or state filters, and verify that active filter count, filter application, legend compaction, and refresh signals remain visible without dominating the page. + +**Acceptance Scenarios**: + +1. **Given** the operator changes multi-select filters, **When** those changes are staged, **Then** the page uses an explicit apply or reset pattern for heavy filter changes instead of re-rendering noisily on every click. +2. **Given** active compare runs or polling are present, **When** the matrix refreshes in the background, **Then** the operator sees a non-blocking update signal and a page-level freshness hint rather than a permanent loading impression. +3. **Given** the operator already understands the legends, **When** the page loads in daily-use mode, **Then** legends are grouped and visually compact, with deeper explanation still available on demand. + +### Edge Cases + +- If total assigned tenants are greater than one but only one tenant is visible to the current actor, auto mode MUST choose compact mode, not dense mode. +- If the operator manually overrides `auto` to `dense` or `compact`, the override MUST stay local to the matrix route and MUST NOT create a persisted user preference or domain artifact. +- If filters reduce the visible row set to zero, the page MUST preserve the active mode and still show a clear empty state. +- If compare runs are queued or running while the page is open, the refresh signal MUST remain distinct from a blocking loading state. +- If dense mode cannot fit all compact cell details legibly, secondary detail MUST move behind tooltip, popover, expand, or a deliberate drilldown instead of widening every cell again. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This follow-up spec changes only the operator presentation of the existing matrix surface. It introduces no new Microsoft Graph path, no new baseline or finding truth, and no new mutation beyond the already-existing compare-start behavior. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature must remain a local presentation refactor on top of Spec 190. It MUST NOT introduce a new persisted report, a new compare artifact, a new domain state family, or a reusable density framework. + +**Constitution alignment (OPS-UX):** Any compare-start controls remain bound to the existing `baseline_compare` run semantics from Spec 190. This spec only changes the presentation around those controls and their feedback, not the run model. + +**Constitution alignment (RBAC-UX):** All existing `404` versus `403` semantics, visible-set-only counts, drilldown authorization, and capability checks remain unchanged. Dense or compact mode MUST never reveal more tenant truth than the current actor can already see. + +**Constitution alignment (BADGE-001):** Dense and compact mode MUST reuse centralized state, freshness, trust, and severity badge semantics. This spec MUST NOT create page-local status colors or a second status vocabulary. + +**Constitution alignment (UI-FIL-001):** The matrix page should continue to use Filament-native sections, actions, and shared primitives. The dense matrix body and compact single-tenant layout may use custom Blade composition where Filament's one-axis primitives are insufficient, but the page MUST avoid inventing a local semantic component framework. + +**Constitution alignment (UI-NAMING-001):** Operator-facing labels must stay aligned with the vocabulary established in Spec 190, including `Baseline compare matrix`, `Compare assigned tenants`, `Reference snapshot`, `Visible tenants`, and the existing compare-state labels. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-REVIEW-001):** The matrix remains a workspace operator surface with explicit inspect controls and forbidden row click. The primary working surface is the matrix body, while filters, legends, and status strips become supporting context. + +### Functional Requirements + +- **FR-191-001 Primary working surface**: The matrix body MUST become visually dominant over reference context, filters, legends, and refresh hints. +- **FR-191-002 Auto presentation mode**: The page MUST support an `auto` presentation mode that chooses dense multi-tenant mode when more than one visible tenant is in scope and compact single-tenant mode when exactly one visible tenant is in scope. +- **FR-191-003 Manual override**: The page MUST allow a local manual override between `auto`, `dense`, and `compact` presentation without persisting that choice as domain truth or a stored user preference. +- **FR-191-004 Dense multi-tenant layout**: Dense mode MUST render one subject row and one visible-tenant column with a sticky first subject column. +- **FR-191-005 Dense cell contract**: Dense mode cells MUST default to compact state, trust, and freshness signals. Detailed reasons, long helper text, and multiple secondary links MUST NOT dominate the default cell chrome. +- **FR-191-006 Single-tenant compact layout**: Compact mode MUST render a shorter subject-result list optimized for one visible tenant instead of a pseudo-matrix with repeated tenant headers and oversized cells. +- **FR-191-007 Action calming**: Repeated follow-up actions such as tenant compare, finding, or run links MUST become visually secondary. The default focus in dense or compact mode MUST remain the compare state, not the link chrome. +- **FR-191-008 Filter density**: The page MUST show active filter count and active filter scope clearly while keeping the filter zone visually compact. +- **FR-191-009 Heavy-filter workflow**: Policy type and other heavy multi-select filters MUST use an explicit apply/reset interaction instead of forcing a full matrix recompute on every click. +- **FR-191-010 Policy type usability**: Policy type filtering MUST be faster than the current long checkbox stack, for example by searchability, type-to-find behavior, or another equally compact operator-first selector. +- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST remain available but MUST be grouped and visually compressed so they do not displace the matrix in daily use. +- **FR-191-012 Honest status transitions**: The page MUST distinguish between active loading, background auto-refresh, and last-updated freshness so operators can tell whether the matrix is recalculating or simply polling for updates. +- **FR-191-013 Last updated visibility**: The page MUST show a page-level or matrix-level freshness hint indicating when the currently rendered matrix data was last refreshed. +- **FR-191-014 Visible-set truth preserved**: Dense and compact mode MUST preserve the visible-set-only semantics already defined in Spec 190 for all counts, subject breadth, and drilldowns. +- **FR-191-015 Drilldown continuity preserved**: Switching presentation mode MUST NOT break subject focus, tenant drilldowns, finding drilldowns, or return-path continuity already established on the matrix route. +- **FR-191-016 No compare-logic changes**: This spec MUST NOT change how drift, trust, freshness, severity, or evidence gaps are calculated. +- **FR-191-017 No new persistence**: This spec MUST NOT introduce a new matrix snapshot, portfolio report, stored view preference, or any other new persisted artifact. +- **FR-191-018 Automated regression coverage**: Automated coverage MUST prove mode selection, sticky dense layout, compact single-tenant layout, filter apply/reset behavior, legend compression, non-blocking refresh state, and preservation of existing drilldowns and RBAC semantics. + +## Non-Goals + +- No change to baseline compare logic or evidence resolution +- No new matrix export or stored report artifact +- No new generic dense-table framework for other pages +- No new finding workflow or remediation workflow +- No mobile-first redesign of the matrix surface +- No cross-workspace or tenant-vs-tenant compare feature + +## Assumptions + +- Spec 190 remains the canonical domain-truth foundation for the matrix. +- Existing builder outputs can be extended or re-rendered without introducing new persistence. +- Existing drilldown URLs and canonical navigation context can carry any local presentation override that must survive navigation. +- Existing badge semantics already cover the status information needed for denser rendering. + +## Dependencies + +- Spec 190 baseline compare matrix route and builder +- Existing matrix page and view +- Existing badge semantics for state, freshness, trust, and severity +- Existing tenant compare, finding, and run-detail destinations + +## Risks + +- Dense mode could drift into a local mini-framework if rendering rules become over-generalized. +- Compacting actions too aggressively could hide next steps instead of calming them. +- Apply/reset filtering could feel slower if the staged-filter state is not clearly signaled. +- Manual mode override could create confusion if `auto` behavior and override state are not explicit. + +## Review Questions + +- Does the page now clearly separate supporting context from the primary working surface? +- Is dense mode truly optimized for cross-tenant scanning rather than just a tighter version of the old layout? +- Is single-tenant mode clearly calmer and shorter than the current matrix? +- Are repeated actions secondary without becoming hard to discover? +- Are filter count, legend compression, and last-updated feedback visible without dominating the page? +- Does the spec stay local to the matrix surface and avoid importing a reusable UI framework? + +## Definition of Done + +This feature is complete when: + +- the existing matrix route supports `auto`, `dense`, and `compact` presentation behavior, +- multi-tenant auto mode renders a clearly denser matrix with a sticky subject column, +- single-tenant auto mode renders a compact compare-list presentation instead of the current matrix-heavy layout, +- supporting context is visibly lighter than the matrix body, +- repeated per-cell or per-row actions no longer dominate the reading flow, +- active filters are counted and heavy filters use an explicit apply/reset pattern, +- legends remain available but are grouped and visually compressed, +- page-level refresh and last-updated signals are honest and non-blocking, +- no compare logic, trust logic, freshness logic, or RBAC semantics have changed, +- and focused feature plus browser coverage proves the new operator-density behavior. \ No newline at end of file diff --git a/specs/191-baseline-compare-operator-mode/tasks.md b/specs/191-baseline-compare-operator-mode/tasks.md new file mode 100644 index 00000000..1c9ed423 --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/tasks.md @@ -0,0 +1,146 @@ +# Tasks: Baseline Compare Matrix: High-Density Operator Mode + +**Input**: Design documents from `/specs/191-baseline-compare-operator-mode/` +**Prerequisites**: `plan.md`, `spec.md` + +**Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route. +**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, no new run-summary contract, and no new notification channel should be introduced. +**RBAC**: Existing workspace and tenant visibility rules from Spec 190 remain authoritative. Tasks must preserve visible-set-only aggregation and existing `404` vs `403` behavior. +**Operator Surfaces**: The affected operator surface is the existing workspace baseline compare matrix route, with additive presentation changes only. +**Filament UI Action Surfaces**: The matrix page keeps explicit drilldown controls and forbidden row click. No destructive action is added. +**Badges**: Dense and compact rendering must continue to use centralized matrix state, trust, freshness, and severity semantics. + +**Organization**: Tasks are grouped by user story so each operator-density improvement can be implemented and verified independently. + +## Phase 1: Setup (Presentation Seams) + +**Purpose**: Prepare focused acceptance seams for adaptive presentation work. + +- [ ] T001 [P] Add presentation-mode acceptance scaffolds in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` +- [ ] T002 [P] Extend surface-contract guard coverage for calmer matrix actions in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` + +**Checkpoint**: The test suite has clear seams for dense mode, compact mode, and action-noise expectations. + +--- + +## Phase 2: Foundational (Blocking Operator-Density Contract) + +**Purpose**: Establish page-level presentation state and derived view metadata before reshaping the UI. + +**⚠️ CRITICAL**: No story work should begin until the presentation contract is stable. + +- [ ] T003 Add `auto`, `dense`, and `compact` presentation state handling plus route persistence in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [ ] T004 [P] Add derived density metadata for compact cell summaries and compact single-tenant summaries in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` +- [ ] T005 [P] Add page-level last-updated and staged-filter metadata in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` + +**Checkpoint**: The page can resolve presentation mode and expose the supporting state required for dense and compact rendering. + +--- + +## Phase 3: User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1) 🎯 MVP + +**Goal**: Make multi-tenant reading materially denser and faster without changing compare truth. + +**Independent Test**: Open the matrix with multiple visible tenants and verify dense mode, sticky subject behavior, and state-first cells. + +### Tests for User Story 1 + +- [ ] T006 [P] [US1] Add dense multi-tenant page coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [ ] T007 [P] [US1] Extend browser smoke coverage for dense-mode scanning in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` + +### Implementation for User Story 1 + +- [ ] T008 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [ ] T009 [US1] Reduce dense-cell chrome to compact state, trust, freshness, and attention signals in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [ ] T010 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [ ] T011 [US1] Run focused US1 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` + +**Checkpoint**: Multi-tenant scanning is visibly denser and the matrix body reads as the primary working surface. + +--- + +## Phase 4: User Story 2 - Work a single visible tenant in compact mode (Priority: P2) + +**Goal**: Replace pseudo-matrix rendering with a compact comparison surface when only one visible tenant remains. + +**Independent Test**: Open the matrix with one visible tenant and verify compact mode in auto state plus drilldown continuity. + +### Tests for User Story 2 + +- [ ] T012 [P] [US2] Add compact single-tenant coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` + +### Implementation for User Story 2 + +- [ ] T013 [US2] Resolve auto-to-compact presentation behavior for one visible tenant in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [ ] T014 [US2] Render the compact single-tenant compare list in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [ ] T015 [US2] Preserve subject focus and drilldown continuity across compact-mode state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [ ] T016 [US2] Run focused US2 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` + +**Checkpoint**: One-tenant viewing is materially shorter and calmer than the current matrix surface. + +--- + +## Phase 5: User Story 3 - Use filters, legends, and status surfaces without losing the matrix (Priority: P2) + +**Goal**: Compress supporting context so it stays useful without pushing the matrix down or increasing visual noise. + +**Independent Test**: Apply filters, inspect legends, and observe background refresh behavior without losing scanability. + +### Tests for User Story 3 + +- [ ] T017 [P] [US3] Add filter apply/reset and legend-compaction coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [ ] T018 [P] [US3] Add non-blocking refresh and last-updated browser coverage in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` + +### Implementation for User Story 3 + +- [ ] T019 [US3] Convert heavy matrix filters to a staged apply/reset workflow in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [ ] T020 [US3] Replace the current policy-type control with a faster compact operator-first selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [ ] T021 [US3] Group or collapse legends and lighten supporting context hierarchy in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [ ] T022 [US3] Render page-level last-updated, polling, and manual-refresh signals without blocking the matrix in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [ ] T023 [US3] Run focused US3 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` + +**Checkpoint**: Filters, legends, and status surfaces support the operator without visually competing with the matrix. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Finalize copy, formatting, and the focused verification pack. + +- [ ] T024 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [ ] T025 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` +- [ ] T026 Run the focused verification pack against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: starts immediately. +- **Foundational (Phase 2)**: depends on Setup and blocks presentation work. +- **US1 (Phase 3)**: depends on Phase 2 and is the MVP. +- **US2 (Phase 4)**: depends on Phase 2 and can follow US1 once the page contract is stable. +- **US3 (Phase 5)**: depends on Phase 2 and should land after dense and compact structure are in place. +- **Polish (Phase 6)**: depends on the desired user stories being complete. + +### Within Each User Story + +- Add or extend the story tests first. +- Land page-state changes before view-branching where possible. +- Keep each story independently shippable and verifiable. + +### Parallel Opportunities + +- `T001` and `T002` can run in parallel. +- `T004` and `T005` can run in parallel after `T003` defines the presentation contract. +- Within US1, `T006` and `T007` can run in parallel before `T008` through `T010`. +- Within US3, `T017` and `T018` can run in parallel before `T019` through `T022`. + +## Implementation Strategy + +1. Lock the presentation contract and route state first. +2. Deliver dense multi-tenant mode as the MVP operator gain. +3. Deliver compact single-tenant mode as the adaptive counterpart. +4. Compress filters, legends, and status surfaces last so they match the final page structure. +5. Finish with copy review, formatting, and the focused verification pack. \ No newline at end of file -- 2.45.2 From 1075ed5ae353e96b4429571332d8d7318c8ff197 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 11 Apr 2026 14:49:52 +0200 Subject: [PATCH 2/5] spec: complete baseline compare operator mode plan --- .github/agents/copilot-instructions.md | 5 +- ...compare-operator-mode.logical.openapi.yaml | 501 ++++++++++++++++++ .../data-model.md | 166 ++++++ .../plan.md | 192 ++++--- .../quickstart.md | 70 +++ .../research.md | 111 ++++ .../tasks.md | 2 +- 7 files changed, 968 insertions(+), 79 deletions(-) create mode 100644 specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml create mode 100644 specs/191-baseline-compare-operator-mode/data-model.md create mode 100644 specs/191-baseline-compare-operator-mode/quickstart.md create mode 100644 specs/191-baseline-compare-operator-mode/research.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index d7b55ebe..16d1fcb1 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -167,6 +167,8 @@ ## Active Technologies - PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores (189-portfolio-triage-review-state) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns (190-baseline-compare-matrix) - PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix) +- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode) +- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode) - PHP 8.4.15 (feat/005-bulk-operations) @@ -201,8 +203,7 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns - 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns -- 189-portfolio-triage-review-state: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns -- 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries diff --git a/specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml b/specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml new file mode 100644 index 00000000..5283fb2b --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml @@ -0,0 +1,501 @@ +openapi: 3.1.0 +info: + title: Baseline Compare Matrix Operator Mode Internal Surface Contract + version: 0.1.0 + summary: Internal logical contract for adaptive operator-density rendering on the existing baseline compare matrix route + description: | + This contract is an internal planning artifact for Spec 191. The affected surface + still renders HTML through Filament and Livewire. The schemas below define the + bounded request-scoped presentation models and staged filter interactions that must + be derivable from existing Spec 190 matrix truth before the operator-density + refactor can render safely. +servers: + - url: /internal +x-baseline-compare-operator-mode-consumers: + - surface: baseline.compare.matrix + sourceFiles: + - apps/platform/app/Filament/Pages/BaselineCompareMatrix.php + - apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php + mustRender: + - reference + - presentation_state + - support_surface_state + - applied_filters + - tenant_summaries + - dense_rows_or_compact_results + - last_updated_at + - auto_refresh_state + mustAccept: + - mode + - policy_type + - state + - severity + - tenant_sort + - subject_sort + - subject_key + mustStage: + - selectedPolicyTypes + - selectedStates + - selectedSeverities +paths: + /admin/baseline-profiles/{profile}/compare-matrix: + get: + summary: Render the existing baseline compare matrix using adaptive operator-density presentation + operationId: viewBaselineCompareOperatorMode + parameters: + - name: profile + in: path + required: true + schema: + type: integer + - name: mode + in: query + required: false + schema: + $ref: '#/components/schemas/PresentationMode' + - name: policy_type + in: query + required: false + schema: + type: array + items: + type: string + - name: state + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/MatrixCellState' + - name: severity + in: query + required: false + schema: + type: array + items: + $ref: '#/components/schemas/FindingSeverity' + - name: tenant_sort + in: query + required: false + schema: + type: string + - name: subject_sort + in: query + required: false + schema: + type: string + - name: subject_key + in: query + required: false + schema: + type: string + responses: + '200': + description: Rendered matrix plus adaptive operator-density read models + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.baseline-compare-operator-mode+json: + schema: + $ref: '#/components/schemas/BaselineCompareOperatorModeBundle' + '403': + description: Actor is in scope but lacks workspace baseline view capability + '404': + description: Workspace or baseline profile is outside actor scope + /internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/apply-filters: + post: + summary: Apply staged heavy filters to the operator-density matrix route + operationId: applyBaselineCompareOperatorFilters + parameters: + - name: workspace + in: path + required: true + schema: + type: integer + - name: profile + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MatrixFilterDraft' + responses: + '200': + description: Updated operator-density bundle using the applied filter state + content: + application/vnd.tenantpilot.baseline-compare-operator-mode+json: + schema: + $ref: '#/components/schemas/BaselineCompareOperatorModeBundle' + '403': + description: Actor is in scope but lacks workspace baseline view capability + '404': + description: Workspace or baseline profile is outside actor scope + /internal/workspaces/{workspace}/baseline-profiles/{profile}/compare-matrix/reset-filters: + post: + summary: Reset staged and applied heavy filters for the operator-density matrix route + operationId: resetBaselineCompareOperatorFilters + parameters: + - name: workspace + in: path + required: true + schema: + type: integer + - name: profile + in: path + required: true + schema: + type: integer + responses: + '200': + description: Updated operator-density bundle with default filter state restored + content: + application/vnd.tenantpilot.baseline-compare-operator-mode+json: + schema: + $ref: '#/components/schemas/BaselineCompareOperatorModeBundle' + '403': + description: Actor is in scope but lacks workspace baseline view capability + '404': + description: Workspace or baseline profile is outside actor scope +components: + schemas: + PresentationMode: + type: string + enum: + - auto + - dense + - compact + MatrixCellState: + type: string + enum: + - match + - differ + - missing + - ambiguous + - not_compared + - stale_result + FindingSeverity: + type: string + enum: + - low + - medium + - high + - critical + FreshnessState: + type: string + enum: + - fresh + - stale + - never_compared + - unknown + TrustLevel: + type: string + enum: + - trustworthy + - limited_confidence + - diagnostic_only + - unusable + AttentionLevel: + type: string + enum: + - aligned + - review + - refresh_recommended + - needs_attention + MatrixReference: + type: object + additionalProperties: false + required: + - baselineProfileId + - baselineProfileName + - referenceState + - assignedTenantCount + - visibleTenantCount + properties: + baselineProfileId: + type: integer + baselineProfileName: + type: string + referenceSnapshotId: + type: + - integer + - 'null' + referenceState: + type: string + assignedTenantCount: + type: integer + visibleTenantCount: + type: integer + MatrixFilterDraft: + type: object + additionalProperties: false + required: + - selectedPolicyTypes + - selectedStates + - selectedSeverities + - tenantSort + - subjectSort + properties: + selectedPolicyTypes: + type: array + items: + type: string + selectedStates: + type: array + items: + $ref: '#/components/schemas/MatrixCellState' + selectedSeverities: + type: array + items: + $ref: '#/components/schemas/FindingSeverity' + tenantSort: + type: string + subjectSort: + type: string + focusedSubjectKey: + type: + - string + - 'null' + MatrixPresentationState: + type: object + additionalProperties: false + required: + - requestedMode + - resolvedMode + - visibleTenantCount + - activeFilterCount + - hasStagedFilterChanges + - autoRefreshActive + - canOverrideMode + properties: + requestedMode: + $ref: '#/components/schemas/PresentationMode' + resolvedMode: + type: string + enum: + - dense + - compact + visibleTenantCount: + type: integer + activeFilterCount: + type: integer + hasStagedFilterChanges: + type: boolean + autoRefreshActive: + type: boolean + lastUpdatedAt: + type: + - string + - 'null' + format: date-time + canOverrideMode: + type: boolean + MatrixTenantSummary: + type: object + additionalProperties: false + required: + - tenantId + - tenantName + - freshnessState + - differingCount + - missingCount + - ambiguousCount + - trustLevel + properties: + tenantId: + type: integer + tenantName: + type: string + freshnessState: + $ref: '#/components/schemas/FreshnessState' + lastComparedAt: + type: + - string + - 'null' + format: date-time + differingCount: + type: integer + missingCount: + type: integer + ambiguousCount: + type: integer + trustLevel: + $ref: '#/components/schemas/TrustLevel' + maxSeverity: + type: + - string + - 'null' + DenseCellView: + type: object + additionalProperties: false + required: + - tenantId + - subjectKey + - state + - freshnessState + - trustLevel + - attentionLevel + properties: + tenantId: + type: integer + subjectKey: + type: string + state: + $ref: '#/components/schemas/MatrixCellState' + freshnessState: + $ref: '#/components/schemas/FreshnessState' + trustLevel: + $ref: '#/components/schemas/TrustLevel' + severity: + type: + - string + - 'null' + attentionLevel: + $ref: '#/components/schemas/AttentionLevel' + reasonSummary: + type: + - string + - 'null' + primaryDrilldownUrl: + type: + - string + - 'null' + secondaryDrilldownUrls: + type: object + additionalProperties: + type: string + DenseSubjectRowView: + type: object + additionalProperties: false + required: + - subjectKey + - displayName + - policyType + - deviationBreadth + - missingBreadth + - ambiguousBreadth + - trustLevel + - cells + properties: + subjectKey: + type: string + displayName: + type: string + policyType: + type: string + baselineExternalId: + type: + - string + - 'null' + deviationBreadth: + type: integer + missingBreadth: + type: integer + ambiguousBreadth: + type: integer + maxSeverity: + type: + - string + - 'null' + trustLevel: + $ref: '#/components/schemas/TrustLevel' + cells: + type: array + items: + $ref: '#/components/schemas/DenseCellView' + CompactSubjectResultView: + type: object + additionalProperties: false + required: + - tenantId + - subjectKey + - displayName + - policyType + - state + - freshnessState + - trustLevel + properties: + tenantId: + type: integer + subjectKey: + type: string + displayName: + type: string + policyType: + type: string + state: + $ref: '#/components/schemas/MatrixCellState' + freshnessState: + $ref: '#/components/schemas/FreshnessState' + trustLevel: + $ref: '#/components/schemas/TrustLevel' + severity: + type: + - string + - 'null' + reasonSummary: + type: + - string + - 'null' + primaryDrilldownUrl: + type: + - string + - 'null' + runUrl: + type: + - string + - 'null' + MatrixSupportSurfaceState: + type: object + additionalProperties: false + required: + - legendMode + - showActiveFilterSummary + - showLastUpdated + - showAutoRefreshHint + - showBlockingRefreshState + properties: + legendMode: + type: string + showActiveFilterSummary: + type: boolean + showLastUpdated: + type: boolean + showAutoRefreshHint: + type: boolean + showBlockingRefreshState: + type: boolean + BaselineCompareOperatorModeBundle: + type: object + additionalProperties: false + required: + - reference + - presentation + - supportSurface + - appliedFilters + - tenantSummaries + properties: + reference: + $ref: '#/components/schemas/MatrixReference' + presentation: + $ref: '#/components/schemas/MatrixPresentationState' + supportSurface: + $ref: '#/components/schemas/MatrixSupportSurfaceState' + appliedFilters: + $ref: '#/components/schemas/MatrixFilterDraft' + tenantSummaries: + type: array + items: + $ref: '#/components/schemas/MatrixTenantSummary' + denseRows: + type: array + items: + $ref: '#/components/schemas/DenseSubjectRowView' + compactResults: + type: array + items: + $ref: '#/components/schemas/CompactSubjectResultView' \ No newline at end of file diff --git a/specs/191-baseline-compare-operator-mode/data-model.md b/specs/191-baseline-compare-operator-mode/data-model.md new file mode 100644 index 00000000..05e75d18 --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/data-model.md @@ -0,0 +1,166 @@ +# Data Model: Baseline Compare Matrix: High-Density Operator Mode + +## Overview + +This follow-up introduces no new persisted entity. It reuses the existing Spec 190 matrix truth and adds derived presentation models for operator density, staged filtering, and non-blocking status cues. + +## Existing Source Truths Reused Without Change + +### Baseline compare truth from Spec 190 + +The following derived or canonical inputs remain authoritative and are not redefined by this spec: + +- workspace-scoped baseline reference truth +- visible tenant summaries +- subject summaries +- subject-by-tenant matrix cells +- compare-start availability and existing drilldown destinations + +This spec changes how those inputs are rendered and interacted with, not how they are computed. + +## New Derived Presentation Models + +### MatrixPresentationState + +**Type**: request-scoped page presentation contract +**Source**: route/query state + visible tenant count + existing run state + +| Field | Type | Notes | +|------|------|-------| +| `requestedMode` | string | `auto`, `dense`, or `compact` from route/query state | +| `resolvedMode` | string | Final mode used for rendering: `dense` or `compact` | +| `visibleTenantCount` | integer | Existing visible-set count from the matrix bundle | +| `activeFilterCount` | integer | Count of currently applied filters | +| `hasStagedFilterChanges` | boolean | Whether filter draft state differs from applied state | +| `autoRefreshActive` | boolean | True when background polling is active because compare work is queued or running | +| `lastUpdatedAt` | datetime or null | Timestamp for the currently rendered matrix data | +| `canOverrideMode` | boolean | Whether the operator may locally switch away from `auto` | + +### MatrixFilterDraft + +**Type**: request-scoped staged filter model +**Source**: page form state only + +| Field | Type | Notes | +|------|------|-------| +| `selectedPolicyTypes` | array | Draft policy-type filter selection | +| `selectedStates` | array | Draft state-group selection | +| `selectedSeverities` | array | Draft severity selection | +| `tenantSort` | string | Current tenant sort choice | +| `subjectSort` | string | Current subject sort choice | +| `focusedSubjectKey` | string or null | Optional current subject focus | + +### DenseSubjectRowView + +**Type**: request-scoped dense-mode row view +**Source**: existing subject summary + existing matrix cells + +| Field | Type | Notes | +|------|------|-------| +| `subjectKey` | string | Stable row key | +| `displayName` | string | Primary row label | +| `policyType` | string | Compact secondary label | +| `baselineExternalId` | string or null | Optional secondary context | +| `deviationBreadth` | integer | Existing subject summary metric | +| `missingBreadth` | integer | Existing subject summary metric | +| `ambiguousBreadth` | integer | Existing subject summary metric | +| `maxSeverity` | string or null | Existing subject summary severity | +| `trustLevel` | string | Existing subject summary trust | +| `cells` | array | One condensed cell per visible tenant | + +### DenseCellView + +**Type**: request-scoped dense-mode cell view +**Source**: existing matrix cell + existing tenant summary freshness + +| Field | Type | Notes | +|------|------|-------| +| `tenantId` | integer | Visible tenant identifier | +| `subjectKey` | string | Subject row key | +| `state` | string | Existing Spec 190 state | +| `freshnessState` | string | Freshness signal shown in compact form | +| `trustLevel` | string | Trust signal shown in compact form | +| `severity` | string or null | Optional attention signal | +| `attentionLevel` | string | Derived presentation label such as `aligned`, `refresh_recommended`, or `needs_attention` | +| `reasonSummary` | string or null | Short secondary explanation for compact reveal surfaces | +| `primaryDrilldownUrl` | string or null | Preferred next follow-up action | +| `secondaryDrilldownUrls` | array | Additional compact follow-up links when available | + +### CompactSubjectResultView + +**Type**: request-scoped single-tenant row view +**Source**: one visible tenant summary + existing matrix cell + existing subject summary + +| Field | Type | Notes | +|------|------|-------| +| `tenantId` | integer | The single visible tenant in compact mode | +| `subjectKey` | string | Stable subject key | +| `displayName` | string | Primary subject label | +| `policyType` | string | Secondary grouping/context | +| `state` | string | Existing Spec 190 state | +| `freshnessState` | string | Compact freshness label | +| `trustLevel` | string | Compact trust label | +| `severity` | string or null | Optional attention indicator | +| `reasonSummary` | string or null | Short explanation line | +| `primaryDrilldownUrl` | string or null | Main follow-up action | +| `runUrl` | string or null | Secondary run-level follow-up | + +### MatrixSupportSurfaceState + +**Type**: request-scoped supporting-context contract +**Source**: page state + existing legends + refresh metadata + +| Field | Type | Notes | +|------|------|-------| +| `legendMode` | string | `grouped`, `collapsed`, or equivalent compact support behavior | +| `showActiveFilterSummary` | boolean | Whether applied filters are summarized inline | +| `showLastUpdated` | boolean | Whether the page displays last-updated metadata | +| `showAutoRefreshHint` | boolean | Whether passive auto-refresh copy is visible | +| `showBlockingRefreshState` | boolean | Reserved for deliberate user-triggered reloads only | + +## Rendering and Resolution Rules + +### Mode resolution rules + +1. If `requestedMode = auto` and `visibleTenantCount > 1`, resolve to `dense`. +2. If `requestedMode = auto` and `visibleTenantCount = 1`, resolve to `compact`. +3. If a manual override is present, use it unless it would produce an invalid empty layout. +4. Manual override remains route-local and must never be persisted as product truth. + +### Dense-mode rules + +- The subject column remains sticky during horizontal scroll. +- The primary visible content per cell is state, trust, freshness, and attention. +- Long explanatory text and repeated action links do not render as the dominant cell body. + +### Compact single-tenant rules + +- The tenant header does not repeat as a pseudo-column structure. +- Each subject entry shows one primary status line and a reduced set of secondary metadata. +- Existing subject focus and drilldown continuity remain available. + +### Filter workflow rules + +- Heavy multi-select filters use staged state first and apply only when the operator confirms. +- Applied filter count and scope summary reflect the applied state, not merely the draft state. +- Reset may clear both draft and applied state in one explicit action. + +### Status signal rules + +- `blocking refresh` is reserved for deliberate user-triggered reload or recalculation moments. +- `auto-refresh active` indicates passive polling while compare work is still queued or running. +- `lastUpdatedAt` reflects the timestamp of the rendered matrix payload, not merely the latest compare run in the system. + +### Safety rules + +- No rendering path may widen tenant visibility beyond the existing visible set. +- No presentation-state change may change the underlying compare state, trust, or freshness semantics. +- No grouped legend or compact cell may invent new status vocabulary outside existing centralized badge semantics. + +## Relationships + +- One `MatrixPresentationState` governs one rendered matrix page. +- One `MatrixFilterDraft` belongs to one `MatrixPresentationState`. +- In dense mode, one `DenseSubjectRowView` maps to many `DenseCellView` entries. +- In compact mode, one visible tenant yields many `CompactSubjectResultView` entries. +- One `MatrixSupportSurfaceState` coordinates legends, refresh hints, and active-filter summaries for the same page render. \ No newline at end of file diff --git a/specs/191-baseline-compare-operator-mode/plan.md b/specs/191-baseline-compare-operator-mode/plan.md index 34499099..a4d8de51 100644 --- a/specs/191-baseline-compare-operator-mode/plan.md +++ b/specs/191-baseline-compare-operator-mode/plan.md @@ -3,64 +3,108 @@ # Implementation Plan: Baseline Compare Matrix: High-Density Operator Mode **Branch**: `191-baseline-compare-operator-mode` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md` **Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/spec.md` +**Note**: This plan formalizes the existing 191 spec slice and keeps the work strictly inside the already-shipped Spec 190 matrix surface. + ## Summary -Rework the existing baseline compare matrix route into an operator-density follow-up to Spec 190. The route stays workspace-scoped and fully derived, but gains adaptive presentation rules: dense multi-tenant scanning when several visible tenants are present, compact single-tenant comparison when only one visible tenant remains, and calmer filter, legend, action, and refresh surfaces. +Refactor the existing workspace baseline compare matrix into an adaptive operator-density surface. The route, baseline reference, visible-set-only truth, compare-start behavior, and drilldowns stay unchanged, but the page gains local presentation-mode state, dense multi-tenant scanning, compact single-tenant rendering, staged heavy-filter application, grouped legends, and clearer separation between blocking refresh, passive auto-refresh, and last-updated status. ## Technical Context **Language/Version**: PHP 8.4.15 -**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns -**Storage**: Existing PostgreSQL truth only; no new tables or artifacts -**Testing**: Pest feature tests and one browser smoke path through Sail +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns +**Storage**: PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned +**Testing**: Pest feature tests and browser smoke coverage run through Laravel Sail **Target Platform**: Laravel monolith web application under `apps/platform` **Project Type**: web application -**Performance Goals**: Improve operator scan throughput without adding more data queries than Spec 190; keep heavy filter changes explicit rather than chatty -**Constraints**: No compare-logic changes, no new persistence, no hidden-tenant leakage, no generalized UI framework, no Filament provider changes -**Scale/Scope**: One existing matrix page, one existing view, one existing builder, and focused test coverage updates +**Performance Goals**: Improve scan throughput without increasing query shape beyond Spec 190, keep heavy filter changes non-chatty, and preserve DB-only render-time matrix surfaces +**Constraints**: No compare-logic change, no new persistence, no hidden-tenant leakage, no generalized density framework, no provider or panel changes, and no new asset pipeline +**Scale/Scope**: One existing matrix page, one existing Blade view, one existing builder, one logical contract file, and focused feature plus browser regressions ## Constitution Check -*GATE: Passed before design. No new source-of-truth or persistence changes are expected.* +*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.* -| Principle | Status | Notes | -|-----------|--------|-------| -| Inventory-first / snapshots-second | PASS | The feature changes presentation only and keeps Spec 190 truth sources intact. | -| Read/write separation | PASS | `Compare assigned tenants` remains the only mutation and already exists. | -| Workspace + tenant isolation | PASS | Visible-set-only behavior remains unchanged. | -| RBAC-UX | PASS | Existing `404` vs `403` semantics stay intact; only presentation changes. | -| Ops-UX 3-surface feedback | PASS | Refresh and polling surfaces are clarified visually without changing run semantics. | -| Proportionality / anti-bloat | PASS | No new persistence, enum, framework, or cross-domain abstraction is introduced. | -| UI semantics / few layers | PASS | Dense and compact modes reuse existing badge and compare semantics rather than inventing new status taxonomies. | -| Filament v5 / Livewire v4 compliance | PASS | Work remains on the existing Filament page and Livewire-backed route. | -| Provider registration location | PASS | No provider changes; registration remains in `bootstrap/providers.php`. | -| Global search hard rule | PASS | No new global-searchable resource or page is introduced. | -| Destructive action safety | PASS | No destructive action is added by this spec. | -| Asset strategy | PASS | No new panel assets or shared assets are required. Existing deployment use of `filament:assets` remains unchanged. | +| Principle | Pre-Research | Post-Design | Notes | +|-----------|--------------|-------------|-------| +| Inventory-first / snapshots-second | PASS | PASS | The spec changes presentation only and keeps Spec 190 truth sources intact. | +| Read/write separation | PASS | PASS | `Compare assigned tenants` remains the only mutation and is unchanged. | +| Graph contract path | N/A | N/A | No new Graph behavior or contract-registry work is introduced. | +| Deterministic capabilities | PASS | PASS | Existing capabilities remain canonical and unchanged. | +| Workspace + tenant isolation | PASS | PASS | Visible-set-only aggregation and drilldown scope remain unchanged. | +| RBAC-UX authorization semantics | PASS | PASS | Existing `404` vs `403` semantics and server-side enforcement remain unchanged. | +| Run observability / Ops-UX | PASS | PASS | Compare-run truth is reused exactly as in Spec 190; this spec only clarifies the visual cues around it. | +| Data minimization | PASS | PASS | No new data copies, exports, or persisted UI artifacts are introduced. | +| Proportionality / anti-bloat | PASS | PASS | The work stays local to one page and does not add a new abstraction or stored artifact. | +| Persisted truth / behavioral state | PASS | PASS | Presentation mode and staged filter state remain request-scoped only. | +| UI semantics / few layers | PASS | PASS | Existing state, trust, freshness, and severity semantics are reused rather than redefined. | +| Filament v5 / Livewire v4 compliance | PASS | PASS | The work remains inside the existing Filament page and Livewire-backed route. | +| Provider registration location | PASS | PASS | No provider changes are required; Laravel 11+ registration remains in `bootstrap/providers.php`. | +| Global search hard rule | PASS | PASS | No new searchable resource or page is introduced. | +| Destructive action safety | PASS | PASS | No destructive action is added. Existing confirmation behavior for compare-start remains unchanged. | +| Asset strategy | PASS | PASS | No new assets are required. Existing deployment behavior for `cd apps/platform && php artisan filament:assets` remains unchanged. | ## Filament-Specific Compliance Notes -- **Livewire v4.0+ compliance**: This plan stays on the existing Filament v5 + Livewire v4 page stack and does not introduce legacy APIs. -- **Provider registration location**: No panel/provider work is needed. Laravel 11+ provider registration remains in `bootstrap/providers.php`. -- **Global search**: This spec does not add a new globally searchable resource. Existing baseline-resource search behavior is unchanged. +- **Livewire v4.0+ compliance**: This plan remains on Filament v5 + Livewire v4 and does not introduce legacy APIs. +- **Provider registration location**: No panel or provider changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. +- **Global search**: The feature does not add a new globally searchable resource. Existing baseline-resource search behavior is unchanged. - **Destructive actions**: No new destructive action is introduced. Existing compare-start actions remain confirmation-gated where already defined. - **Asset strategy**: No new global or on-demand asset registration is planned. Deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged. -- **Testing plan**: Extend the existing matrix feature and browser suites to cover presentation mode, density, compact controls, and non-blocking status surfaces. +- **Testing plan**: Extend the existing matrix feature, builder, guard, and browser suites to cover presentation mode, staged filter application, and non-blocking status surfaces. + +## Phase 0 Research + +Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/research.md`. + +Key decisions: + +- Keep the existing matrix route and truth model and change presentation only. +- Resolve `auto`, `dense`, and `compact` mode from visible tenant count, with a route-local override only. +- Make dense mode state-first rather than action-first. +- Render single-tenant review as a compact compare list rather than a one-column matrix. +- Convert heavy filters to staged apply/reset semantics. +- Replace the long policy-type checkbox stack with a more compact operator-first selector. +- Group legends into compact support context and separate blocking refresh from passive auto-refresh and last-updated cues. +- Reuse existing drilldown and visible-set semantics unchanged. + +## Phase 1 Design + +Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/191-baseline-compare-operator-mode/`: + +- `research.md`: decisions and rejected alternatives for local operator-density work +- `data-model.md`: request-scoped presentation models for mode state, staged filters, dense rows, compact results, and support-surface state +- `contracts/baseline-compare-operator-mode.logical.openapi.yaml`: internal logical contract for adaptive rendering and staged filter application +- `quickstart.md`: implementation and verification sequence for the follow-up spec + +Design decisions: + +- `auto` remains the default requested mode and resolves to `dense` for multiple visible tenants and `compact` for exactly one visible tenant. +- Manual mode override remains route-local and must never become stored product truth. +- Dense mode reuses existing compare truth but condenses cell content to state, trust, freshness, and attention. +- Compact mode reuses the same truth but removes pseudo-matrix structure once only one visible tenant remains. +- Heavy filter inputs stage locally and apply explicitly; lightweight route-state changes may remain immediate. +- Grouped legends, passive auto-refresh, and last-updated signals become support context rather than competing top-level content. ## Project Structure -### Documentation +### Documentation (this feature) ```text specs/191-baseline-compare-operator-mode/ -├── spec.md ├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── spec.md ├── tasks.md +├── contracts/ +│ └── baseline-compare-operator-mode.logical.openapi.yaml └── checklists/ └── requirements.md ``` -### Source Code +### Source Code (repository root) ```text apps/platform/ @@ -77,84 +121,80 @@ ### Source Code ├── Browser/ │ └── Spec190BaselineCompareMatrixSmokeTest.php ├── Feature/ + │ ├── Baselines/ + │ │ └── BaselineCompareMatrixBuilderTest.php │ ├── Filament/ │ │ └── BaselineCompareMatrixPageTest.php │ └── Guards/ │ └── ActionSurfaceContractTest.php - └── Feature/Baselines/ - └── BaselineCompareMatrixBuilderTest.php + └── Unit/ + └── Badges/ ``` -**Structure Decision**: Keep the work inside the existing Spec 190 implementation surface. This follow-up spec is a refactor of one page and its supporting builder/view behavior, not a new domain slice. +**Structure Decision**: Keep the work inside the existing Spec 190 matrix implementation surface. This is a presentation refactor of one existing page and its supporting builder/view behavior, not a new domain slice or a new application area. -## Key Design Decisions +## Complexity Tracking -### D-001 — Keep the route and truth model unchanged +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | N/A | The follow-up stays within the existing page, builder, and test surfaces and introduces no new structural violation. | -This spec modifies the existing `/compare-matrix` route only. No second route, second matrix artifact, or separate dense-report model is created. +## Proportionality Review -### D-002 — Use adaptive presentation, not separate features - -`auto` mode is the canonical default. `dense` and `compact` exist as local operator overrides, but the product concept remains one matrix page with adaptive presentation. - -### D-003 — Keep dense cells state-first - -Dense mode cells must prioritize compare state, trust, freshness, and attention. Detailed reasons and multiple navigation targets become secondary reveals rather than permanent default chrome. - -### D-004 — Treat controls as supporting context - -Filters, legends, and refresh hints remain available but must become visibly subordinate to the matrix body. The page should read as a working surface, not a form-first screen. - -### D-005 — Keep single-tenant mode honest - -If only one visible tenant remains, the operator should see a compact comparison surface rather than an artificially wide matrix. The page should not preserve multi-tenant structure when it no longer helps. +- **Current operator problem**: The matrix already answers the right governance question, but not with enough density or calmness for repeated operator scanning. +- **Existing structure is insufficient because**: The current single rendering shape tries to serve both multi-tenant and single-tenant workflows, so supporting context, action repetition, and cell chrome are too heavy in both cases. +- **Narrowest correct implementation**: Keep the same route, truth sources, drilldowns, and compare semantics while adding route-local presentation state, denser rendering, and staged filter application. +- **Ownership cost created**: Additional view-state logic, a logical contract file, and focused regression coverage for mode resolution, filter workflow, and status visibility. +- **Alternative intentionally rejected**: A generalized density framework, a separate dense-report route, or a stored matrix artifact were rejected because the problem is local to the existing matrix surface. +- **Release truth**: current-release operator workflow compression ## Implementation Strategy ### Phase A — Presentation Mode Contract -- Add `auto`, `dense`, and `compact` mode state to the page. -- Keep override state local to the route and compatible with existing drilldown URLs. -- Reuse the current derived matrix bundle instead of adding a second persisted view model. +- Add route-local `auto`, `dense`, and `compact` mode state. +- Resolve the active mode from visible tenant count unless manually overridden. +- Expose `lastUpdatedAt`, `hasStagedFilterChanges`, and passive auto-refresh state to the page. ### Phase B — Dense Multi-Tenant Surface -- Reduce per-cell chrome and prioritize state/trust/freshness. -- Keep the subject axis sticky and readable across horizontal scroll. -- Move repeated actions into compact secondary affordances where necessary. +- Keep the subject column sticky during horizontal scroll. +- Condense dense cells to state, trust, freshness, and attention signals. +- Move repeated actions into compact secondary affordances without breaking drilldown continuity. ### Phase C — Compact Single-Tenant Surface -- Replace pseudo-matrix presentation with a shorter, calmer list optimized for one visible tenant. -- Remove repeated tenant headers and duplicated labels. -- Preserve subject focus and drilldowns. +- Replace pseudo-matrix rendering with a compact subject-result list when only one visible tenant remains. +- Remove repeated tenant headers and duplicated secondary metadata. +- Preserve subject focus and the existing compare/finding/run destinations. ### Phase D — Supporting Context Compression -- Convert heavy filters to an apply/reset workflow. -- Compress legends into grouped or collapsible supporting blocks. -- Clarify background polling, manual refresh, and last-updated status without using blocking loading surfaces. +- Convert heavy matrix filters to staged apply/reset behavior. +- Replace the current long policy-type control with a more compact selector. +- Group or collapse legends. +- Separate blocking refresh from passive auto-refresh and last-updated status. ### Phase E — Verification -- Extend feature coverage for mode selection and density rules. -- Extend browser coverage for one dense-mode path and one compact-mode path. -- Keep existing Spec 190 truth and RBAC guarantees intact. +- Extend focused feature coverage for mode resolution, staged filter behavior, and support-surface state. +- Extend browser smoke coverage for one dense-mode path and one compact-mode path. +- Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green. -## Risks & Mitigations +## Risk Assessment | Risk | Impact | Likelihood | Mitigation | |------|--------|------------|------------| -| Dense mode becomes another framework | Medium | Low | Keep presentation logic local to the matrix page and view. | -| Compact mode hides too much drilldown value | Medium | Medium | Keep one clear follow-up path per subject and preserve existing drilldowns. | -| Apply/reset feels stale compared with live filters | Medium | Medium | Make staged filter state obvious and keep reset immediate. | -| Manual override confuses operators | Low | Medium | Keep `auto` as default and label override state clearly. | +| Dense mode becomes another framework | Medium | Low | Keep presentation logic local to the matrix page and avoid generalized shared abstractions. | +| Compact mode hides too much follow-up value | Medium | Medium | Preserve one clear primary drilldown per subject and keep existing follow-up destinations intact. | +| Staged filtering feels slow or unclear | Medium | Medium | Show explicit staged/applied state and keep reset obvious. | +| Manual override confuses operators | Low | Medium | Keep `auto` as the default and surface the resolved mode clearly. | +| Last-updated and auto-refresh cues drift out of sync | Medium | Low | Derive both cues from the same rendered matrix payload and active-run state. | ## Test Strategy -- Extend feature tests for mode resolution based on visible tenant count. -- Add assertions for dense multi-tenant sticky subject behavior and reduced visible action noise. -- Add assertions for compact single-tenant rendering and shorter supporting chrome. -- Add coverage for explicit filter apply/reset behavior, grouped legends, and page-level last-updated messaging. -- Reuse existing browser smoke coverage and extend it for one dense path plus one compact-mode path. -- Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and the focused matrix-related Pest suite before sign-off. \ No newline at end of file +- Extend `BaselineCompareMatrixPageTest` for requested vs resolved mode, active filter application, compact vs dense rendering, and non-blocking refresh cues. +- Extend `BaselineCompareMatrixBuilderTest` for any new derived presentation metadata required by the page. +- Keep `ActionSurfaceContractTest` green so calmer actions do not regress the surface contract. +- Extend `Spec190BaselineCompareMatrixSmokeTest` to prove one dense-mode and one compact-mode operator path on the Livewire page. +- Run the focused Sail verification pack from `quickstart.md` and re-run `update-agent-context.sh copilot` after the plan is finalized. diff --git a/specs/191-baseline-compare-operator-mode/quickstart.md b/specs/191-baseline-compare-operator-mode/quickstart.md new file mode 100644 index 00000000..359a49d2 --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/quickstart.md @@ -0,0 +1,70 @@ +# Quickstart: Baseline Compare Matrix: High-Density Operator Mode + +## Goal + +Turn the existing baseline compare matrix into a denser operator surface without changing its underlying compare truth. Multi-tenant use should favor high-density cross-tenant scanning, while single-tenant use should collapse into a calmer compact comparison view. + +## Implementation Sequence + +1. Add page-level presentation state. + - Add `auto`, `dense`, and `compact` route-local mode state. + - Resolve the active mode from visible tenant count unless the operator explicitly overrides it. + - Expose `lastUpdatedAt`, staged-filter state, and passive auto-refresh state on the page. + +2. Build the dense multi-tenant rendering contract. + - Keep the subject column sticky. + - Reduce dense-cell chrome to state, trust, freshness, and attention. + - Move repeated follow-up links into compact secondary affordances. + +3. Build the compact single-tenant rendering contract. + - Replace the pseudo-matrix layout with a compact subject-result list. + - Remove repeated tenant headers and repeated metadata blocks. + - Preserve subject focus and existing drilldowns. + +4. Compress supporting context. + - Convert heavy filters to staged apply/reset semantics. + - Replace the current long policy-type list with a more compact operator-first control. + - Group or collapse legends so they remain available without dominating the page. + - Separate blocking refresh from passive auto-refresh and last-updated status. + +5. Extend regression coverage. + - Cover mode resolution, dense multi-tenant layout, compact single-tenant layout, staged filters, and non-blocking refresh cues. + - Keep existing Spec 190 matrix truth, drilldown continuity, and RBAC guarantees green. + +## Suggested Test Files + +- `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` +- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` +- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` + +## Minimum Verification Commands + +Run all commands through Sail from `apps/platform`. + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +## Manual Acceptance Checklist + +1. Open a baseline profile whose matrix has multiple visible tenants and confirm `auto` resolves to dense mode. +2. Verify the first subject column remains visible while horizontally scrolling dense mode. +3. Confirm dense cells foreground compare state, trust, freshness, and attention before links or long prose. +4. Open a matrix that resolves to one visible tenant and confirm `auto` resolves to compact mode instead of a one-column matrix. +5. Change heavy filters and confirm the page stages those changes until the operator applies them. +6. Confirm active filter count and filter summary reflect the applied state clearly. +7. Confirm legends are still understandable but no longer dominate the top of the page. +8. Trigger or observe queued/running compare work and confirm passive auto-refresh does not look like a permanent blocking load. +9. Confirm the page shows when the current matrix payload was last updated. +10. Verify tenant compare, finding, and run drilldowns still preserve the existing matrix context. + +## Deployment Notes + +- No migration is expected. +- No new asset registration is expected. +- No queue topology change is expected because compare execution semantics stay unchanged. \ No newline at end of file diff --git a/specs/191-baseline-compare-operator-mode/research.md b/specs/191-baseline-compare-operator-mode/research.md new file mode 100644 index 00000000..76f5fc40 --- /dev/null +++ b/specs/191-baseline-compare-operator-mode/research.md @@ -0,0 +1,111 @@ +# Research: Baseline Compare Matrix: High-Density Operator Mode + +## Decision: Keep the existing matrix route and truth model, and change presentation only + +### Rationale + +Spec 190 already established the correct workspace route, the correct baseline reference model, and the correct visible-set-only compare truth. The operator-density follow-up should stay on `/admin/baseline-profiles/{record}/compare-matrix` and must not introduce a second route, a second report artifact, or a second source of matrix truth. + +### Alternatives considered + +- Add a separate `dense report` page: rejected because it would duplicate the same baseline-scoped workflow on a second route. +- Add a stored matrix snapshot: rejected because the operator problem is scan efficiency, not missing persistence. + +## Decision: Resolve presentation mode from visible tenant count, with a local override only + +### Rationale + +The core operator split is real: one visible tenant is a compact review problem, while several visible tenants create a cross-tenant scan problem. The narrowest implementation is one requested mode (`auto`, `dense`, or `compact`) and one resolved mode at render time. `auto` should remain the default, while manual override stays local to the matrix route and must not become stored user preference or domain truth. + +### Alternatives considered + +- Separate feature flags or separate navigation entries for each mode: rejected because the matrix should remain one operator surface. +- Persist mode preference per user: rejected because the current need is local workflow control, not profile-level personalization. + +## Decision: Dense mode must be state-first, not action-first + +### Rationale + +In multi-tenant reading, the primary questions are where drift exists, how severe it is, whether the signal is trustworthy, and what deserves follow-up next. Dense cells should therefore foreground compare state, trust, freshness, and attention, while detailed reasons and repeated links move into compact secondary affordances. + +### Alternatives considered + +- Keep the current repeated open-link pattern in every cell: rejected because repeated actions visually outrank the state being scanned. +- Remove cell-level follow-up completely: rejected because the matrix must remain a decision surface, not a dead-end report. + +## Decision: Single-tenant mode should be a compact compare list, not a one-column matrix + +### Rationale + +Once only one visible tenant remains, the value of cross-tenant columns disappears. The surface should switch to a shorter subject-result list that reuses the same truth but removes repeated tenant headers, empty width, and oversized cell chrome. + +### Alternatives considered + +- Reuse dense mode even for one tenant: rejected because it preserves the wrong reading model. +- Route single-tenant viewing away to the tenant compare page: rejected because the operator still started from the workspace baseline matrix context and should not lose that context automatically. + +## Decision: Heavy filters should use staged apply/reset semantics + +### Rationale + +The current matrix is dense enough that chatty recomputation on every multi-select click works against operator flow. Policy types and other heavy matrix filters should stage changes locally, show that staged state clearly, and apply them deliberately. This improves calmness and makes the surface feel less like a form page. + +### Alternatives considered + +- Keep all filters live: rejected because heavy multi-select controls create noisy redraw behavior. +- Convert every filter to manual apply: rejected because lightweight interactions such as mode switching or focused-subject clearing should remain immediate. + +## Decision: Replace the long policy-type checkbox stack with a more compact operator-first selector + +### Rationale + +The policy-type filter is the most visually expensive control on the page. The follow-up spec should use a denser selection pattern such as searchable multi-select, type-to-find, or another compact control that exposes the same filter truth without the current long vertical list. + +### Alternatives considered + +- Keep the long checkbox list and only restyle it: rejected because vertical space is the actual product problem. +- Hide policy type filtering behind a modal by default: rejected because the filter remains core enough to deserve immediate access. + +## Decision: Legends should become grouped support context, optionally collapsible + +### Rationale + +State, freshness, and trust legends remain semantically valuable, especially for onboarding or occasional operators, but they should no longer compete with the matrix for top-of-screen attention. Grouped, compact legend blocks are the narrowest way to preserve semantics while reducing dominance. + +### Alternatives considered + +- Remove legends entirely: rejected because trust and freshness semantics still need an on-page reference. +- Leave three separate full-width legend sections: rejected because they displace the primary working surface. + +## Decision: Separate loading, auto-refresh, and last-updated cues + +### Rationale + +Spec 190 already exposed the risk of background polling reading like permanent blocking load. This follow-up should make three states explicit: active loading for user-triggered refresh, passive auto-refresh while queued or running compare work exists, and last-updated time for the currently rendered matrix. + +### Alternatives considered + +- Reuse one generic refresh chip for all states: rejected because operators cannot tell whether the page is blocked or simply polling. +- Hide refresh state entirely: rejected because operator trust depends on understanding when the matrix is current. + +## Decision: Reuse the existing drilldown and visible-set semantics without change + +### Rationale + +This spec is a presentation refactor, not a navigation or authorization redesign. The existing tenant compare, finding, run-detail, and canonical-navigation context from Spec 190 remain correct and should carry forward unchanged. + +### Alternatives considered + +- Introduce a dense-mode-specific drilldown model: rejected because it would create new behavior where existing follow-up paths are already sufficient. +- Add aggregated hidden-tenant remainder summaries: rejected because visible-set-only semantics explicitly avoid hidden-tenant leakage. + +## Decision: Validate primarily with focused page, builder, guard, and browser coverage + +### Rationale + +The highest-risk changes are mode resolution, dense-cell hierarchy, compact single-tenant rendering, filter apply behavior, and non-blocking refresh cues. These are best covered with focused feature tests plus one browser smoke path for the interactive Livewire surface. + +### Alternatives considered + +- Browser-test every combination exhaustively: rejected because most of the behavior is deterministic and cheaper to validate through feature tests. +- Limit validation to visual inspection: rejected because mode resolution and filter workflow are important enough to guard in CI. \ No newline at end of file diff --git a/specs/191-baseline-compare-operator-mode/tasks.md b/specs/191-baseline-compare-operator-mode/tasks.md index 1c9ed423..94937fb5 100644 --- a/specs/191-baseline-compare-operator-mode/tasks.md +++ b/specs/191-baseline-compare-operator-mode/tasks.md @@ -1,7 +1,7 @@ # Tasks: Baseline Compare Matrix: High-Density Operator Mode **Input**: Design documents from `/specs/191-baseline-compare-operator-mode/` -**Prerequisites**: `plan.md`, `spec.md` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md` **Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route. **Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, no new run-summary contract, and no new notification channel should be introduced. -- 2.45.2 From 5914ec3e3e4823de429e320d2dc6ac4d159a7e41 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 11 Apr 2026 17:31:41 +0200 Subject: [PATCH 3/5] feat: add baseline compare operator modes --- .../Filament/Pages/BaselineCompareMatrix.php | 306 ++++- .../BaselineCompareMatrixBuilder.php | 123 +- .../pages/baseline-compare-matrix.blade.php | 1079 +++++++++++------ .../Spec190BaselineCompareMatrixSmokeTest.php | 132 +- .../BaselineCompareMatrixBuilderTest.php | 103 +- .../BaselineCompareMatrixPageTest.php | 152 ++- ...compare-operator-mode.logical.openapi.yaml | 20 +- .../spec.md | 29 +- .../tasks.md | 142 ++- 9 files changed, 1491 insertions(+), 595 deletions(-) diff --git a/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php index b6cc5a97..c871afd3 100644 --- a/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php +++ b/apps/platform/app/Filament/Pages/BaselineCompareMatrix.php @@ -23,7 +23,6 @@ use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\Action; -use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Select; use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Contracts\HasForms; @@ -48,6 +47,8 @@ class BaselineCompareMatrix extends Page implements HasForms protected string $view = 'filament.pages.baseline-compare-matrix'; + public string $requestedMode = 'auto'; + /** * @var list */ @@ -69,6 +70,25 @@ class BaselineCompareMatrix extends Page implements HasForms public ?string $focusedSubjectKey = null; + /** + * @var list + */ + public array $draftSelectedPolicyTypes = []; + + /** + * @var list + */ + public array $draftSelectedStates = []; + + /** + * @var list + */ + public array $draftSelectedSeverities = []; + + public string $draftTenantSort = 'tenant_name'; + + public string $draftSubjectSort = 'deviation_breadth'; + /** * @var array */ @@ -107,33 +127,40 @@ public function form(Schema $schema): Schema 'lg' => 5, ]) ->schema([ - CheckboxList::make('selectedPolicyTypes') + Select::make('draftSelectedPolicyTypes') ->label('Policy types') ->options(fn (): array => $this->matrixOptions('policyTypeOptions')) + ->multiple() + ->searchable() + ->preload() + ->native(false) + ->placeholder('All policy types') ->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') + ]), + Select::make('draftSelectedStates') ->label('Technical states') ->options(fn (): array => $this->matrixOptions('stateOptions')) + ->multiple() + ->searchable() + ->native(false) + ->placeholder('All technical states') ->columnSpan([ 'lg' => 2, - ]) - ->columns(1) - ->live(), - CheckboxList::make('selectedSeverities') + ]), + Select::make('draftSelectedSeverities') ->label('Severity') ->options(fn (): array => $this->matrixOptions('severityOptions')) - ->columns(1) - ->live(), + ->multiple() + ->searchable() + ->native(false) + ->placeholder('All severities'), ]) ->columnSpan([ 'xl' => 1, @@ -144,20 +171,18 @@ public function form(Schema $schema): Schema 'xl' => 1, ]) ->schema([ - Select::make('tenantSort') + Select::make('draftTenantSort') ->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') + Select::make('draftSubjectSort') ->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']), ]) @@ -232,6 +257,34 @@ protected function getHeaderActions(): array ]; } + public function applyFilters(): void + { + $this->selectedPolicyTypes = $this->normalizeQueryList($this->draftSelectedPolicyTypes); + $this->selectedStates = $this->normalizeQueryList($this->draftSelectedStates); + $this->selectedSeverities = $this->normalizeQueryList($this->draftSelectedSeverities); + $this->tenantSort = $this->normalizeTenantSort($this->draftTenantSort); + $this->subjectSort = $this->normalizeSubjectSort($this->draftSubjectSort); + + $this->redirect($this->filterUrl(), navigate: true); + } + + public function resetFilters(): void + { + $this->selectedPolicyTypes = []; + $this->selectedStates = []; + $this->selectedSeverities = []; + $this->tenantSort = 'tenant_name'; + $this->subjectSort = 'deviation_breadth'; + $this->focusedSubjectKey = null; + $this->draftSelectedPolicyTypes = []; + $this->draftSelectedStates = []; + $this->draftSelectedSeverities = []; + $this->draftTenantSort = 'tenant_name'; + $this->draftSubjectSort = 'deviation_breadth'; + + $this->redirect($this->filterUrl(), navigate: true); + } + public function refreshMatrix(): void { $user = auth()->user(); @@ -307,41 +360,18 @@ public function clearSubjectFocusUrl(): string ]), panel: 'admin'); } + public function modeUrl(string $mode): string + { + return $this->filterUrl([ + 'mode' => $this->normalizeRequestedMode($mode), + ]); + } + public function filterUrl(array $overrides = []): string { return static::getUrl($this->routeParameters($overrides), panel: 'admin'); } - public function updatedSelectedPolicyTypes(): void - { - $this->refreshMatrix(); - } - - public function updatedSelectedStates(): void - { - $this->refreshMatrix(); - } - - public function updatedSelectedSeverities(): void - { - $this->refreshMatrix(); - } - - public function updatedTenantSort(): void - { - $this->refreshMatrix(); - } - - public function updatedSubjectSort(): void - { - $this->refreshMatrix(); - } - - public function updatedFocusedSubjectKey(): void - { - $this->refreshMatrix(); - } - public function activeFilterCount(): int { return count($this->selectedPolicyTypes) @@ -350,6 +380,25 @@ public function activeFilterCount(): int + ($this->focusedSubjectKey !== null ? 1 : 0); } + public function hasStagedFilterChanges(): bool + { + return $this->draftFilterState() !== $this->appliedFilterState(); + } + + public function canUseCompactMode(): bool + { + return $this->visibleTenantCount() <= 1; + } + + public function presentationModeLabel(string $mode): string + { + return match ($mode) { + 'dense' => 'Dense mode', + 'compact' => 'Compact mode', + default => 'Auto mode', + }; + } + /** * @return array */ @@ -376,6 +425,36 @@ public function activeFilterSummary(): array return $summary; } + /** + * @return array + */ + public function stagedFilterSummary(): array + { + $summary = []; + + if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) { + $summary['Policy types'] = count($this->draftSelectedPolicyTypes); + } + + if ($this->draftSelectedStates !== $this->selectedStates) { + $summary['Technical states'] = count($this->draftSelectedStates); + } + + if ($this->draftSelectedSeverities !== $this->selectedSeverities) { + $summary['Severity'] = count($this->draftSelectedSeverities); + } + + if ($this->draftTenantSort !== $this->tenantSort) { + $summary['Tenant sort'] = $this->draftTenantSort; + } + + if ($this->draftSubjectSort !== $this->subjectSort) { + $summary['Subject sort'] = $this->draftSubjectSort; + } + + return $summary; + } + /** * @return array */ @@ -384,6 +463,7 @@ protected function getViewData(): array return array_merge($this->matrix, [ 'profile' => $this->getRecord(), 'currentFilters' => [ + 'mode' => $this->requestedMode, 'policy_type' => $this->selectedPolicyTypes, 'state' => $this->selectedStates, 'severity' => $this->selectedSeverities, @@ -391,18 +471,32 @@ protected function getViewData(): array 'subject_sort' => $this->subjectSort, 'subject_key' => $this->focusedSubjectKey, ], + 'draftFilters' => [ + 'policy_type' => $this->draftSelectedPolicyTypes, + 'state' => $this->draftSelectedStates, + 'severity' => $this->draftSelectedSeverities, + 'tenant_sort' => $this->draftTenantSort, + 'subject_sort' => $this->draftSubjectSort, + ], + 'presentationState' => $this->presentationState(), ]); } private function hydrateFiltersFromRequest(): void { + $this->requestedMode = $this->normalizeRequestedMode(request()->query('mode', 'auto')); $this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', [])); $this->selectedStates = $this->normalizeQueryList(request()->query('state', [])); $this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', [])); - $this->tenantSort = is_string(request()->query('tenant_sort')) ? (string) request()->query('tenant_sort') : 'tenant_name'; - $this->subjectSort = is_string(request()->query('subject_sort')) ? (string) request()->query('subject_sort') : 'deviation_breadth'; + $this->tenantSort = $this->normalizeTenantSort(request()->query('tenant_sort', 'tenant_name')); + $this->subjectSort = $this->normalizeSubjectSort(request()->query('subject_sort', 'deviation_breadth')); $subjectKey = request()->query('subject_key'); $this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null; + $this->draftSelectedPolicyTypes = $this->selectedPolicyTypes; + $this->draftSelectedStates = $this->selectedStates; + $this->draftSelectedSeverities = $this->selectedSeverities; + $this->draftTenantSort = $this->tenantSort; + $this->draftSubjectSort = $this->subjectSort; } /** @@ -411,11 +505,11 @@ private function hydrateFiltersFromRequest(): void private function filterFormState(): array { return [ - 'selectedPolicyTypes' => $this->selectedPolicyTypes, - 'selectedStates' => $this->selectedStates, - 'selectedSeverities' => $this->selectedSeverities, - 'tenantSort' => $this->tenantSort, - 'subjectSort' => $this->subjectSort, + 'draftSelectedPolicyTypes' => $this->draftSelectedPolicyTypes, + 'draftSelectedStates' => $this->draftSelectedStates, + 'draftSelectedSeverities' => $this->draftSelectedSeverities, + 'draftTenantSort' => $this->draftTenantSort, + 'draftSubjectSort' => $this->draftSubjectSort, ]; } @@ -429,6 +523,46 @@ private function matrixOptions(string $key): array return is_array($options) ? $options : []; } + /** + * @return array{ + * selectedPolicyTypes: list, + * selectedStates: list, + * selectedSeverities: list, + * tenantSort: string, + * subjectSort: string + * } + */ + private function draftFilterState(): array + { + return [ + 'selectedPolicyTypes' => $this->normalizeQueryList($this->draftSelectedPolicyTypes), + 'selectedStates' => $this->normalizeQueryList($this->draftSelectedStates), + 'selectedSeverities' => $this->normalizeQueryList($this->draftSelectedSeverities), + 'tenantSort' => $this->normalizeTenantSort($this->draftTenantSort), + 'subjectSort' => $this->normalizeSubjectSort($this->draftSubjectSort), + ]; + } + + /** + * @return array{ + * selectedPolicyTypes: list, + * selectedStates: list, + * selectedSeverities: list, + * tenantSort: string, + * subjectSort: string + * } + */ + private function appliedFilterState(): array + { + return [ + 'selectedPolicyTypes' => $this->selectedPolicyTypes, + 'selectedStates' => $this->selectedStates, + 'selectedSeverities' => $this->selectedSeverities, + 'tenantSort' => $this->tenantSort, + 'subjectSort' => $this->subjectSort, + ]; + } + /** * @return list */ @@ -447,6 +581,27 @@ private function normalizeQueryList(mixed $value): array }, $values)))); } + private function normalizeRequestedMode(mixed $value): string + { + return in_array((string) $value, ['auto', 'dense', 'compact'], true) + ? (string) $value + : 'auto'; + } + + private function normalizeTenantSort(mixed $value): string + { + return in_array((string) $value, ['tenant_name', 'deviation_count', 'freshness_urgency'], true) + ? (string) $value + : 'tenant_name'; + } + + private function normalizeSubjectSort(mixed $value): string + { + return in_array((string) $value, ['deviation_breadth', 'policy_type', 'display_name'], true) + ? (string) $value + : 'deviation_breadth'; + } + private function compareAssignedTenantsDisabledReason(): ?string { $reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : []; @@ -520,11 +675,12 @@ private function routeParameters(array $overrides = []): array { return array_filter([ 'record' => $this->getRecord(), + 'mode' => $this->requestedMode !== 'auto' ? $this->requestedMode : null, 'policy_type' => $this->selectedPolicyTypes, 'state' => $this->selectedStates, 'severity' => $this->selectedSeverities, - 'tenant_sort' => $this->tenantSort, - 'subject_sort' => $this->subjectSort, + 'tenant_sort' => $this->tenantSort !== 'tenant_name' ? $this->tenantSort : null, + 'subject_sort' => $this->subjectSort !== 'deviation_breadth' ? $this->subjectSort : null, 'subject_key' => $this->focusedSubjectKey, ...$overrides, ], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== ''); @@ -557,4 +713,44 @@ private function workspace(): ?Workspace { return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first(); } + + /** + * @return array + */ + private function presentationState(): array + { + $resolvedMode = $this->resolvePresentationMode($this->visibleTenantCount()); + + return [ + 'requestedMode' => $this->requestedMode, + 'resolvedMode' => $resolvedMode, + 'visibleTenantCount' => $this->visibleTenantCount(), + 'activeFilterCount' => $this->activeFilterCount(), + 'hasStagedFilterChanges' => $this->hasStagedFilterChanges(), + 'autoRefreshActive' => (bool) ($this->matrix['hasActiveRuns'] ?? false), + 'lastUpdatedAt' => $this->matrix['lastUpdatedAt'] ?? null, + 'canOverrideMode' => $this->visibleTenantCount() > 0, + 'compactModeAvailable' => $this->canUseCompactMode(), + ]; + } + + private function visibleTenantCount(): int + { + $reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : []; + + return (int) ($reference['visibleTenantCount'] ?? 0); + } + + private function resolvePresentationMode(int $visibleTenantCount): string + { + if ($this->requestedMode === 'dense') { + return 'dense'; + } + + if ($this->requestedMode === 'compact' && $visibleTenantCount <= 1) { + return 'compact'; + } + + return $visibleTenantCount > 1 ? 'dense' : 'compact'; + } } diff --git a/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php b/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php index 8d0edc0b..adbb171f 100644 --- a/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php +++ b/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php @@ -15,7 +15,6 @@ use App\Services\Auth\CapabilityResolver; use App\Services\Baselines\BaselineSnapshotTruthResolver; use App\Support\Auth\Capabilities; -use App\Support\Baselines\BaselineProfileStatus; use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; use App\Support\Inventory\InventoryPolicyTypeMeta; @@ -142,9 +141,19 @@ public function build(BaselineProfile $profile, User $user, array $filters = []) 'diagnostic_only', 'unusable', ]), + 'supportSurfaceState' => [ + 'legendMode' => 'grouped', + 'showActiveFilterSummary' => true, + 'showLastUpdated' => true, + 'showAutoRefreshHint' => false, + 'showBlockingRefreshState' => false, + ], + 'lastUpdatedAt' => now()->toIso8601String(), 'tenantSummaries' => [], 'subjectSummaries' => [], 'rows' => [], + 'denseRows' => [], + 'compactResults' => [], 'emptyState' => $this->emptyState( reference: $reference, snapshotItemsCount: $snapshotItems->count(), @@ -247,6 +256,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = []) $rows, ); $bundle['rows'] = $rows; + $bundle['denseRows'] = $rows; + $bundle['compactResults'] = $this->compactResults($rows, $tenantSummaries); $bundle['emptyState'] = $this->emptyState( reference: $reference, snapshotItemsCount: $snapshotItems->count(), @@ -258,6 +269,7 @@ public function build(BaselineProfile $profile, User $user, array $filters = []) OperationRunStatus::Queued->value, OperationRunStatus::Running->value, ], true)); + $bundle['supportSurfaceState']['showAutoRefreshHint'] = $bundle['hasActiveRuns']; return $bundle; } @@ -476,6 +488,9 @@ private function cellFor( 'state' => $state, 'severity' => $finding instanceof Finding ? (string) $finding->severity : null, 'trustLevel' => $trustLevel, + 'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot), + 'attentionLevel' => $this->attentionLevel($state, $finding instanceof Finding ? (string) $finding->severity : null), + 'reasonSummary' => $this->reasonSummary($state, $reasonCode, $policyTypeCovered), 'reasonCode' => $reasonCode, 'compareRunId' => $completedRun?->getKey(), 'findingId' => $finding?->getKey(), @@ -526,6 +541,42 @@ private function runReasonCode(?OperationRun $run): ?string : null; } + private function attentionLevel(string $state, ?string $severity): string + { + if (in_array($state, ['differ', 'missing', 'ambiguous'], true) || in_array($severity, [ + Finding::SEVERITY_HIGH, + Finding::SEVERITY_CRITICAL, + ], true)) { + return 'needs_attention'; + } + + if (in_array($state, ['stale_result', 'not_compared'], true)) { + return 'refresh_recommended'; + } + + if ($state === 'match') { + return 'aligned'; + } + + return 'review'; + } + + private function reasonSummary(string $state, ?string $reasonCode, bool $policyTypeCovered): ?string + { + return match ($state) { + 'differ' => 'A baseline compare finding exists for this subject.', + 'missing' => 'The reference subject is missing from the tenant result.', + 'ambiguous' => $reasonCode !== null + ? Str::headline(str_replace(['.', '_'], ' ', $reasonCode)) + : 'Identity or evidence stayed ambiguous.', + 'stale_result' => 'Refresh recommended before acting on this result.', + 'not_compared' => $policyTypeCovered + ? 'No completed compare result is available yet.' + : 'Policy type coverage was not proven in the latest compare run.', + default => null, + }; + } + private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool { if (! $run instanceof OperationRun || ! $run->finished_at) { @@ -599,6 +650,7 @@ private function subjectSummary(array $subject, array $cells): array 'notComparedBreadth' => $this->countStates($cells, ['not_compared']), 'maxSeverity' => $this->maxSeverity($cells), 'trustLevel' => $this->worstTrustLevel($cells), + 'attentionLevel' => $this->worstAttentionLevel($cells), ]; } @@ -724,6 +776,28 @@ private function trustRank(string $trustLevel): int }; } + /** + * @param list> $cells + */ + private function worstAttentionLevel(array $cells): string + { + return collect($cells) + ->map(static fn (array $cell): string => (string) ($cell['attentionLevel'] ?? 'review')) + ->sortByDesc(fn (string $level): int => $this->attentionRank($level)) + ->first() ?? 'review'; + } + + private function attentionRank(string $attentionLevel): int + { + return match ($attentionLevel) { + 'needs_attention' => 4, + 'refresh_recommended' => 3, + 'review' => 2, + 'aligned' => 1, + default => 0, + }; + } + /** * @param list> $rows * @return list> @@ -812,6 +886,53 @@ private function sortCellsForTenants(array $cells, array $tenantSummaries): arra return array_values($cells); } + /** + * @param list> $rows + * @param list> $tenantSummaries + * @return list> + */ + private function compactResults(array $rows, array $tenantSummaries): array + { + if (count($tenantSummaries) !== 1) { + return []; + } + + $tenantSummary = $tenantSummaries[0]; + $tenantId = (int) ($tenantSummary['tenantId'] ?? 0); + + if ($tenantId <= 0) { + return []; + } + + return array_values(array_map(function (array $row) use ($tenantId, $tenantSummary): array { + $subject = is_array($row['subject'] ?? null) ? $row['subject'] : []; + $cell = collect($row['cells'] ?? [])->firstWhere('tenantId', $tenantId) ?? []; + + return [ + 'tenantId' => $tenantId, + 'tenantName' => (string) ($tenantSummary['tenantName'] ?? 'Tenant'), + 'subjectKey' => (string) ($subject['subjectKey'] ?? ''), + 'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject', + 'policyType' => (string) ($subject['policyType'] ?? ''), + 'baselineExternalId' => $subject['baselineExternalId'] ?? null, + 'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0), + 'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0), + 'ambiguousBreadth' => (int) ($subject['ambiguousBreadth'] ?? 0), + 'state' => (string) ($cell['state'] ?? 'not_compared'), + 'freshnessState' => (string) ($cell['freshnessState'] ?? 'unknown'), + 'trustLevel' => (string) ($cell['trustLevel'] ?? 'unusable'), + 'attentionLevel' => (string) ($cell['attentionLevel'] ?? 'review'), + 'severity' => $cell['severity'] ?? null, + 'reasonSummary' => $cell['reasonSummary'] ?? null, + 'reasonCode' => $cell['reasonCode'] ?? null, + 'compareRunId' => $cell['compareRunId'] ?? null, + 'findingId' => $cell['findingId'] ?? null, + 'lastComparedAt' => $cell['lastComparedAt'] ?? null, + 'policyTypeCovered' => $cell['policyTypeCovered'] ?? true, + ]; + }, $rows)); + } + /** * @param array $reference * @return array{title: string, body: string}|null diff --git a/apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php b/apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php index 66f202c4..d10ce711 100644 --- a/apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php +++ b/apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php @@ -1,15 +1,10 @@ - @if (($hasActiveRuns ?? false) === true) -
- @endif - @php $reference = is_array($reference ?? null) ? $reference : []; $tenantSummaries = is_array($tenantSummaries ?? null) ? $tenantSummaries : []; - $rows = is_array($rows ?? null) ? $rows : []; + $denseRows = is_array($denseRows ?? null) ? $denseRows : []; + $compactResults = is_array($compactResults ?? null) ? $compactResults : []; $policyTypeOptions = is_array($policyTypeOptions ?? null) ? $policyTypeOptions : []; - $stateOptions = is_array($stateOptions ?? null) ? $stateOptions : []; - $severityOptions = is_array($severityOptions ?? null) ? $severityOptions : []; $tenantSortOptions = is_array($tenantSortOptions ?? null) ? $tenantSortOptions : []; $subjectSortOptions = is_array($subjectSortOptions ?? null) ? $subjectSortOptions : []; $stateLegend = is_array($stateLegend ?? null) ? $stateLegend : []; @@ -17,11 +12,21 @@ $trustLegend = is_array($trustLegend ?? null) ? $trustLegend : []; $emptyState = is_array($emptyState ?? null) ? $emptyState : null; $currentFilters = is_array($currentFilters ?? null) ? $currentFilters : []; + $draftFilters = is_array($draftFilters ?? null) ? $draftFilters : []; + $presentationState = is_array($presentationState ?? null) ? $presentationState : []; + $supportSurfaceState = is_array($supportSurfaceState ?? null) ? $supportSurfaceState : []; $referenceReady = ($reference['referenceState'] ?? null) === 'ready'; - $matrixSourceNavigation = is_array($navigationContext ?? null) ? $navigationContext : null; $activeFilterCount = $this->activeFilterCount(); $activeFilterSummary = $this->activeFilterSummary(); - $hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - (int) ($reference['visibleTenantCount'] ?? 0)); + $stagedFilterSummary = $this->stagedFilterSummary(); + $hasStagedFilterChanges = (bool) ($presentationState['hasStagedFilterChanges'] ?? false); + $requestedMode = (string) ($presentationState['requestedMode'] ?? 'auto'); + $resolvedMode = (string) ($presentationState['resolvedMode'] ?? 'compact'); + $visibleTenantCount = (int) ($presentationState['visibleTenantCount'] ?? 0); + $autoRefreshActive = (bool) ($presentationState['autoRefreshActive'] ?? false); + $lastUpdatedAt = $presentationState['lastUpdatedAt'] ?? null; + $compactModeAvailable = (bool) ($presentationState['compactModeAvailable'] ?? false); + $hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - $visibleTenantCount); $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); @@ -29,122 +34,213 @@ $severityBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::FindingSeverity, $value); $profileStatusBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineProfileStatus, $value); $profileStatusSpec = $profileStatusBadge($reference['baselineStatus'] ?? null); + $modeBadgeColor = match ($resolvedMode) { + 'dense' => 'info', + 'compact' => 'success', + default => 'gray', + }; + $modeLabel = $this->presentationModeLabel($resolvedMode); @endphp + @if ($autoRefreshActive) + + @endif + - Compare assigned tenants is simulation only. It reuses the existing tenant-owned baseline compare path for the visible assigned set and does not create a workspace umbrella run. + Compare assigned tenants remains simulation only. This operator view changes presentation density, not compare truth, visible-set scope, or the existing drilldown path. -
-
-
- - {{ $profileStatusSpec->label }} - - - @if ($referenceReady) - - Reference snapshot ready +
+
+
+
+ + {{ $profileStatusSpec->label }} - @else - - Reference snapshot blocked + + + {{ $referenceReady ? 'Reference snapshot ready' : 'Reference snapshot blocked' }} - @endif - @if (filled($reference['referenceSnapshotId'] ?? null)) - - Snapshot #{{ (int) $reference['referenceSnapshotId'] }} + + {{ $modeLabel }} - @endif - @if ($hiddenAssignedTenantCount > 0) - - {{ $hiddenAssignedTenantCount }} hidden by access scope - - @endif -
- -
-

- {{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }} -

- -

- Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}. - Visible tenants: {{ (int) ($reference['visibleTenantCount'] ?? 0) }}. - @if (filled($reference['referenceSnapshotCapturedAt'] ?? null)) - Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}. + @if (filled($reference['referenceSnapshotId'] ?? null)) + + Snapshot #{{ (int) $reference['referenceSnapshotId'] }} + @endif -

- @if (filled($reference['referenceReasonCode'] ?? null)) -

- Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }} -

- @endif + @if ($hiddenAssignedTenantCount > 0) + + {{ $hiddenAssignedTenantCount }} hidden by access scope + + @endif +
- @if ($hiddenAssignedTenantCount > 0) -

- Showing only the visible assigned set for your current access scope. Hidden tenants are excluded from summaries, rows, and drilldowns. +

+

+ {{ $reference['baselineProfileName'] ?? ($profile->name ?? 'Baseline compare matrix') }} +

+ +

+ Assigned tenants: {{ (int) ($reference['assignedTenantCount'] ?? 0) }}. + Visible tenants: {{ $visibleTenantCount }}. + @if (filled($reference['referenceSnapshotCapturedAt'] ?? null)) + Reference captured {{ \Illuminate\Support\Carbon::parse($reference['referenceSnapshotCapturedAt'])->diffForHumans() }}. + @endif

- @endif + +

+ Auto mode resolves from the visible tenant set. Manual mode stays local to this route and never becomes stored preference truth. +

+ + @if (filled($reference['referenceReasonCode'] ?? null)) +

+ Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }} +

+ @endif +
+ +
+
+
Visible tenants
+
+ {{ $visibleTenantCount }} +
+
+ +
+
Rendered subjects
+
+ {{ $resolvedMode === 'compact' ? count($compactResults) : count($denseRows) }} +
+
+ +
+
Active filters
+
+ @if ($activeFilterCount === 0) + All visible results + @else + {{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} + @endif +
+
+ +
+
Resolved mode
+
+ {{ $modeLabel }} +
+
+
-
-
-
Visible tenants
-
- {{ (int) ($reference['visibleTenantCount'] ?? 0) }} -
+
+
+
+
+
Presentation mode
+

+ Requested: {{ $this->presentationModeLabel($requestedMode) }}. Resolved: {{ $modeLabel }}. +

+
+ +
+ + Auto + + + + Dense + + + @if ($compactModeAvailable) + + Compact + + @else + + Compact unlocks at one visible tenant + + @endif +
+
-
-
Rendered subjects
-
- {{ count($rows) }} -
-
+
+
+
+ @if (($supportSurfaceState['showLastUpdated'] ?? true) && filled($lastUpdatedAt)) + + Last updated {{ \Illuminate\Support\Carbon::parse($lastUpdatedAt)->diffForHumans() }} + + @endif -
-
Tenant sort
-
- {{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }} -
-
+ @if (($supportSurfaceState['showAutoRefreshHint'] ?? false) && $autoRefreshActive) + + Passive auto-refresh every 5 seconds + + @endif -
-
Subject sort
-
- {{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }} -
+
+ + Refreshing now + +
+
+ +
+ + Refresh matrix + + + @if ($hiddenAssignedTenantCount > 0) + + Visible-set only. Hidden tenants never contribute to summaries or drilldowns. + + @endif +
+
-
+
- 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. + Heavy filters stage locally first. The matrix keeps rendering the applied scope until you explicitly apply or reset the draft.
-
-
-
Current matrix scope
+
+
+
Applied matrix scope

@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. + {{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are already shaping the rendered matrix. @endif

+ + @if ($activeFilterSummary !== []) +
+ @foreach ($activeFilterSummary as $label => $value) + + {{ $label }}: {{ $value }} + + @endforeach +
+ @endif
-
+
@if ($activeFilterCount === 0) All visible results @@ -153,83 +249,146 @@ @endif - @if ($hiddenAssignedTenantCount > 0) - - Visible-set only - - @endif + + Tenant sort: {{ $tenantSortOptions[$currentFilters['tenant_sort'] ?? 'tenant_name'] ?? 'Tenant name' }} + + + + Subject sort: {{ $subjectSortOptions[$currentFilters['subject_sort'] ?? 'deviation_breadth'] ?? 'Deviation breadth' }} +
- @if ($activeFilterSummary !== []) -
- @foreach ($activeFilterSummary as $label => $value) - - {{ $label }}: {{ $value }} - - @endforeach + @if ($hasStagedFilterChanges) +
+
+
+
Draft filters are staged
+

+ The controls below differ from the current route state. Apply them when you are ready to redraw the matrix. +

+
+ + @if ($stagedFilterSummary !== []) +
+ @foreach ($stagedFilterSummary as $label => $value) + + {{ $label }}: {{ is_string($value) ? \Illuminate\Support\Str::headline(str_replace('_', ' ', $value)) : $value }} + + @endforeach +
+ @endif +
@endif
-
+ {{ $this->form }} -
-
-
-
- Focused subject +
+
+
+ Focused subject - @if (filled($currentFilters['subject_key'] ?? null)) - - {{ $currentFilters['subject_key'] }} - + @if (filled($currentFilters['subject_key'] ?? null)) + + {{ $currentFilters['subject_key'] }} + - - Clear subject focus - - @else - None set yet. Use Focus subject from a row when you want a subject-first drilldown. - @endif + + Clear subject focus + + @else + + None set yet. Use Focus subject from a row when you want a subject-first drilldown. + + @endif +
+
+ +
+ + Apply filters + + + + Reset filters +
- -
- @if ($activeFilterCount > 0) - - Clear all filters - - @else - - No filter reset needed - - @endif -
-
+
-
- @if (($hasActiveRuns ?? false) === true) -
-
- - Auto-refresh every 5 seconds while compare runs are queued or running. - + + + Status, legends, and refresh cues stay compact so the matrix body remains the primary working surface. + + +
+
+
+
Current scope
+

+ {{ $visibleTenantCount }} visible {{ \Illuminate\Support\Str::plural('tenant', $visibleTenantCount) }}. + {{ $resolvedMode === 'dense' ? 'State-first dense scan stays active.' : 'Compact single-tenant review stays active.' }} +

+ + @if ($policyTypeOptions !== []) +
+ + {{ count($policyTypeOptions) }} searchable policy types + + @if ($hiddenAssignedTenantCount > 0) + + Visible-set only + + @endif +
+ @endif +
+ +
+
Refresh honesty
+

+ Manual refresh shows a blocking state only while you explicitly redraw. Background polling remains a passive hint. +

+ + @if ($autoRefreshActive) +
+ + Compare work is still queued or running + +
+ @endif
- @endif -
- -
-
-
-
State legend
+
+ +
+
+
Grouped legend
+

+ State, freshness, and trust stay available on demand without pushing the matrix down the page. +

+
+
+ {{ count($stateLegend) }} states + {{ count($freshnessLegend) }} freshness cues + {{ count($trustLegend) }} trust cues +
+
+
+ +
+
+
State legend
+
@foreach ($stateLegend as $item) {{ $item['label'] }} @@ -238,10 +397,9 @@
-
-
-
Freshness legend
- +
+
Freshness legend
+
@foreach ($freshnessLegend as $item) {{ $item['label'] }} @@ -250,10 +408,9 @@
-
-
-
Trust legend
- +
+
Trust legend
+
@foreach ($trustLegend as $item) {{ $item['label'] }} @@ -262,85 +419,230 @@
- +
+
+ - @if ($emptyState !== null) - -
-
-

{{ $emptyState['title'] ?? 'Nothing to show' }}

-

{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}

+
+ @if ($emptyState !== null) + +
+
+

{{ $emptyState['title'] ?? 'Nothing to show' }}

+

{{ $emptyState['body'] ?? 'Adjust the current inputs and try again.' }}

+ + @if ($activeFilterCount > 0) +
+ + Reset filters + +
+ @endif +
+
+
+ @elseif ($resolvedMode === 'compact') + @php + $compactTenant = $tenantSummaries[0] ?? null; + $compactTenantFreshnessSpec = $freshnessBadge($compactTenant['freshnessState'] ?? null); + $compactTenantTrustSpec = $trustBadge($compactTenant['trustLevel'] ?? null); + @endphp + + + + One visible tenant remains in scope, so the matrix collapses into a shorter subject-result list instead of a pseudo-grid. + + + @if ($compactTenant) +
+
+
+
{{ $compactTenant['tenantName'] }}
+

+ Compact mode stays visible-set only. Subject drilldowns and run links still preserve the matrix context. +

+
+ +
+ + {{ $compactTenantFreshnessSpec->label }} + + + {{ $compactTenantTrustSpec->label }} + +
-
- @else - - - Tenant-level freshness, trust, and breadth stay visible before you scan the subject-by-tenant body. - + @endif -
- @foreach ($tenantSummaries as $tenantSummary) - @php - $freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null); - $trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null); - $tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null; - $tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']); - $tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null) - ? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId']) - : null; - @endphp +
+ @foreach ($compactResults as $result) + @php + $stateSpec = $stateBadge($result['state'] ?? null); + $freshnessSpec = $freshnessBadge($result['freshnessState'] ?? null); + $trustSpec = $trustBadge($result['trustLevel'] ?? null); + $severitySpec = filled($result['severity'] ?? null) ? $severityBadge($result['severity']) : null; + $tenantId = (int) ($result['tenantId'] ?? 0); + $subjectKey = $result['subjectKey'] ?? null; + $primaryUrl = filled($result['findingId'] ?? null) + ? $this->findingUrl($tenantId, (int) $result['findingId'], $subjectKey) + : $this->tenantCompareUrl($tenantId, $subjectKey); + $runUrl = filled($result['compareRunId'] ?? null) + ? $this->runUrl((int) $result['compareRunId'], $tenantId, $subjectKey) + : null; + $attentionClasses = match ((string) ($result['attentionLevel'] ?? 'review')) { + 'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300', + 'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300', + 'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300', + default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + }; + $attentionLabel = \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($result['attentionLevel'] ?? 'review'))); + @endphp -
-
+
+
+
-

{{ $tenantSummary['tenantName'] }}

-
- - {{ $freshnessSpec->label }} - - - {{ $trustSpec->label }} - - @if ($tenantSeveritySpec) - - {{ $tenantSeveritySpec->label }} - - @endif +
+ {{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
+
+ {{ $result['policyType'] ?? 'Unknown policy type' }} +
+ @if (filled($result['baselineExternalId'] ?? null)) +
+ Reference ID: {{ $result['baselineExternalId'] }} +
+ @endif
-
- @if (filled($tenantSummary['lastComparedAt'] ?? null)) - Compared {{ \Illuminate\Support\Carbon::parse($tenantSummary['lastComparedAt'])->diffForHumans() }} - @else - No completed compare yet +
+ + {{ $stateSpec->label }} + + + {{ $freshnessSpec->label }} + + + {{ $trustSpec->label }} + + @if ($severitySpec) + + {{ $severitySpec->label }} + + @endif + + {{ $attentionLabel }} + +
+ + @if (filled($result['reasonSummary'] ?? null) || filled($result['lastComparedAt'] ?? null)) +
+ @if (filled($result['reasonSummary'] ?? null)) +
{{ $result['reasonSummary'] }}
+ @endif + @if (filled($result['lastComparedAt'] ?? null)) +
Compared {{ \Illuminate\Support\Carbon::parse($result['lastComparedAt'])->diffForHumans() }}
+ @endif +
+ @endif +
+ +
+
+ + Drift breadth {{ (int) ($result['deviationBreadth'] ?? 0) }} + + + Missing {{ (int) ($result['missingBreadth'] ?? 0) }} + + + Ambiguous {{ (int) ($result['ambiguousBreadth'] ?? 0) }} + +
+ +
+ @if ($primaryUrl) + + {{ filled($result['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }} + + @endif + + @if ($runUrl) + + Open run + + @endif + + @if (filled($result['subjectKey'] ?? null)) + + Focus subject + + @endif +
+
+
+
+ @endforeach +
+ + @else + + + The matrix body is state-first. Row click stays forbidden, the subject column stays pinned, and repeated follow-up actions move behind compact secondary reveals. + + +
+ @foreach ($tenantSummaries as $tenantSummary) + @php + $freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null); + $trustSpec = $trustBadge($tenantSummary['trustLevel'] ?? null); + $tenantSeveritySpec = filled($tenantSummary['maxSeverity'] ?? null) ? $severityBadge($tenantSummary['maxSeverity']) : null; + $tenantCompareUrl = $this->tenantCompareUrl((int) $tenantSummary['tenantId']); + $tenantRunUrl = filled($tenantSummary['compareRunId'] ?? null) + ? $this->runUrl((int) $tenantSummary['compareRunId'], (int) $tenantSummary['tenantId']) + : null; + @endphp + +
+
+
+
{{ $tenantSummary['tenantName'] }}
+
+ + {{ $freshnessSpec->label }} + + + {{ $trustSpec->label }} + + @if ($tenantSeveritySpec) + + {{ $tenantSeveritySpec->label }} + @endif
-
+
-
Aligned
-
{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}
+
Aligned
+
{{ (int) ($tenantSummary['matchedCount'] ?? 0) }}
-
Drift
-
{{ (int) ($tenantSummary['differingCount'] ?? 0) }}
+
Drift
+
{{ (int) ($tenantSummary['differingCount'] ?? 0) }}
-
Missing
-
{{ (int) ($tenantSummary['missingCount'] ?? 0) }}
+
Missing
+
{{ (int) ($tenantSummary['missingCount'] ?? 0) }}
-
Ambiguous / not compared
-
- {{ (int) ($tenantSummary['ambiguousCount'] ?? 0) + (int) ($tenantSummary['notComparedCount'] ?? 0) }} -
+
Ambiguous
+
{{ (int) ($tenantSummary['ambiguousCount'] ?? 0) }}
-
+
-
+
@if ($tenantCompareUrl) Open tenant compare @@ -354,210 +656,213 @@ @endif
- @endforeach -
- +
+ @endforeach +
- - - Row click is intentionally disabled. The subject column stays pinned while you scan across visible tenants. - +
+
+ + + + -
-
-
+ Baseline subject +
- - - - - @foreach ($tenantSummaries as $tenantSummary) - @php - $freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null); - @endphp - - @endforeach - - - - - @foreach ($rows as $row) + @foreach ($tenantSummaries as $tenantSummary) @php - $subject = is_array($row['subject'] ?? null) ? $row['subject'] : []; - $cells = is_array($row['cells'] ?? null) ? $row['cells'] : []; - $subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : 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'; + $freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null); @endphp - - + @endforeach + + + + + @foreach ($denseRows as $row) + @php + $subject = is_array($row['subject'] ?? null) ? $row['subject'] : []; + $cells = is_array($row['cells'] ?? null) ? $row['cells'] : []; + $subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null); + $subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : 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'; + $subjectAttentionClasses = match ((string) ($subject['attentionLevel'] ?? 'review')) { + 'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300', + 'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300', + 'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300', + default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + }; + @endphp + + + - @foreach ($cells as $cell) - @php - $cellStateSpec = $stateBadge($cell['state'] ?? null); - $cellTrustSpec = $trustBadge($cell['trustLevel'] ?? 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); - $tenantId = (int) ($cell['tenantId'] ?? 0); - $tenantCompareUrl = $tenantId > 0 ? $this->tenantCompareUrl($tenantId, $subjectKey) : null; - $cellFindingUrl = ($tenantId > 0 && filled($cell['findingId'] ?? null)) - ? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey) - : null; - $cellRunUrl = filled($cell['compareRunId'] ?? null) - ? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey) - : null; - @endphp +
+ + Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }} + + + Missing {{ (int) ($subject['missingBreadth'] ?? 0) }} + + + {{ $subjectTrustSpec->label }} + + @if ($subjectSeveritySpec) + + {{ $subjectSeveritySpec->label }} + + @endif + + {{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($subject['attentionLevel'] ?? 'review'))) }} + +
- - - {{ $cellPriorityLabel }} - - + @foreach ($cells as $cell) + @php + $cellStateSpec = $stateBadge($cell['state'] ?? null); + $cellFreshnessSpec = $freshnessBadge($cell['freshnessState'] ?? null); + $cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null); + $cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null; + $tenantId = (int) ($cell['tenantId'] ?? 0); + $subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null); + $primaryUrl = filled($cell['findingId'] ?? null) + ? $this->findingUrl($tenantId, (int) $cell['findingId'], $subjectKey) + : $this->tenantCompareUrl($tenantId, $subjectKey); + $runUrl = filled($cell['compareRunId'] ?? null) + ? $this->runUrl((int) $cell['compareRunId'], $tenantId, $subjectKey) + : null; + $attentionClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) { + 'needs_attention' => 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300', + 'refresh_recommended' => 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300', + 'aligned' => 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300', + default => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', + }; + $cellSurfaceClasses = match ((string) ($cell['attentionLevel'] ?? 'review')) { + 'needs_attention' => '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', + 'refresh_recommended' => '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', + 'aligned' => 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10', + default => 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40', + }; + @endphp + - @endforeach - - @endforeach - -
- Baseline subject - -
-
{{ $tenantSummary['tenantName'] }}
-
- - {{ $freshnessSpec->label }} - -
-
-
-
-
-
- {{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }} -
+
+
+
{{ $tenantSummary['tenantName'] }}
+
+ + {{ $freshnessSpec->label }} + +
+
+
+
+
+
+ {{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }} +
+
+ {{ $subject['policyType'] ?? 'Unknown policy type' }} +
+ @if (filled($subject['baselineExternalId'] ?? null))
- {{ $subject['policyType'] ?? 'Unknown policy type' }} -
- @if (filled($subject['baselineExternalId'] ?? null)) -
- Reference ID: {{ $subject['baselineExternalId'] }} -
- @endif -
- -
- - Drift breadth {{ (int) ($subject['deviationBreadth'] ?? 0) }} - - - Missing {{ (int) ($subject['missingBreadth'] ?? 0) }} - - - Ambiguous {{ (int) ($subject['ambiguousBreadth'] ?? 0) }} - - - {{ $subjectTrustSpec->label }} - - @if ($subjectSeveritySpec) - - {{ $subjectSeveritySpec->label }} - - @endif -
- - @if (filled($subject['subjectKey'] ?? null)) -
- - Focus subject - + Reference ID: {{ $subject['baselineExternalId'] }}
@endif
-
-
-
-
- - {{ $cellStateSpec->label }} - - @if ($cellSeveritySpec) - - {{ $cellSeveritySpec->label }} - - @endif -
+ @if (filled($subject['subjectKey'] ?? null)) +
+ + Focus subject + +
+ @endif +
+
+
+
- - {{ $cellTrustSpec->label }} + + {{ $cellStateSpec->label }} + @if ($cellSeveritySpec) + + {{ $cellSeveritySpec->label }} + + @endif
-
- @if (filled($cell['reasonCode'] ?? null)) -
- Reason: {{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) $cell['reasonCode'])) }} -
+ + {{ \Illuminate\Support\Str::headline(str_replace('_', ' ', (string) ($cell['attentionLevel'] ?? 'review'))) }} + +
+ +
+ + {{ $cellFreshnessSpec->label }} + + + {{ $cellTrustSpec->label }} + +
+ + @if (filled($cell['reasonSummary'] ?? null) || filled($cell['lastComparedAt'] ?? null)) +
+ @if (filled($cell['reasonSummary'] ?? null)) +
{{ $cell['reasonSummary'] }}
@endif @if (filled($cell['lastComparedAt'] ?? null)) -
- Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }} +
Compared {{ \Illuminate\Support\Carbon::parse($cell['lastComparedAt'])->diffForHumans() }}
+ @endif +
+ @endif + +
+ @if ($primaryUrl) +
+ + {{ filled($cell['findingId'] ?? null) ? 'Open finding' : 'Open tenant compare' }} + +
+ @endif + + @if ($runUrl || filled($subjectKey)) +
+ + More follow-up + + +
+ @if ($runUrl) + + Open run + + @endif + + @if (filled($subjectKey)) + + Focus subject + + @endif
- @endif - - @if (($cell['policyTypeCovered'] ?? true) === false) -
Policy type coverage was not proven in the latest compare run.
- @endif -
- -
- @if ($cellFindingUrl) - - Open finding - - @elseif ($tenantCompareUrl) - - Open tenant compare - - @endif - - @if ($cellRunUrl) - - Open run - - @endif -
+ + @endif
-
-
+
+ + @endforeach + + @endforeach + +
- - @endif -
+
+ + @endif
diff --git a/apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php b/apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php index 0fcd9edf..9b679256 100644 --- a/apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php +++ b/apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php @@ -4,6 +4,8 @@ use App\Filament\Resources\BaselineProfileResource; use App\Models\Finding; +use App\Models\User; +use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures; @@ -11,7 +13,7 @@ pest()->browser()->timeout(15_000); -it('smokes the baseline compare matrix render, filter interaction, and finding drilldown continuity', function (): void { +it('smokes dense multi-tenant scanning and finding drilldown continuity', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $run = $this->makeBaselineCompareMatrixRun( @@ -26,7 +28,7 @@ $fixture['snapshot'], ); - $finding = $this->makeBaselineCompareMatrixFinding( + $this->makeBaselineCompareMatrixFinding( $fixture['visibleTenant'], $fixture['profile'], $run, @@ -46,40 +48,102 @@ $page ->assertNoJavaScriptErrors() - ->waitForText('Visible-set baseline') - ->assertSee('Reference overview') - ->assertSee('No narrowing filters are active') - ->assertSee('Subject-by-tenant matrix') - ->assertSee('WiFi Corp Profile') - ->assertSee('Windows Compliance') - ->assertSee('Open finding'); - - $page->script(<<<'JS' -const input = Array.from(document.querySelectorAll('input[type="checkbox"]')).find((element) => { - 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.dispatchEvent(new Event('input', { bubbles: true })); -input.dispatchEvent(new Event('change', { bubbles: true })); -JS); - - $page - ->wait(1) - ->waitForText('Open finding') - ->assertDontSee('Windows Compliance') + ->waitForText('Requested: Auto mode. Resolved: Dense mode.') + ->assertSee('Dense multi-tenant scan') + ->assertSee('Grouped legend') + ->assertSee('Open finding') + ->assertSee('More follow-up') ->click('Open finding') ->waitForText('Back to compare matrix') ->assertNoJavaScriptErrors() ->assertSee('Back to compare matrix'); }); + +it('smokes the compact single-tenant path when only one visible tenant remains', function (): void { + $fixture = $this->makeBaselineCompareMatrixFixture(); + + $run = $this->makeBaselineCompareMatrixRun( + $fixture['visibleTenant'], + $fixture['profile'], + $fixture['snapshot'], + ); + + $this->makeBaselineCompareMatrixRun( + $fixture['visibleTenantTwo'], + $fixture['profile'], + $fixture['snapshot'], + ); + + $this->makeBaselineCompareMatrixFinding( + $fixture['visibleTenant'], + $fixture['profile'], + $run, + 'wifi-corp-profile', + ['severity' => Finding::SEVERITY_HIGH], + ); + + $viewer = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'user_id' => (int) $viewer->getKey(), + 'role' => 'owner', + ]); + + $viewer->tenants()->syncWithoutDetaching([ + (int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($viewer)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(), + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(), + ], + ]); + session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey()); + + visit(BaselineProfileResource::compareMatrixUrl($fixture['profile'])) + ->assertNoJavaScriptErrors() + ->waitForText('Requested: Auto mode. Resolved: Compact mode.') + ->assertSee('Compact compare results') + ->assertSee('Open finding'); +}); + +it('smokes filtered zero-results reset flow and passive refresh cues without losing the matrix route', 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(), + ], + ); + + $this->makeBaselineCompareMatrixRun( + $fixture['visibleTenantTwo'], + $fixture['profile'], + $fixture['snapshot'], + ); + + $this->actingAs($fixture['user'])->withSession([ + WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(), + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(), + ], + ]); + session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey()); + + visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing') + ->assertNoJavaScriptErrors() + ->waitForText('No rows match the current filters') + ->assertSee('Passive auto-refresh every 5 seconds') + ->click('Reset filters') + ->waitForText('Dense multi-tenant scan') + ->assertSee('Requested: Dense mode. Resolved: Dense mode.') + ->assertNoJavaScriptErrors(); +}); diff --git a/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php b/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php index a1dc7fad..1328f636 100644 --- a/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php +++ b/apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use App\Models\Tenant; +use App\Models\User; +use App\Models\WorkspaceMembership; use App\Support\Baselines\BaselineCompareMatrixBuilder; use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; @@ -11,7 +13,7 @@ uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class); -it('builds visible-set-only tenant and subject summaries from assigned baseline truth', function (): void { +it('builds visible-set-only dense rows plus support metadata from assigned baseline truth', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $visibleRun = $this->makeBaselineCompareMatrixRun( @@ -49,7 +51,7 @@ $matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']); - $wifiRow = collect($matrix['rows'])->first( + $wifiRow = collect($matrix['denseRows'])->first( static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile', ); @@ -61,10 +63,16 @@ ]) ->and($wifiRow)->not->toBeNull() ->and($wifiRow['subject']['deviationBreadth'])->toBe(1) - ->and(count($wifiRow['cells']))->toBe(2); + ->and($wifiRow['subject']['attentionLevel'])->toBe('needs_attention') + ->and(count($wifiRow['cells']))->toBe(2) + ->and($matrix['denseRows'])->toHaveCount(2) + ->and($matrix['compactResults'])->toBeEmpty() + ->and($matrix['supportSurfaceState']['legendMode'])->toBe('grouped') + ->and($matrix['supportSurfaceState']['showAutoRefreshHint'])->toBeFalse() + ->and($matrix['lastUpdatedAt'])->not->toBeNull(); }); -it('derives matrix cell precedence from compare freshness, evidence gaps, findings, and uncovered policy types', function (): void { +it('derives matrix cell precedence, freshness, attention, and reason summaries from compare truth', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $matchTenant = $fixture['visibleTenant']; @@ -188,23 +196,33 @@ $matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']); - $wifiRow = collect($matrix['rows'])->first( + $wifiRow = collect($matrix['denseRows'])->first( static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile', ); - $statesByTenant = collect($wifiRow['cells'] ?? []) - ->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => (string) $cell['state']]) + $cellsByTenant = collect($wifiRow['cells'] ?? []) + ->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => $cell]) ->all(); - expect($statesByTenant[(int) $matchTenant->getKey()] ?? null)->toBe('match') - ->and($statesByTenant[(int) $differTenant->getKey()] ?? null)->toBe('differ') - ->and($statesByTenant[(int) $missingTenant->getKey()] ?? null)->toBe('missing') - ->and($statesByTenant[(int) $ambiguousTenant->getKey()] ?? null)->toBe('ambiguous') - ->and($statesByTenant[(int) $notComparedTenant->getKey()] ?? null)->toBe('not_compared') - ->and($statesByTenant[(int) $staleTenant->getKey()] ?? null)->toBe('stale_result'); + expect($cellsByTenant[(int) $matchTenant->getKey()]['state'] ?? null)->toBe('match') + ->and($cellsByTenant[(int) $matchTenant->getKey()]['attentionLevel'] ?? null)->toBe('aligned') + ->and($cellsByTenant[(int) $differTenant->getKey()]['state'] ?? null)->toBe('differ') + ->and($cellsByTenant[(int) $differTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention') + ->and($cellsByTenant[(int) $differTenant->getKey()]['reasonSummary'] ?? null)->toBe('A baseline compare finding exists for this subject.') + ->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing') + ->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention') + ->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous') + ->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match') + ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared') + ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended') + ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven') + ->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result') + ->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale') + ->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence') + ->and($cellsByTenant[(int) $staleTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended'); }); -it('applies policy-type, state, severity, and subject-focus filters honestly', function (): void { +it('applies policy, state, severity, and subject-focus filters honestly without changing compare truth', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $visibleRun = $this->makeBaselineCompareMatrixRun( @@ -245,10 +263,55 @@ 'focusedSubjectKey' => 'wifi-corp-profile', ]); - expect(count($deviceOnly['rows']))->toBe(1) - ->and($deviceOnly['rows'][0]['subject']['policyType'])->toBe('deviceConfiguration') - ->and(count($driftOnly['rows']))->toBe(1) - ->and($driftOnly['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile') - ->and(count($subjectFocus['rows']))->toBe(1) - ->and($subjectFocus['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile'); + expect(count($deviceOnly['denseRows']))->toBe(1) + ->and($deviceOnly['denseRows'][0]['subject']['policyType'])->toBe('deviceConfiguration') + ->and(count($driftOnly['denseRows']))->toBe(1) + ->and($driftOnly['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile') + ->and(count($subjectFocus['denseRows']))->toBe(1) + ->and($subjectFocus['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile'); +}); + +it('emits compact single-tenant results from the visible set only when one tenant remains in scope', function (): void { + $fixture = $this->makeBaselineCompareMatrixFixture(); + + $run = $this->makeBaselineCompareMatrixRun( + $fixture['visibleTenant'], + $fixture['profile'], + $fixture['snapshot'], + ); + + $this->makeBaselineCompareMatrixFinding( + $fixture['visibleTenant'], + $fixture['profile'], + $run, + 'wifi-corp-profile', + ['severity' => 'critical'], + ); + + $this->makeBaselineCompareMatrixRun( + $fixture['visibleTenantTwo'], + $fixture['profile'], + $fixture['snapshot'], + ); + + $viewer = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'user_id' => (int) $viewer->getKey(), + 'role' => 'owner', + ]); + + $viewer->tenants()->syncWithoutDetaching([ + (int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'], + ]); + + $matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $viewer); + + expect($matrix['reference']['assignedTenantCount'])->toBe(3) + ->and($matrix['reference']['visibleTenantCount'])->toBe(1) + ->and($matrix['compactResults'])->toHaveCount(2) + ->and(collect($matrix['compactResults'])->pluck('tenantId')->unique()->all())->toBe([(int) $fixture['visibleTenant']->getKey()]) + ->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['state'] ?? null)->toBe('differ') + ->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['attentionLevel'] ?? null)->toBe('needs_attention'); }); diff --git a/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php b/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php index 844fc6ee..7f289af2 100644 --- a/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php @@ -4,13 +4,15 @@ use App\Filament\Pages\BaselineCompareMatrix; use App\Filament\Resources\BaselineProfileResource; +use App\Models\User; +use App\Models\WorkspaceMembership; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures; uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class); -it('renders the baseline compare matrix with reference truth, legends, and explicit drilldowns', function (): void { +it('renders dense auto mode with sticky subject behavior and compact support surfaces', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $run = $this->makeBaselineCompareMatrixRun( @@ -38,25 +40,19 @@ ->get(BaselineProfileResource::compareMatrixUrl($fixture['profile'])) ->assertOk() ->assertSee('Visible-set baseline') - ->assertSee('Reference overview') - ->assertSee('fi-fo-checkbox-list', false) - ->assertSee('fi-fo-select', false) - ->assertSee('State legend') - ->assertSee('Tenant summaries') - ->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((string) $fixture['visibleTenant']->name) - ->assertSee((string) $fixture['visibleTenantTwo']->name) - ->assertSee('Needs attention') + ->assertSee('Requested: Auto mode. Resolved: Dense mode.') + ->assertDontSee('Passive auto-refresh every 5 seconds') + ->assertSee('Grouped legend') + ->assertSee('Apply filters') + ->assertSee('Compact unlocks at one visible tenant') + ->assertSee('Dense multi-tenant scan') ->assertSee('Open finding') - ->assertSee('Open tenant compare') - ->assertSee('data-testid="matrix-active-filters"', false) + ->assertSee('More follow-up') + ->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false) ->assertSee('sticky left-0', false); }); -it('keeps query-backed filters and subject focus on the matrix route and drilldown links', function (): void { +it('stages heavy filter changes until apply and preserves mode and subject continuity in drilldown urls', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $run = $this->makeBaselineCompareMatrixRun( @@ -70,7 +66,6 @@ $fixture['profile'], $run, 'wifi-corp-profile', - ['severity' => 'critical'], ); $this->makeBaselineCompareMatrixRun( @@ -82,26 +77,109 @@ $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']); $component = Livewire::withQueryParams([ + 'mode' => 'dense', 'policy_type' => ['deviceConfiguration'], 'state' => ['differ'], - 'severity' => ['critical'], + 'severity' => ['high'], 'subject_key' => 'wifi-corp-profile', ]) ->actingAs($fixture['user']) ->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') - ->assertDontSee('Windows Compliance'); + ->assertSet('requestedMode', 'dense') + ->assertSee('Requested: Dense mode. Resolved: Dense mode.') + ->assertSee('Focused subject') + ->assertSee('wifi-corp-profile'); - $tenantCompareUrl = $component->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile'); - $findingUrl = $component->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile'); + expect($component->instance()->hasStagedFilterChanges())->toBeFalse(); - expect($tenantCompareUrl)->toContain('baseline_profile_id='.(int) $fixture['profile']->getKey()) - ->and($tenantCompareUrl)->toContain('subject_key=wifi-corp-profile') - ->and($tenantCompareUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix') + $component + ->set('draftSelectedPolicyTypes', ['compliancePolicy']) + ->set('draftSelectedStates', ['match']) + ->set('draftSelectedSeverities', []) + ->set('draftTenantSort', 'freshness_urgency') + ->set('draftSubjectSort', 'display_name') + ->assertSee('Draft filters are staged'); + + expect($component->instance()->hasStagedFilterChanges())->toBeTrue(); + + $component->call('applyFilters')->assertRedirect( + BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&policy_type%5B0%5D=compliancePolicy&state%5B0%5D=match&tenant_sort=freshness_urgency&subject_sort=display_name&subject_key=wifi-corp-profile' + ); + + $applied = Livewire::withQueryParams([ + 'mode' => 'dense', + 'policy_type' => ['compliancePolicy'], + 'state' => ['match'], + 'tenant_sort' => 'freshness_urgency', + 'subject_sort' => 'display_name', + 'subject_key' => 'wifi-corp-profile', + ]) + ->actingAs($fixture['user']) + ->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]); + + $tenantCompareUrl = $applied->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile'); + $findingUrl = $applied->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile'); + + expect(urldecode((string) $tenantCompareUrl))->toContain('mode=dense') + ->and(urldecode((string) $tenantCompareUrl))->toContain('subject_key=wifi-corp-profile') + ->and(urldecode((string) $findingUrl))->toContain('mode=dense') ->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix'); + + $applied->call('resetFilters')->assertRedirect( + BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense' + ); +}); + +it('resolves auto to compact for the visible-set-only single-tenant edge case and still allows dense override', function (): void { + $fixture = $this->makeBaselineCompareMatrixFixture(); + + $run = $this->makeBaselineCompareMatrixRun( + $fixture['visibleTenant'], + $fixture['profile'], + $fixture['snapshot'], + ); + + $this->makeBaselineCompareMatrixRun( + $fixture['visibleTenantTwo'], + $fixture['profile'], + $fixture['snapshot'], + ); + + $this->makeBaselineCompareMatrixFinding( + $fixture['visibleTenant'], + $fixture['profile'], + $run, + 'wifi-corp-profile', + ['severity' => 'critical'], + ); + + $viewer = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $fixture['workspace']->getKey(), + 'user_id' => (int) $viewer->getKey(), + 'role' => 'owner', + ]); + + $viewer->tenants()->syncWithoutDetaching([ + (int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'], + ]); + + $session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace'], $fixture['visibleTenant']); + + $this->withSession($session) + ->get(BaselineProfileResource::compareMatrixUrl($fixture['profile'])) + ->assertOk() + ->assertSee('Requested: Auto mode. Resolved: Compact mode.') + ->assertSee('Compact compare results') + ->assertSee('data-testid="baseline-compare-matrix-compact-shell"', false) + ->assertDontSee('data-testid="baseline-compare-matrix-dense-shell"', false); + + $this->withSession($session) + ->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense') + ->assertOk() + ->assertSee('Requested: Dense mode. Resolved: Dense mode.') + ->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false); }); it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void { @@ -133,9 +211,9 @@ it('renders an empty state when the assigned set is not visible to the current actor', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); - $viewer = \App\Models\User::factory()->create(); + $viewer = User::factory()->create(); - \App\Models\WorkspaceMembership::factory()->create([ + WorkspaceMembership::factory()->create([ 'workspace_id' => (int) $fixture['workspace']->getKey(), 'user_id' => (int) $viewer->getKey(), 'role' => 'owner', @@ -149,7 +227,7 @@ ->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 { +it('renders a passive auto-refresh cue instead of a perpetual blocking state while compare runs remain active', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $this->makeBaselineCompareMatrixRun( @@ -169,12 +247,12 @@ $this->withSession($session) ->get(BaselineProfileResource::compareMatrixUrl($fixture['profile'])) ->assertOk() - ->assertSee('Auto-refresh every 5 seconds while compare runs are queued or running.') + ->assertSee('Passive auto-refresh every 5 seconds') ->assertSee('wire:poll.5s="pollMatrix"', false) - ->assertDontSee('Refreshing matrix'); + ->assertSee('Refresh matrix'); }); -it('renders an empty state when no rows match the current filters', function (): void { +it('renders a filtered zero-result state that preserves mode and offers reset filters as the primary cta', function (): void { $fixture = $this->makeBaselineCompareMatrixFixture(); $this->makeBaselineCompareMatrixRun( @@ -192,7 +270,9 @@ $session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']); $this->withSession($session) - ->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?state[]=missing') + ->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing') ->assertOk() - ->assertSee('No rows match the current filters'); + ->assertSee('Requested: Dense mode. Resolved: Dense mode.') + ->assertSee('No rows match the current filters') + ->assertSee('Reset filters'); }); diff --git a/specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml b/specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml index 5283fb2b..e3e47d4a 100644 --- a/specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml +++ b/specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Baseline Compare Matrix Operator Mode Internal Surface Contract - version: 0.1.0 + version: 0.2.0 summary: Internal logical contract for adaptive operator-density rendering on the existing baseline compare matrix route description: | This contract is an internal planning artifact for Spec 191. The affected surface @@ -18,9 +18,12 @@ x-baseline-compare-operator-mode-consumers: - apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php mustRender: - reference + - requested_vs_resolved_mode - presentation_state - support_surface_state - applied_filters + - draft_filters + - staged_filter_changes - tenant_summaries - dense_rows_or_compact_results - last_updated_at @@ -37,6 +40,8 @@ x-baseline-compare-operator-mode-consumers: - selectedPolicyTypes - selectedStates - selectedSeverities + - tenantSort + - subjectSort paths: /admin/baseline-profiles/{profile}/compare-matrix: get: @@ -270,7 +275,9 @@ components: - activeFilterCount - hasStagedFilterChanges - autoRefreshActive + - lastUpdatedAt - canOverrideMode + - compactModeAvailable properties: requestedMode: $ref: '#/components/schemas/PresentationMode' @@ -279,6 +286,10 @@ components: enum: - dense - compact + description: | + Final render mode after evaluating the requested route mode against the + visible tenant count. A requested `compact` mode may still resolve to + `dense` when more than one visible tenant remains in scope. visibleTenantCount: type: integer activeFilterCount: @@ -294,6 +305,8 @@ components: format: date-time canOverrideMode: type: boolean + compactModeAvailable: + type: boolean MatrixTenantSummary: type: object additionalProperties: false @@ -477,6 +490,7 @@ components: - presentation - supportSurface - appliedFilters + - draftFilters - tenantSummaries properties: reference: @@ -487,6 +501,8 @@ components: $ref: '#/components/schemas/MatrixSupportSurfaceState' appliedFilters: $ref: '#/components/schemas/MatrixFilterDraft' + draftFilters: + $ref: '#/components/schemas/MatrixFilterDraft' tenantSummaries: type: array items: @@ -498,4 +514,4 @@ components: compactResults: type: array items: - $ref: '#/components/schemas/CompactSubjectResultView' \ No newline at end of file + $ref: '#/components/schemas/CompactSubjectResultView' diff --git a/specs/191-baseline-compare-operator-mode/spec.md b/specs/191-baseline-compare-operator-mode/spec.md index 5676a6af..84f11cf6 100644 --- a/specs/191-baseline-compare-operator-mode/spec.md +++ b/specs/191-baseline-compare-operator-mode/spec.md @@ -2,7 +2,7 @@ # Feature Specification: Baseline Compare Matrix: High-Density Operator Mode **Feature Branch**: `191-baseline-compare-operator-mode` **Created**: 2026-04-11 -**Status**: Draft +**Status**: Approved **Input**: User description: "Spec Candidate 190b — Baseline Compare Matrix: High-Density Operator Mode" ## Spec Candidate Check *(mandatory — SPEC-GATE-001)* @@ -51,6 +51,12 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang |---|---|---|---|---|---|---|---|---|---| | Workspace baseline compare matrix | Workspace operator | Matrix / triage surface | Where is the meaningful drift across the visible tenant set, how trustworthy is it, and where should I go next? | Subject-by-tenant state, trust, freshness, severity or attention signal, visible-set filter scope, mode, last updated | Raw reason codes, run identifiers, detailed evidence gaps, low-level compare metadata | compare state, freshness, trust, severity/attention | `simulation only` for compare start; otherwise read-only | Compare assigned tenants, apply or reset filters, switch presentation mode, focus subject, drill into compare/finding/run | none | +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Workspace baseline compare matrix | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | `Compare assigned tenants` remains the sole primary header action; presentation mode, refresh status, and filter state stay in contextual support surfaces rather than the header | Explicit subject, cell, and tenant drilldown controls only; row click remains forbidden | none; follow-up links remain inside compact cell or compact-result affordances only | none | `Reset filters` becomes the single primary CTA when filters reduce the visible row set to zero; otherwise the surface keeps the existing compare-start guidance and no duplicate empty-state CTA | No separate detail header exists; the matrix route remains the canonical working surface | n/a | Existing compare-start run and audit semantics remain unchanged; no new audit event is introduced by presentation changes | Dense-grid and compact-single-tenant rendering are approved custom surface exceptions, but HDR-001 still applies: no pure-navigation header actions and only one primary visible header action | + ## Proportionality Review *(mandatory when structural complexity is introduced)* - **New source of truth?**: no @@ -94,7 +100,8 @@ ### User Story 2 - Work a single visible tenant in compact mode (Priority: P2) **Acceptance Scenarios**: 1. **Given** exactly one visible assigned tenant after RBAC scoping, **When** the operator opens the matrix in auto mode, **Then** the page renders compact single-tenant mode instead of dense mode. -2. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface. +2. **Given** more than one tenant is assigned to the baseline profile but RBAC scoping leaves only one tenant visible to the current actor, **When** the operator opens the matrix in auto mode, **Then** the page still resolves to compact mode and all counts and drilldowns remain visible-set-only. +3. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface. --- @@ -111,6 +118,7 @@ ### User Story 3 - Use filters, legends, and status surfaces without losing the 1. **Given** the operator changes multi-select filters, **When** those changes are staged, **Then** the page uses an explicit apply or reset pattern for heavy filter changes instead of re-rendering noisily on every click. 2. **Given** active compare runs or polling are present, **When** the matrix refreshes in the background, **Then** the operator sees a non-blocking update signal and a page-level freshness hint rather than a permanent loading impression. 3. **Given** the operator already understands the legends, **When** the page loads in daily-use mode, **Then** legends are grouped and visually compact, with deeper explanation still available on demand. +4. **Given** staged or applied filters reduce the visible subject set to zero, **When** the page renders the filtered result, **Then** it preserves the active presentation mode, shows a clear zero-results empty state, and offers `Reset filters` as the single primary CTA. ### Edge Cases @@ -140,7 +148,7 @@ ## Requirements *(mandatory)* ### Functional Requirements -- **FR-191-001 Primary working surface**: The matrix body MUST become visually dominant over reference context, filters, legends, and refresh hints. +- **FR-191-001 Primary working surface**: On desktop operator viewports (`>= 1280px`), the initial render MUST show the first dense matrix row or first compact result without scrolling past expanded legends or a long filter stack. Reference context, filter summary, and legend summary MAY remain above the working surface, but detailed legend or helper text MUST stay collapsed or secondary by default. - **FR-191-002 Auto presentation mode**: The page MUST support an `auto` presentation mode that chooses dense multi-tenant mode when more than one visible tenant is in scope and compact single-tenant mode when exactly one visible tenant is in scope. - **FR-191-003 Manual override**: The page MUST allow a local manual override between `auto`, `dense`, and `compact` presentation without persisting that choice as domain truth or a stored user preference. - **FR-191-004 Dense multi-tenant layout**: Dense mode MUST render one subject row and one visible-tenant column with a sticky first subject column. @@ -149,8 +157,8 @@ ### Functional Requirements - **FR-191-007 Action calming**: Repeated follow-up actions such as tenant compare, finding, or run links MUST become visually secondary. The default focus in dense or compact mode MUST remain the compare state, not the link chrome. - **FR-191-008 Filter density**: The page MUST show active filter count and active filter scope clearly while keeping the filter zone visually compact. - **FR-191-009 Heavy-filter workflow**: Policy type and other heavy multi-select filters MUST use an explicit apply/reset interaction instead of forcing a full matrix recompute on every click. -- **FR-191-010 Policy type usability**: Policy type filtering MUST be faster than the current long checkbox stack, for example by searchability, type-to-find behavior, or another equally compact operator-first selector. -- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST remain available but MUST be grouped and visually compressed so they do not displace the matrix in daily use. +- **FR-191-010 Policy type usability**: Policy type filtering MUST replace the long checkbox stack with a searchable multi-select or an equivalent compact selector that supports type-to-find behavior and stays one compact control when closed. +- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST default to one grouped support block with summary labels visible and detailed explanatory text hidden behind an explicit reveal so they do not displace the matrix in daily use. - **FR-191-012 Honest status transitions**: The page MUST distinguish between active loading, background auto-refresh, and last-updated freshness so operators can tell whether the matrix is recalculating or simply polling for updates. - **FR-191-013 Last updated visibility**: The page MUST show a page-level or matrix-level freshness hint indicating when the currently rendered matrix data was last refreshed. - **FR-191-014 Visible-set truth preserved**: Dense and compact mode MUST preserve the visible-set-only semantics already defined in Spec 190 for all counts, subject breadth, and drilldowns. @@ -159,6 +167,13 @@ ### Functional Requirements - **FR-191-017 No new persistence**: This spec MUST NOT introduce a new matrix snapshot, portfolio report, stored view preference, or any other new persisted artifact. - **FR-191-018 Automated regression coverage**: Automated coverage MUST prove mode selection, sticky dense layout, compact single-tenant layout, filter apply/reset behavior, legend compression, non-blocking refresh state, and preservation of existing drilldowns and RBAC semantics. +## Measurable Acceptance Thresholds + +- Dense auto mode is accepted only when a multi-tenant matrix render shows the first sticky subject row without scrolling past expanded legends or a long filter stack on a desktop operator viewport. +- Compact auto mode is accepted only when the RBAC-visible single-tenant edge case renders the compact result list instead of the dense grid while preserving visible-set-only counts and drilldown continuity. +- Staged filtering is accepted only when draft multi-select or sort changes do not redraw the matrix until the operator explicitly applies or resets them, and the active filter summary continues to describe the applied route state. +- Support-surface compression is accepted only when legends stay grouped behind an explicit reveal, passive auto-refresh remains visibly distinct from deliberate refresh, and last-updated context stays visible on the page. + ## Non-Goals - No change to baseline compare logic or evidence resolution @@ -204,11 +219,13 @@ ## Definition of Done - the existing matrix route supports `auto`, `dense`, and `compact` presentation behavior, - multi-tenant auto mode renders a clearly denser matrix with a sticky subject column, +- the RBAC-scoped case where more than one tenant is assigned but only one tenant is visible resolves to compact mode while preserving visible-set-only counts and drilldowns, - single-tenant auto mode renders a compact compare-list presentation instead of the current matrix-heavy layout, - supporting context is visibly lighter than the matrix body, - repeated per-cell or per-row actions no longer dominate the reading flow, - active filters are counted and heavy filters use an explicit apply/reset pattern, +- zero-result filtered states preserve the active mode and offer `Reset filters` as the single primary CTA, - legends remain available but are grouped and visually compressed, - page-level refresh and last-updated signals are honest and non-blocking, - no compare logic, trust logic, freshness logic, or RBAC semantics have changed, -- and focused feature plus browser coverage proves the new operator-density behavior. \ No newline at end of file +- and focused feature plus browser coverage proves the new operator-density behavior. diff --git a/specs/191-baseline-compare-operator-mode/tasks.md b/specs/191-baseline-compare-operator-mode/tasks.md index 94937fb5..73c8bad9 100644 --- a/specs/191-baseline-compare-operator-mode/tasks.md +++ b/specs/191-baseline-compare-operator-mode/tasks.md @@ -1,10 +1,10 @@ # Tasks: Baseline Compare Matrix: High-Density Operator Mode **Input**: Design documents from `/specs/191-baseline-compare-operator-mode/` -**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/baseline-compare-operator-mode.logical.openapi.yaml` **Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route. -**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, no new run-summary contract, and no new notification channel should be introduced. +**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, run-summary contract, or notification channel should be introduced. **RBAC**: Existing workspace and tenant visibility rules from Spec 190 remain authoritative. Tasks must preserve visible-set-only aggregation and existing `404` vs `403` behavior. **Operator Surfaces**: The affected operator surface is the existing workspace baseline compare matrix route, with additive presentation changes only. **Filament UI Action Surfaces**: The matrix page keeps explicit drilldown controls and forbidden row click. No destructive action is added. @@ -12,28 +12,31 @@ # Tasks: Baseline Compare Matrix: High-Density Operator Mode **Organization**: Tasks are grouped by user story so each operator-density improvement can be implemented and verified independently. -## Phase 1: Setup (Presentation Seams) +## Phase 1: Setup (Spec and Acceptance Seams) -**Purpose**: Prepare focused acceptance seams for adaptive presentation work. +**Purpose**: Lock the implementation contract and acceptance seams before page behavior changes. -- [ ] T001 [P] Add presentation-mode acceptance scaffolds in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` -- [ ] T002 [P] Extend surface-contract guard coverage for calmer matrix actions in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` +- [X] T001 Finalize the UI Action Matrix, operator-surface assumptions, and measurable acceptance thresholds in `specs/191-baseline-compare-operator-mode/spec.md` +- [X] T002 [P] Reconcile the staged filter and presentation-mode interaction contract in `specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml` +- [X] T003 [P] Add acceptance scaffolds for multi-tenant, single-tenant, and staged-filter scenarios in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` +- [X] T004 [P] Extend browser and action-surface guard seams in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` -**Checkpoint**: The test suite has clear seams for dense mode, compact mode, and action-noise expectations. +**Checkpoint**: The spec contract and test seams are ready for implementation work. --- -## Phase 2: Foundational (Blocking Operator-Density Contract) +## Phase 2: Foundational (Blocking Presentation Contract) -**Purpose**: Establish page-level presentation state and derived view metadata before reshaping the UI. +**Purpose**: Establish page-level presentation state and derived read models before reshaping dense and compact layouts. -**⚠️ CRITICAL**: No story work should begin until the presentation contract is stable. +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. -- [ ] T003 Add `auto`, `dense`, and `compact` presentation state handling plus route persistence in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` -- [ ] T004 [P] Add derived density metadata for compact cell summaries and compact single-tenant summaries in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` -- [ ] T005 [P] Add page-level last-updated and staged-filter metadata in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T005 Add requested, resolved, and manual presentation-mode query handling plus staged filter state as request-scoped-only route state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T006 [P] Extend matrix bundle outputs for dense rows, compact results, support-surface state, and last-updated metadata in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` +- [X] T007 [P] Add foundational builder coverage for requested or resolved mode, filter metadata, support-surface state, and unchanged compare state, trust, freshness, and severity outputs in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` +- [X] T008 [P] Add foundational page coverage for mode resolution, route-state persistence, and derived-only non-persistence guarantees in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` -**Checkpoint**: The page can resolve presentation mode and expose the supporting state required for dense and compact rendering. +**Checkpoint**: The page can resolve `auto`, `dense`, and `compact` mode and expose all derived state needed by the UI. --- @@ -45,15 +48,16 @@ ## Phase 3: User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1) ### Tests for User Story 1 -- [ ] T006 [P] [US1] Add dense multi-tenant page coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` -- [ ] T007 [P] [US1] Extend browser smoke coverage for dense-mode scanning in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` +- [X] T009 [P] [US1] Add dense-mode assertions for auto resolution, sticky subject behavior, and compact cell hierarchy in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [X] T010 [P] [US1] Extend browser smoke coverage for dense-mode scanning and dense-mode drilldowns in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` ### Implementation for User Story 1 -- [ ] T008 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` -- [ ] T009 [US1] Reduce dense-cell chrome to compact state, trust, freshness, and attention signals in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` -- [ ] T010 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` -- [ ] T011 [US1] Run focused US1 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` +- [X] T011 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [X] T012 [US1] Surface condensed dense-cell state, trust, freshness, and attention summaries in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T013 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T014 [US1] Preserve focused-subject and visible-set drilldown continuity for dense mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T015 [US1] Run focused dense-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` **Checkpoint**: Multi-tenant scanning is visibly denser and the matrix body reads as the primary working surface. @@ -67,14 +71,15 @@ ## Phase 4: User Story 2 - Work a single visible tenant in compact mode (Priorit ### Tests for User Story 2 -- [ ] T012 [P] [US2] Add compact single-tenant coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [X] T016 [P] [US2] Add compact single-tenant page assertions for auto-to-compact resolution, including the assigned-greater-than-visible RBAC edge case, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [X] T017 [P] [US2] Add compact single-tenant builder assertions for visible-set-only compact resolution and unchanged compare semantics in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` ### Implementation for User Story 2 -- [ ] T013 [US2] Resolve auto-to-compact presentation behavior for one visible tenant in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` -- [ ] T014 [US2] Render the compact single-tenant compare list in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` -- [ ] T015 [US2] Preserve subject focus and drilldown continuity across compact-mode state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` -- [ ] T016 [US2] Run focused US2 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [X] T018 [US2] Emit compact single-tenant result entries, compact drilldown metadata, and visible-set-only compact resolution when assigned tenants exceed visible tenants in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` +- [X] T019 [US2] Render the compact single-tenant compare list and reduced metadata shell in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [X] T020 [US2] Preserve manual override, subject focus, and drilldown continuity for compact mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T021 [US2] Run focused compact-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php` **Checkpoint**: One-tenant viewing is materially shorter and calmer than the current matrix surface. @@ -88,16 +93,17 @@ ## Phase 5: User Story 3 - Use filters, legends, and status surfaces without los ### Tests for User Story 3 -- [ ] T017 [P] [US3] Add filter apply/reset and legend-compaction coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` -- [ ] T018 [P] [US3] Add non-blocking refresh and last-updated browser coverage in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` +- [X] T022 [P] [US3] Add staged-filter, legend-compaction, refresh-cue, and zero-result empty-state assertions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [X] T023 [P] [US3] Add browser smoke coverage for apply/reset filters, passive auto-refresh cues, and filtered zero-result empty states in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` ### Implementation for User Story 3 -- [ ] T019 [US3] Convert heavy matrix filters to a staged apply/reset workflow in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` -- [ ] T020 [US3] Replace the current policy-type control with a faster compact operator-first selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` -- [ ] T021 [US3] Group or collapse legends and lighten supporting context hierarchy in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` -- [ ] T022 [US3] Render page-level last-updated, polling, and manual-refresh signals without blocking the matrix in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` -- [ ] T023 [US3] Run focused US3 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` +- [X] T024 [US3] Implement staged heavy-filter draft, apply, reset, and zero-result empty-state behavior in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T025 [US3] Replace the long policy-type control with a searchable compact selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` +- [X] T026 [US3] Render applied-versus-draft filter summaries, one grouped collapsed legend block, and compressed support context in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [X] T027 [US3] Render honest manual-refresh, passive polling, and last-updated cues in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [X] T028 [US3] Keep calmer actions and forbidden row-click behavior enforced in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php` +- [X] T029 [US3] Run focused support-surface verification, including zero-result empty-state behavior, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` **Checkpoint**: Filters, legends, and status surfaces support the operator without visually competing with the matrix. @@ -107,9 +113,10 @@ ## Phase 6: Polish & Cross-Cutting Concerns **Purpose**: Finalize copy, formatting, and the focused verification pack. -- [ ] T024 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` -- [ ] T025 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` -- [ ] T026 Run the focused verification pack against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` +- [X] T030 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` +- [X] T031 [P] Verify shared badge semantics remain centralized in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` +- [X] T032 [P] Run formatting for changed implementation files in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php` +- [X] T033 Run the focused verification pack and confirm no compare-truth or persistence regressions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` --- @@ -117,30 +124,57 @@ ## Dependencies & Execution Order ### Phase Dependencies -- **Setup (Phase 1)**: starts immediately. -- **Foundational (Phase 2)**: depends on Setup and blocks presentation work. -- **US1 (Phase 3)**: depends on Phase 2 and is the MVP. -- **US2 (Phase 4)**: depends on Phase 2 and can follow US1 once the page contract is stable. -- **US3 (Phase 5)**: depends on Phase 2 and should land after dense and compact structure are in place. -- **Polish (Phase 6)**: depends on the desired user stories being complete. +- **Setup (Phase 1)**: No dependencies. Start immediately. +- **Foundational (Phase 2)**: Depends on Phase 1. Blocks all user-story implementation. +- **User Story 1 (Phase 3)**: Depends on Phase 2. This is the MVP slice. +- **User Story 2 (Phase 4)**: Depends on Phase 2. Can proceed after the shared presentation contract is stable. +- **User Story 3 (Phase 5)**: Depends on Phase 2. Should land after the dense and compact layout branches exist. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1**: Independent after Phase 2 and should be delivered first. +- **US2**: Independent after Phase 2, but it reuses the shared presentation contract from US1-era foundational work. +- **US3**: Independent after Phase 2, but it should align with the final dense and compact layout structure. ### Within Each User Story -- Add or extend the story tests first. -- Land page-state changes before view-branching where possible. -- Keep each story independently shippable and verifiable. +- Tests for that story should be written and made to fail before implementation. +- Builder and page state updates should land before Blade branching that depends on them. +- Each story must remain independently testable when finished. -### Parallel Opportunities +## Parallel Execution Examples -- `T001` and `T002` can run in parallel. -- `T004` and `T005` can run in parallel after `T003` defines the presentation contract. -- Within US1, `T006` and `T007` can run in parallel before `T008` through `T010`. -- Within US3, `T017` and `T018` can run in parallel before `T019` through `T022`. +### User Story 1 + +- Run `T009` and `T010` in parallel because they touch separate test files. +- After `T011` lands, `T012` can proceed while `T014` is prepared if the route-state contract is already stable. + +### User Story 2 + +- Run `T016` and `T017` in parallel because they cover separate test layers. +- `T018` should land before `T019` because the compact Blade path depends on compact result entries. + +### User Story 3 + +- Run `T022` and `T023` in parallel because they touch separate test files. +- `T024` and `T025` can be split between staged filter flow and selector compaction if coordinated on `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`. ## Implementation Strategy -1. Lock the presentation contract and route state first. -2. Deliver dense multi-tenant mode as the MVP operator gain. -3. Deliver compact single-tenant mode as the adaptive counterpart. -4. Compress filters, legends, and status surfaces last so they match the final page structure. -5. Finish with copy review, formatting, and the focused verification pack. \ No newline at end of file +### MVP First + +1. Finish Setup and Foundational work. +2. Deliver US1 dense multi-tenant mode as the MVP operator gain. +3. Verify US1 independently before moving on. + +### Incremental Delivery + +1. Add US2 compact single-tenant mode on top of the shared presentation contract. +2. Add US3 filter, legend, and refresh-surface compression once both layout branches are stable. +3. Finish with copy review, formatting, and the focused verification pack. + +### Validation Rule + +1. Do not mark a story complete until its focused verification task passes. +2. Keep the existing Spec 190 truth, RBAC semantics, and drilldown continuity intact while implementing each story. -- 2.45.2 From 78f1c1c7b25aac0ac203b6933785fdc5e29e6ca8 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 11 Apr 2026 17:40:28 +0200 Subject: [PATCH 4/5] fix: disable unused filament font preloads --- apps/platform/app/Providers/Filament/AdminPanelProvider.php | 2 ++ apps/platform/app/Providers/Filament/SystemPanelProvider.php | 2 ++ apps/platform/app/Providers/Filament/TenantPanelProvider.php | 2 ++ .../tests/Feature/Filament/BaselineCompareMatrixPageTest.php | 1 + 4 files changed, 7 insertions(+) diff --git a/apps/platform/app/Providers/Filament/AdminPanelProvider.php b/apps/platform/app/Providers/Filament/AdminPanelProvider.php index e21caaff..d840a197 100644 --- a/apps/platform/app/Providers/Filament/AdminPanelProvider.php +++ b/apps/platform/app/Providers/Filament/AdminPanelProvider.php @@ -32,6 +32,7 @@ use App\Support\Workspaces\WorkspaceContext; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; +use Filament\FontProviders\LocalFontProvider; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Navigation\NavigationItem; @@ -62,6 +63,7 @@ public function panel(Panel $panel): Panel ->brandLogoHeight('2rem') ->homeUrl(fn (): string => route('admin.home')) ->favicon(asset('favicon.ico')) + ->font(null, provider: LocalFontProvider::class, preload: []) ->authenticatedRoutes(function (Panel $panel): void { ChooseWorkspace::registerRoutes($panel); ChooseTenant::registerRoutes($panel); diff --git a/apps/platform/app/Providers/Filament/SystemPanelProvider.php b/apps/platform/app/Providers/Filament/SystemPanelProvider.php index ecfedaa6..2bdeeb8e 100644 --- a/apps/platform/app/Providers/Filament/SystemPanelProvider.php +++ b/apps/platform/app/Providers/Filament/SystemPanelProvider.php @@ -7,6 +7,7 @@ use App\Http\Middleware\UseSystemSessionCookie; use App\Support\Auth\PlatformCapabilities; use App\Support\Filament\PanelThemeAsset; +use Filament\FontProviders\LocalFontProvider; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -31,6 +32,7 @@ public function panel(Panel $panel): Panel ->path('system') ->authGuard('platform') ->login(Login::class) + ->font(null, provider: LocalFontProvider::class, preload: []) ->colors([ 'primary' => Color::Blue, ]) diff --git a/apps/platform/app/Providers/Filament/TenantPanelProvider.php b/apps/platform/app/Providers/Filament/TenantPanelProvider.php index 3cf74d62..f7ab215f 100644 --- a/apps/platform/app/Providers/Filament/TenantPanelProvider.php +++ b/apps/platform/app/Providers/Filament/TenantPanelProvider.php @@ -10,6 +10,7 @@ use App\Support\Middleware\DenyNonMemberTenantAccess; use App\Support\OperationRunLinks; use Filament\Facades\Filament; +use Filament\FontProviders\LocalFontProvider; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -40,6 +41,7 @@ public function panel(Panel $panel): Panel ->brandLogo(fn () => view('filament.admin.logo')) ->brandLogoHeight('2rem') ->favicon(asset('favicon.ico')) + ->font(null, provider: LocalFontProvider::class, preload: []) ->tenant(Tenant::class, slugAttribute: 'external_id') ->tenantRoutePrefix(null) ->tenantMenu(fn (): bool => filled(Filament::getTenant())) diff --git a/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php b/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php index 7f289af2..23e3c026 100644 --- a/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php +++ b/apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php @@ -41,6 +41,7 @@ ->assertOk() ->assertSee('Visible-set baseline') ->assertSee('Requested: Auto mode. Resolved: Dense mode.') + ->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false) ->assertDontSee('Passive auto-refresh every 5 seconds') ->assertSee('Grouped legend') ->assertSee('Apply filters') -- 2.45.2 From 03f9740c4e4a1bcb1bf2d07381d50d4eb2213efa Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 11 Apr 2026 18:10:07 +0200 Subject: [PATCH 5/5] docs: amend constitution for action surface discipline --- .specify/memory/constitution.md | 246 +++++++++++++++++++++++++-- .specify/templates/plan-template.md | 9 +- .specify/templates/spec-template.md | 27 ++- .specify/templates/tasks-template.md | 24 ++- docs/product/standards/README.md | 4 +- 5 files changed, 282 insertions(+), 28 deletions(-) diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index bb19be46..1bccf24a 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,20 +1,29 @@