feat: add shared diff presentation foundation #170

Merged
ahmido merged 1 commits from 141-shared-diff-presentation-foundation into dev 2026-03-14 12:32:10 +00:00
35 changed files with 2551 additions and 1 deletions
Showing only changes of commit 4ff0d91a2e - Show all commits

View File

@ -72,6 +72,7 @@ ## Active Technologies
- PostgreSQL; existing JSON-backed onboarding draft state and `OperationRun.context.verification_report` (139-verify-access-permissions-assist)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
- PostgreSQL tables including `managed_tenant_onboarding_sessions`, `operation_runs`, `tenants`, and provider-connection-backed tenant records (140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp)
- PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced (141-shared-diff-presentation-foundation)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -91,8 +92,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 141-shared-diff-presentation-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
- 140-onboarding-lifecycle-operation-checkpoints-concurrency-mvp: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, PostgreSQL, Laravel Sail, Pest v4, existing `OperationRunService`, `ProviderOperationStartGate`, onboarding services, workspace audit logging
- 139-verify-access-permissions-assist: Added PHP 8.4 (Laravel 12) + Filament v5 (Livewire v4), Laravel Blade, existing onboarding/verification support classes
- 137-platform-provider-identity: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Laravel Sail, Pest v4, PHPUnit v12
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -48,6 +48,7 @@ final class BadgeCatalog
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class,
BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class,
BadgeDomain::DiffRowStatus->value => Domains\DiffRowStatusBadge::class,
];
/**

View File

@ -40,4 +40,5 @@ enum BadgeDomain: string
case ReviewPackStatus = 'review_pack_status';
case SystemHealth = 'system_health';
case ReferenceResolutionState = 'reference_resolution_state';
case DiffRowStatus = 'diff_row_status';
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\Diff\DiffRowStatus;
final class DiffRowStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
DiffRowStatus::Unchanged->value => new BadgeSpec('Unchanged', 'gray', 'heroicon-m-minus-circle'),
DiffRowStatus::Changed->value => new BadgeSpec('Changed', 'warning', 'heroicon-m-arrow-path'),
DiffRowStatus::Added->value => new BadgeSpec('Added', 'success', 'heroicon-m-plus-circle'),
DiffRowStatus::Removed->value => new BadgeSpec('Removed', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Support\Diff;
use InvalidArgumentException;
final readonly class DiffPresentation
{
/**
* @param array<int, DiffRow> $rows
*/
public function __construct(
public DiffSummary $summary,
public array $rows,
) {
foreach ($this->rows as $row) {
if (! $row instanceof DiffRow) {
throw new InvalidArgumentException('DiffPresentation rows must contain only DiffRow instances.');
}
}
}
public static function empty(?string $message = 'No diff data available.'): self
{
return new self(
summary: DiffSummary::empty($message),
rows: [],
);
}
}

View File

@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Support\Diff;
use BackedEnum;
use Illuminate\Support\Str;
use Stringable;
final class DiffPresenter
{
/**
* @param array<string, mixed> $baseline
* @param array<string, mixed> $current
* @param array<int, string> $changedKeys
* @param array<string, string> $labels
* @param array<string, array<string, mixed>> $meta
*/
public function present(
array $baseline,
array $current,
array $changedKeys = [],
array $labels = [],
array $meta = [],
): DiffPresentation {
$keys = [];
foreach ([array_keys($baseline), array_keys($current), $changedKeys] as $sourceKeys) {
foreach ($sourceKeys as $key) {
if (! is_string($key) || trim($key) === '') {
continue;
}
$keys[$key] = true;
}
}
if ($keys === []) {
return DiffPresentation::empty();
}
$changedLookup = [];
foreach ($changedKeys as $changedKey) {
if (! is_string($changedKey) || trim($changedKey) === '') {
continue;
}
$changedLookup[$changedKey] = true;
}
$rows = [];
foreach (array_keys($keys) as $key) {
$hasOldValue = array_key_exists($key, $baseline);
$hasNewValue = array_key_exists($key, $current);
if (! $hasOldValue && ! $hasNewValue) {
continue;
}
$oldValue = $baseline[$key] ?? null;
$newValue = $current[$key] ?? null;
$isListLike = $this->isListRowCandidate($hasOldValue, $oldValue, $hasNewValue, $newValue);
[$addedItems, $removedItems, $unchangedItems] = $isListLike
? $this->prepareListFragments(
$hasOldValue && is_array($oldValue) ? $oldValue : [],
$hasNewValue && is_array($newValue) ? $newValue : [],
)
: [[], [], []];
$rows[] = new DiffRow(
key: $key,
label: $this->labelForKey($key, $labels[$key] ?? null),
status: $this->statusFor(
key: $key,
hasOldValue: $hasOldValue,
hasNewValue: $hasNewValue,
oldValue: $oldValue,
newValue: $newValue,
changedLookup: $changedLookup,
),
oldValue: $oldValue,
newValue: $newValue,
isListLike: $isListLike,
addedItems: $addedItems,
removedItems: $removedItems,
unchangedItems: $unchangedItems,
meta: is_array($meta[$key] ?? null) ? $meta[$key] : [],
);
}
usort($rows, function (DiffRow $left, DiffRow $right): int {
return [mb_strtolower($left->label), $left->key] <=> [mb_strtolower($right->label), $right->key];
});
return new DiffPresentation(
summary: DiffSummary::fromRows($rows),
rows: $rows,
);
}
/**
* @param array<string, bool> $changedLookup
*/
private function statusFor(
string $key,
bool $hasOldValue,
bool $hasNewValue,
mixed $oldValue,
mixed $newValue,
array $changedLookup,
): DiffRowStatus {
if (! $hasOldValue) {
return DiffRowStatus::Added;
}
if (! $hasNewValue) {
return DiffRowStatus::Removed;
}
if (array_key_exists($key, $changedLookup) || $oldValue !== $newValue) {
return DiffRowStatus::Changed;
}
return DiffRowStatus::Unchanged;
}
private function labelForKey(string $key, mixed $label): string
{
if (is_string($label) && trim($label) !== '') {
return trim($label);
}
$segments = preg_split('/\s*>\s*|\./', $key) ?: [];
$segments = array_values(array_filter(array_map(function (string $segment): string {
return Str::of($segment)
->replace(['_', '-'], ' ')
->headline()
->trim()
->toString();
}, $segments)));
if ($segments !== []) {
return implode(' > ', $segments);
}
return Str::of($key)->headline()->toString();
}
private function isListRowCandidate(
bool $hasOldValue,
mixed $oldValue,
bool $hasNewValue,
mixed $newValue,
): bool {
if ($hasOldValue && $hasNewValue) {
return $this->isInlineList($oldValue) && $this->isInlineList($newValue);
}
if ($hasOldValue) {
return $this->isInlineList($oldValue);
}
return $this->isInlineList($newValue);
}
private function isInlineList(mixed $value): bool
{
if (! is_array($value) || ! array_is_list($value)) {
return false;
}
foreach ($value as $item) {
if (! $this->isInlineListItem($item)) {
return false;
}
}
return true;
}
private function isInlineListItem(mixed $item): bool
{
return $item === null
|| is_scalar($item)
|| $item instanceof BackedEnum
|| $item instanceof Stringable;
}
/**
* @param array<int, mixed> $baseline
* @param array<int, mixed> $current
* @return array{0: array<int, mixed>, 1: array<int, mixed>, 2: array<int, mixed>}
*/
private function prepareListFragments(array $baseline, array $current): array
{
$remainingBaseline = [];
foreach ($baseline as $item) {
$signature = $this->listItemSignature($item);
$remainingBaseline[$signature] = ($remainingBaseline[$signature] ?? 0) + 1;
}
$addedItems = [];
$unchangedItems = [];
foreach ($current as $item) {
$signature = $this->listItemSignature($item);
if (($remainingBaseline[$signature] ?? 0) > 0) {
$unchangedItems[] = $item;
$remainingBaseline[$signature]--;
continue;
}
$addedItems[] = $item;
}
$removedItems = [];
foreach ($baseline as $item) {
$signature = $this->listItemSignature($item);
if (($remainingBaseline[$signature] ?? 0) <= 0) {
continue;
}
$removedItems[] = $item;
$remainingBaseline[$signature]--;
}
return [$addedItems, $removedItems, $unchangedItems];
}
private function listItemSignature(mixed $item): string
{
return serialize($this->normalizeListItem($item));
}
private function normalizeListItem(mixed $item): mixed
{
if ($item instanceof BackedEnum) {
return $item->value;
}
if ($item instanceof Stringable) {
return (string) $item;
}
return $item;
}
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Support\Diff;
use BackedEnum;
use InvalidArgumentException;
use Stringable;
final readonly class DiffRow
{
public string $key;
public string $label;
public DiffRowStatus $status;
public mixed $oldValue;
public mixed $newValue;
public bool $isListLike;
/**
* @var array<int, mixed>
*/
public array $addedItems;
/**
* @var array<int, mixed>
*/
public array $removedItems;
/**
* @var array<int, mixed>
*/
public array $unchangedItems;
/**
* @var array<string, mixed>
*/
public array $meta;
/**
* @param array<int, mixed> $addedItems
* @param array<int, mixed> $removedItems
* @param array<int, mixed> $unchangedItems
* @param array<string, mixed> $meta
*/
public function __construct(
string $key,
string $label,
DiffRowStatus $status,
mixed $oldValue = null,
mixed $newValue = null,
bool $isListLike = false,
array $addedItems = [],
array $removedItems = [],
array $unchangedItems = [],
array $meta = [],
) {
if (trim($key) === '') {
throw new InvalidArgumentException('DiffRow key must be a non-empty string.');
}
if (trim($label) === '') {
throw new InvalidArgumentException('DiffRow label must be a non-empty string.');
}
$this->key = $key;
$this->label = $label;
$this->status = $status;
$this->oldValue = $oldValue;
$this->newValue = $newValue;
$this->isListLike = $isListLike;
$this->addedItems = array_values($addedItems);
$this->removedItems = array_values($removedItems);
$this->unchangedItems = array_values($unchangedItems);
$this->meta = $this->normalizeMeta($meta);
}
/**
* @param array<string, mixed> $meta
* @return array<string, mixed>
*/
private function normalizeMeta(array $meta): array
{
$normalized = [];
foreach ($meta as $key => $value) {
if (! is_string($key) || trim($key) === '') {
continue;
}
$normalized[$key] = $this->normalizeMetaValue($value);
}
return $normalized;
}
private function normalizeMetaValue(mixed $value): mixed
{
if ($value === null || is_scalar($value)) {
return $value;
}
if ($value instanceof BackedEnum) {
return $value->value;
}
if ($value instanceof Stringable) {
return (string) $value;
}
if (! is_array($value)) {
return null;
}
$normalized = [];
foreach ($value as $key => $item) {
if (! is_int($key) && ! is_string($key)) {
continue;
}
$normalized[$key] = $this->normalizeMetaValue($item);
}
return array_is_list($normalized) ? array_values($normalized) : $normalized;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Support\Diff;
enum DiffRowStatus: string
{
case Unchanged = 'unchanged';
case Changed = 'changed';
case Added = 'added';
case Removed = 'removed';
}

View File

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Support\Diff;
use InvalidArgumentException;
final readonly class DiffSummary
{
public int $changedCount;
public int $addedCount;
public int $removedCount;
public int $unchangedCount;
public bool $hasRows;
public ?string $message;
public function __construct(
int $changedCount = 0,
int $addedCount = 0,
int $removedCount = 0,
int $unchangedCount = 0,
?string $message = null,
) {
foreach ([
'changedCount' => $changedCount,
'addedCount' => $addedCount,
'removedCount' => $removedCount,
'unchangedCount' => $unchangedCount,
] as $field => $value) {
if ($value < 0) {
throw new InvalidArgumentException(sprintf('DiffSummary %s must be zero or greater.', $field));
}
}
$this->changedCount = $changedCount;
$this->addedCount = $addedCount;
$this->removedCount = $removedCount;
$this->unchangedCount = $unchangedCount;
$this->message = is_string($message) && trim($message) !== ''
? trim($message)
: null;
$this->hasRows = ($changedCount + $addedCount + $removedCount + $unchangedCount) > 0;
}
/**
* @param array<int, DiffRow> $rows
*/
public static function fromRows(array $rows, ?string $message = null): self
{
$counts = [
DiffRowStatus::Changed->value => 0,
DiffRowStatus::Added->value => 0,
DiffRowStatus::Removed->value => 0,
DiffRowStatus::Unchanged->value => 0,
];
foreach ($rows as $row) {
if (! $row instanceof DiffRow) {
continue;
}
$counts[$row->status->value]++;
}
if ($message === null) {
$message = match (true) {
$rows === [] => 'No diff data available.',
($counts[DiffRowStatus::Changed->value]
+ $counts[DiffRowStatus::Added->value]
+ $counts[DiffRowStatus::Removed->value]) === 0 => 'No changes detected.',
default => null,
};
}
return new self(
changedCount: $counts[DiffRowStatus::Changed->value],
addedCount: $counts[DiffRowStatus::Added->value],
removedCount: $counts[DiffRowStatus::Removed->value],
unchangedCount: $counts[DiffRowStatus::Unchanged->value],
message: $message,
);
}
public static function empty(?string $message = 'No diff data available.'): self
{
return new self(message: $message);
}
public function totalCount(): int
{
return $this->changedCount + $this->addedCount + $this->removedCount + $this->unchangedCount;
}
}

View File

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Support\Diff;
use BackedEnum;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Stringable;
final class ValueStringifier
{
public function stringify(mixed $value): string
{
if ($value === null) {
return '—';
}
if ($value instanceof BackedEnum) {
return $this->stringify($value->value);
}
if ($value instanceof Stringable) {
$value = (string) $value;
}
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_string($value)) {
return $value === '' ? '""' : $value;
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (is_array($value)) {
if ($value === []) {
return '[]';
}
if ($this->isInlineList($value)) {
return implode(', ', array_map(fn (mixed $item): string => $this->stringify($item), $value));
}
return $this->encodeStructuredValue($value);
}
if ($value instanceof Arrayable) {
return $this->stringify($value->toArray());
}
if ($value instanceof JsonSerializable) {
return $this->stringify($value->jsonSerialize());
}
return $this->encodeStructuredValue(['value' => get_debug_type($value)]);
}
/**
* @param array<int|string, mixed> $value
*/
private function encodeStructuredValue(array $value): string
{
return json_encode(
$this->normalizeStructuredValue($value),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
) ?: '{}';
}
/**
* @param array<int|string, mixed> $value
* @return array<int|string, mixed>
*/
private function normalizeStructuredValue(array $value): array
{
$normalized = [];
foreach ($value as $key => $item) {
if ($item instanceof BackedEnum) {
$normalized[$key] = $item->value;
continue;
}
if ($item instanceof Stringable) {
$normalized[$key] = (string) $item;
continue;
}
if ($item instanceof Arrayable) {
$normalized[$key] = $this->normalizeStructuredValue($item->toArray());
continue;
}
if ($item instanceof JsonSerializable) {
$json = $item->jsonSerialize();
$normalized[$key] = is_array($json)
? $this->normalizeStructuredValue($json)
: $json;
continue;
}
if (is_array($item)) {
$normalized[$key] = $this->normalizeStructuredValue($item);
continue;
}
if (is_object($item)) {
$normalized[$key] = get_debug_type($item);
continue;
}
$normalized[$key] = $item;
}
return array_is_list($normalized) ? array_values($normalized) : $normalized;
}
/**
* @param array<int, mixed> $value
*/
private function isInlineList(array $value): bool
{
if (! array_is_list($value)) {
return false;
}
foreach ($value as $item) {
if ($item !== null
&& ! is_scalar($item)
&& ! $item instanceof BackedEnum
&& ! $item instanceof Stringable) {
return false;
}
}
return true;
}
}

View File

View File

@ -0,0 +1,107 @@
# Shared Diff Presentation Foundation
## Purpose
Use the shared diff presentation foundation when a screen already has simple before/after data and needs:
- one consistent state vocabulary (`changed`, `unchanged`, `added`, `removed`)
- shared summary badges
- shared row rendering
- inline list add/remove rendering
- one display policy for nulls, booleans, scalars, lists, and compact structured values
This foundation is presentation-only. It does not compare domain records for you, does not fetch data, and does not replace domain-specific diff rules.
## Use It When
- a consumer already has keyed baseline/current values
- the comparison can be represented as row-level fields or simple scalar lists
- the screen benefits from reusable summary and row partials without needing a new Filament component type
## Do Not Use It When
- the screen needs token-level or line-level diff behavior
- the layout would become less clear if flattened into generic rows
- the screen relies on domain-specific compare semantics that are not simple value presentation
The current specialized renderers stay specialized unless a later spec chooses otherwise:
- `resources/views/filament/infolists/entries/normalized-diff.blade.php`
- `resources/views/filament/infolists/entries/rbac-role-definition-diff.blade.php`
- `resources/views/filament/infolists/entries/assignments-diff.blade.php`
- `resources/views/filament/infolists/entries/scope-tags-diff.blade.php`
## Presenter Usage
```php
use App\Support\Diff\DiffPresenter;
$presentation = app(DiffPresenter::class)->present(
baseline: $baseline,
current: $current,
changedKeys: $changedKeys,
labels: $labels,
meta: $meta,
);
```
Expected input shape:
- `baseline`: keyed prior values
- `current`: keyed current values
- `changedKeys`: optional presenter hint for keys that should be treated as changed
- `labels`: optional display labels keyed by field key
- `meta`: optional view-safe metadata keyed by field key
`DiffPresenter` returns a `DiffPresentation` containing:
- `summary`: `DiffSummary`
- `rows`: ordered `DiffRow` instances
## Blade Usage
```blade
@include('filament.partials.diff.summary-badges', [
'summary' => $presentation->summary,
])
@foreach ($presentation->rows as $row)
@include('filament.partials.diff.row', [
'row' => $row,
'compact' => false,
'dimUnchanged' => true,
])
@endforeach
```
`compact` reduces spacing for dense layouts. `dimUnchanged` keeps unchanged content quieter than meaningful changes.
## Standalone Stringifier Usage
If a specialized renderer should not adopt `DiffPresenter`, it can still reuse `ValueStringifier`:
```php
use App\Support\Diff\ValueStringifier;
$displayValue = app(ValueStringifier::class)->stringify($value);
```
Current shared formatting rules are:
- `null` -> `—`
- `true` / `false` -> `Enabled` / `Disabled`
- empty string -> `""`
- empty list -> `[]`
- simple scalar lists -> comma-separated values
- structured values -> compact JSON
## Adoption Checklist
Before adopting the foundation in a consumer spec:
1. Confirm the consumer already owns authorization and compare-shape decisions.
2. Confirm the data is simple row/list presentation, not specialized diff logic.
3. Add or update Pest coverage for presenter output and rendered partial output.
4. Keep destructive actions, routes, and resource/global-search behavior unchanged unless the consumer spec explicitly covers them.
See `specs/141-shared-diff-presentation-foundation/quickstart.md` for the matching feature-level quickstart.

View File

@ -0,0 +1,49 @@
@php
use App\Support\Diff\ValueStringifier;
$stringifier = app(ValueStringifier::class);
$labelClasses = 'text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400';
$mutedTextClasses = $dimUnchanged ? 'text-gray-500 dark:text-gray-400' : 'text-gray-900 dark:text-gray-100';
$itemPadding = $compact ? 'px-2 py-1 text-xs' : 'px-2.5 py-1 text-sm';
@endphp
<div class="space-y-3" aria-label="{{ $row->label }} list diff">
@if ($row->addedItems !== [])
<div class="space-y-2">
<div class="{{ $labelClasses }}">Added items</div>
<ul aria-label="Added items" class="flex flex-wrap gap-2">
@foreach ($row->addedItems as $item)
<li class="rounded-full bg-success-100 {{ $itemPadding }} text-success-800 dark:bg-success-500/20 dark:text-success-100">
{{ $stringifier->stringify($item) }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($row->removedItems !== [])
<div class="space-y-2">
<div class="{{ $labelClasses }}">Removed items</div>
<ul aria-label="Removed items" class="flex flex-wrap gap-2">
@foreach ($row->removedItems as $item)
<li class="rounded-full bg-danger-100 {{ $itemPadding }} text-danger-800 dark:bg-danger-500/20 dark:text-danger-100">
{{ $stringifier->stringify($item) }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($row->unchangedItems !== [])
<div class="space-y-2">
<div class="{{ $labelClasses }}">Unchanged items</div>
<ul aria-label="Unchanged items" class="flex flex-wrap gap-2">
@foreach ($row->unchangedItems as $item)
<li class="rounded-full bg-gray-100 {{ $itemPadding }} {{ $mutedTextClasses }} dark:bg-white/5">
{{ $stringifier->stringify($item) }}
</li>
@endforeach
</ul>
</div>
@endif
</div>

View File

@ -0,0 +1,43 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Diff\ValueStringifier;
use Illuminate\Support\Str;
$badge = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, $row->status);
$stringifier = app(ValueStringifier::class);
$rowId = 'diff-row-'.Str::slug($row->key.'-'.$row->status->value);
$padding = $compact ? 'p-3' : 'p-4';
@endphp
<article
aria-labelledby="{{ $rowId }}"
class="rounded-xl border border-success-200 bg-success-50/80 {{ $padding }} dark:border-success-500/30 dark:bg-success-500/10"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h4 id="{{ $rowId }}" class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row->label }}</h4>
<x-filament::badge :color="$badge->color" :icon="$badge->icon">{{ $badge->label }}</x-filament::badge>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300">Added value</p>
</div>
</div>
<div class="mt-3">
@if ($row->isListLike)
@include('filament.partials.diff.inline-list', [
'row' => $row,
'compact' => $compact,
'dimUnchanged' => $dimUnchanged,
])
@else
<dl>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Current</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $stringifier->stringify($row->newValue) }}</dd>
</div>
</dl>
@endif
</div>
</article>

View File

@ -0,0 +1,47 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Diff\ValueStringifier;
use Illuminate\Support\Str;
$badge = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, $row->status);
$stringifier = app(ValueStringifier::class);
$rowId = 'diff-row-'.Str::slug($row->key.'-'.$row->status->value);
$padding = $compact ? 'p-3' : 'p-4';
@endphp
<article
aria-labelledby="{{ $rowId }}"
class="rounded-xl border border-warning-200 bg-warning-50/70 {{ $padding }} dark:border-warning-500/30 dark:bg-warning-500/10"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h4 id="{{ $rowId }}" class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row->label }}</h4>
<x-filament::badge :color="$badge->color" :icon="$badge->icon">{{ $badge->label }}</x-filament::badge>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300">Changed value</p>
</div>
</div>
<div class="mt-3">
@if ($row->isListLike)
@include('filament.partials.diff.inline-list', [
'row' => $row,
'compact' => $compact,
'dimUnchanged' => $dimUnchanged,
])
@else
<dl class="grid gap-3 md:grid-cols-2">
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">From</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $stringifier->stringify($row->oldValue) }}</dd>
</div>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">To</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $stringifier->stringify($row->newValue) }}</dd>
</div>
</dl>
@endif
</div>
</article>

View File

@ -0,0 +1,43 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Diff\ValueStringifier;
use Illuminate\Support\Str;
$badge = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, $row->status);
$stringifier = app(ValueStringifier::class);
$rowId = 'diff-row-'.Str::slug($row->key.'-'.$row->status->value);
$padding = $compact ? 'p-3' : 'p-4';
@endphp
<article
aria-labelledby="{{ $rowId }}"
class="rounded-xl border border-danger-200 bg-danger-50/75 {{ $padding }} dark:border-danger-500/30 dark:bg-danger-500/10"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h4 id="{{ $rowId }}" class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row->label }}</h4>
<x-filament::badge :color="$badge->color" :icon="$badge->icon">{{ $badge->label }}</x-filament::badge>
</div>
<p class="text-xs text-gray-600 dark:text-gray-300">Removed value</p>
</div>
</div>
<div class="mt-3">
@if ($row->isListLike)
@include('filament.partials.diff.inline-list', [
'row' => $row,
'compact' => $compact,
'dimUnchanged' => $dimUnchanged,
])
@else
<dl>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Previous</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $stringifier->stringify($row->oldValue) }}</dd>
</div>
</dl>
@endif
</div>
</article>

View File

@ -0,0 +1,46 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Diff\ValueStringifier;
use Illuminate\Support\Str;
$badge = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, $row->status);
$stringifier = app(ValueStringifier::class);
$rowId = 'diff-row-'.Str::slug($row->key.'-'.$row->status->value);
$padding = $compact ? 'p-3' : 'p-4';
$bodyClasses = $dimUnchanged
? 'text-gray-600 dark:text-gray-400'
: 'text-gray-900 dark:text-gray-100';
@endphp
<article
aria-labelledby="{{ $rowId }}"
class="rounded-xl border border-gray-200 bg-white {{ $padding }} dark:border-white/10 dark:bg-gray-900"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<h4 id="{{ $rowId }}" class="text-sm font-semibold text-gray-950 dark:text-white">{{ $row->label }}</h4>
<x-filament::badge :color="$badge->color" :icon="$badge->icon">{{ $badge->label }}</x-filament::badge>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">No material change</p>
</div>
</div>
<div class="mt-3">
@if ($row->isListLike)
@include('filament.partials.diff.inline-list', [
'row' => $row,
'compact' => $compact,
'dimUnchanged' => $dimUnchanged,
])
@else
<dl>
<div>
<dt class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Value</dt>
<dd class="mt-1 text-sm {{ $bodyClasses }}">{{ $stringifier->stringify($row->newValue ?? $row->oldValue) }}</dd>
</div>
</dl>
@endif
</div>
</article>

View File

@ -0,0 +1,25 @@
@php
use App\Support\Diff\DiffRow;
use App\Support\Diff\DiffRowStatus;
$row = $row ?? null;
$compact = (bool) ($compact ?? false);
$dimUnchanged = (bool) ($dimUnchanged ?? true);
$partial = $row instanceof DiffRow
? match ($row->status) {
DiffRowStatus::Changed => 'filament.partials.diff.row-changed',
DiffRowStatus::Added => 'filament.partials.diff.row-added',
DiffRowStatus::Removed => 'filament.partials.diff.row-removed',
DiffRowStatus::Unchanged => 'filament.partials.diff.row-unchanged',
}
: null;
@endphp
@if (is_string($partial))
@include($partial, [
'row' => $row,
'compact' => $compact,
'dimUnchanged' => $dimUnchanged,
])
@endif

View File

@ -0,0 +1,52 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Diff\DiffRowStatus;
use App\Support\Diff\DiffSummary;
if (is_array($summary ?? null)) {
$summary = new DiffSummary(
changedCount: (int) ($summary['changedCount'] ?? $summary['changed'] ?? 0),
addedCount: (int) ($summary['addedCount'] ?? $summary['added'] ?? 0),
removedCount: (int) ($summary['removedCount'] ?? $summary['removed'] ?? 0),
unchangedCount: (int) ($summary['unchangedCount'] ?? $summary['unchanged'] ?? 0),
message: is_string($summary['message'] ?? null) ? $summary['message'] : null,
);
}
$summary = $summary instanceof DiffSummary ? $summary : DiffSummary::empty();
$states = [
DiffRowStatus::Changed,
DiffRowStatus::Added,
DiffRowStatus::Removed,
DiffRowStatus::Unchanged,
];
$counts = [
DiffRowStatus::Changed->value => $summary->changedCount,
DiffRowStatus::Added->value => $summary->addedCount,
DiffRowStatus::Removed->value => $summary->removedCount,
DiffRowStatus::Unchanged->value => $summary->unchangedCount,
];
@endphp
<div class="space-y-3" aria-label="Diff summary">
<div class="flex flex-wrap gap-2">
@foreach ($states as $state)
@php
$badge = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, $state);
@endphp
<x-filament::badge :color="$badge->color" :icon="$badge->icon">
{{ $counts[$state->value] }} {{ strtolower($badge->label) }}
</x-filament::badge>
@endforeach
</div>
@if ($summary->message)
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ $summary->message }}
</p>
@endif
</div>

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Shared Diff Presentation Foundation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-14
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation completed in one pass.
- No clarification questions were required.
- The feature-specific content stays presentation-focused and bounded to shared diff semantics, incremental adoption, accessibility, and reuse.

View File

@ -0,0 +1,122 @@
openapi: 3.1.0
info:
title: Shared Diff Presentation Foundation Contract
version: 0.1.0
description: >-
Internal presentation contract for Spec 141. This feature introduces no new HTTP
routes. The component schemas document the supported input and output shapes for the
shared diff presenter and Blade partials so follow-up consumer specs can adopt the
foundation consistently.
paths: {}
components:
schemas:
StructuredCompareInput:
type: object
additionalProperties: false
properties:
baseline:
type: object
additionalProperties: true
current:
type: object
additionalProperties: true
changedKeys:
type: array
items:
type: string
labels:
type: object
additionalProperties:
type: string
meta:
type: object
additionalProperties:
type: object
additionalProperties: true
required:
- baseline
- current
DiffRowStatus:
type: string
enum:
- unchanged
- changed
- added
- removed
DiffRow:
type: object
additionalProperties: false
properties:
key:
type: string
label:
type: string
status:
$ref: '#/components/schemas/DiffRowStatus'
oldValue:
nullable: true
newValue:
nullable: true
isListLike:
type: boolean
addedItems:
type: array
items: {}
removedItems:
type: array
items: {}
unchangedItems:
type: array
items: {}
meta:
type: object
additionalProperties: true
required:
- key
- label
- status
- isListLike
- addedItems
- removedItems
- unchangedItems
- meta
DiffSummary:
type: object
additionalProperties: false
properties:
changedCount:
type: integer
minimum: 0
addedCount:
type: integer
minimum: 0
removedCount:
type: integer
minimum: 0
unchangedCount:
type: integer
minimum: 0
hasRows:
type: boolean
message:
type: string
nullable: true
required:
- changedCount
- addedCount
- removedCount
- unchangedCount
- hasRows
DiffPresentation:
type: object
additionalProperties: false
properties:
summary:
$ref: '#/components/schemas/DiffSummary'
rows:
type: array
items:
$ref: '#/components/schemas/DiffRow'
required:
- summary
- rows

View File

@ -0,0 +1,126 @@
# Data Model: Shared Diff Presentation Foundation
## Overview
This feature adds presentation-layer value objects and support types only. No database schema or persisted business record changes are introduced.
## Entities
### 1. DiffRowStatus
- **Type**: PHP backed enum
- **Purpose**: Canonical presentation-state vocabulary for shared diff rendering
- **Values**:
- `unchanged`
- `changed`
- `added`
- `removed`
- **Rules**:
- Treated as a presentation concern only
- Drives summary counts, badge semantics, icons, and row-state rendering
- Must remain reusable outside any single resource or domain service
### 2. DiffRow
- **Type**: Immutable value object / DTO
- **Purpose**: Render-ready representation of one compare row
- **Fields**:
- `key`: string
- `label`: string
- `status`: `DiffRowStatus`
- `oldValue`: mixed
- `newValue`: mixed
- `isListLike`: bool
- `addedItems`: array<int, mixed>
- `removedItems`: array<int, mixed>
- `unchangedItems`: array<int, mixed>
- `meta`: array<string, scalar|array|null>
- **Validation rules**:
- `key` must be non-empty
- `label` must be non-empty
- `status` must be one of the enum values
- List fragment arrays must be present as empty arrays when not used
- `meta` must stay presentation-safe and serializable for view consumption
### 3. DiffSummary
- **Type**: Immutable value object / DTO
- **Purpose**: Shared summary counts and high-level message context for a rendered compare block
- **Fields**:
- `changedCount`: int
- `addedCount`: int
- `removedCount`: int
- `unchangedCount`: int
- `hasRows`: bool
- `message`: ?string
- **Validation rules**:
- Counts are non-negative integers
- `hasRows` is derived from total count
- `message` is optional and consumer-supplied or presenter-generated fallback copy
### 4. DiffPresentation
- **Type**: Immutable value object / DTO
- **Purpose**: Stable wrapper returned by `DiffPresenter` so consumers can pass one object containing summary and ordered rows into shared partials or downstream adapters
- **Fields**:
- `summary`: `DiffSummary`
- `rows`: array<int, DiffRow>
- **Validation rules**:
- `summary` must always be present
- `rows` must preserve deterministic ordering
- Empty compares are represented as an empty `rows` collection plus an explicit summary state
### 5. StructuredCompareInput
- **Type**: Internal input shape consumed by `DiffPresenter`
- **Purpose**: Minimal adaptation contract for simple compare payloads
- **Fields**:
- `baseline`: array<string, mixed>
- `current`: array<string, mixed>
- `changedKeys`: array<int, string>
- `labels`: array<string, string>
- `meta`: array<string, array<string, scalar|array|null>>
- **Rules**:
- `baseline` and `current` may each omit keys that exist on the other side
- `changedKeys` is optional hint data; presenter can still derive state from values
- `labels` is optional and must not block rendering when absent
### 6. InlineListFragments
- **Type**: Derived presentation fragment embedded in `DiffRow`
- **Purpose**: Simple inline list diff rendering for string or scalar lists
- **Fields**:
- `addedItems`: array<int, mixed>
- `removedItems`: array<int, mixed>
- `unchangedItems`: array<int, mixed>
- **Rules**:
- Built only when both values are simple list-like collections appropriate for inline comparison
- Ordering should remain deterministic for stable rendering and tests
- Not intended for token-level or line-level diff logic
## Relationships
- One `DiffSummary` describes a collection of `DiffRow` instances.
- Each `DiffRow` has exactly one `DiffRowStatus`.
- `DiffPresenter` converts one `StructuredCompareInput` into one `DiffPresentation`, containing one `DiffSummary` plus an ordered list of `DiffRow` instances.
- `ValueStringifier` formats `DiffRow.oldValue`, `DiffRow.newValue`, and inline list items for display, but can also be used independently by specialized views.
## State transitions
### Row classification
- Missing in baseline + present in current → `added`
- Present in baseline + missing in current → `removed`
- Present on both sides + materially equal → `unchanged`
- Present on both sides + materially different → `changed`
### Summary derivation
- Summary counts are derived from the final `DiffRow` list.
- Empty compare input produces either an empty summary or a no-data state, but never synthetic changes.
## Non-persistent boundaries
- None of these entities map to database tables.
- None of these entities may fetch models or rely on Livewire runtime state.
- None of these entities define authoritative business drift, compare, or restore logic.

View File

@ -0,0 +1,275 @@
# Implementation Plan: Shared Diff Presentation Foundation
**Branch**: `141-shared-diff-presentation-foundation` | **Date**: 2026-03-14 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/141-shared-diff-presentation-foundation/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce a small presentation-only diff foundation that standardizes change-state semantics, summary badges, row rendering, inline list rendering, and value stringification across simple compare surfaces. The implementation stays inside the existing Laravel/Filament monolith, adds reusable support classes under `app/Support/Diff`, reuses centralized badge semantics via `BadgeCatalog`, delivers low-magic Blade partials under `resources/views/filament/partials/diff`, and preserves specialized diff screens such as the current normalized diff until follow-up specs adopt only the parts that fit.
## Technical Context
**Language/Version**: PHP 8.4.15 / Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4
**Storage**: PostgreSQL via Laravel Sail for existing source records and JSON payloads; no new persistence introduced
**Testing**: Pest v4 feature and unit tests on PHPUnit 12
**Target Platform**: Laravel Sail web application with Filament admin panel at `/admin`
**Project Type**: Laravel monolith / Filament web application
**Performance Goals**: Presenter output is deterministic and lightweight for typical field-level compare surfaces, render-time logic stays server-side, no heavy client diffing is introduced, and inline list rendering remains readable for representative simple lists up to 25 items without additional network or JS cost
**Constraints**: No new routes, no Microsoft Graph calls, no database schema changes, no OperationRun usage, no authorization plane changes, no forced migration of specialized diff screens, and no generic full diff engine
**Scale/Scope**: One new shared support layer, one shared partial set, one internal contract artifact, lightweight developer guidance, and focused regression tests for presenter, stringifier, badge semantics, and shared Blade output
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: clarify what is “last observed” vs snapshots/backups
- Read/write separation: any writes require preview + confirmation + audit + tests
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; tenant-context routes (/admin/t/{tenant}/...) are tenant-scoped; canonical workspace-context routes under /admin remain tenant-safe; non-member tenant/workspace access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
- Workspace isolation: non-member workspace access is 404; tenant-plane routes require an established workspace context; workspace context switching is separate from Filament Tenancy
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
- UI naming (UI-NAMING-001): operator-facing labels use `Verb + Object`; scope (`Workspace`, `Tenant`) is never the primary action label; source/domain is secondary unless disambiguation is required; runs/toasts/audit prose use the same domain vocabulary; implementation-first terms do not appear in primary operator UI
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
- Filament UI UX-001 (Layout & IA): Create/Edit uses Main/Aside (3-col grid, Main=columnSpan(2), Aside=columnSpan(1)); all fields inside Sections/Cards (no naked inputs); View uses Infolists (not disabled edit forms); status badges use BADGE-001; empty states have specific title + explanation + 1 CTA; max 1 primary + 1 secondary header action; tables provide search/sort/filters for core dimensions; shared layout builders preferred for consistency
- Filament v5 / Livewire v4 compliance: design stays within the existing Filament v5 panel and Livewire v4 runtime
- Provider registration (`bootstrap/providers.php`): unchanged because no new panel/provider work is introduced
- Global search resource rule: unchanged because this feature adds no Resource and no global-search behavior
- Asset strategy: shared Blade partials and badge mappings use existing panel assets only; deploy still relies on the existing `php artisan filament:assets` step when Filament assets change generally
Gate result before research: PASS.
- Inventory-first: PASS — this feature only renders existing compare outputs and does not change the ownership boundary between inventory, snapshots, findings, restore previews, or baselines.
- Read/write separation: PASS — the feature is presentation-only with no mutation flow.
- Graph contract path: PASS — no Graph call is added.
- Deterministic capabilities: PASS — no capability derivation changes are introduced.
- RBAC-UX planes and isolation: PASS — existing authorization remains consumer-owned and unchanged.
- Workspace isolation: PASS — the foundation renders only data already authorized by the consuming surface.
- RBAC-UX destructive confirmation: PASS / N/A — no destructive action is introduced.
- RBAC-UX global search: PASS / N/A — no global search behavior changes.
- Tenant isolation: PASS — no tenant-scoped read/write rules change.
- Run observability and Ops-UX rules: PASS / N/A — no `OperationRun`, queue, or remote workflow is introduced.
- Data minimization: PASS — the support layer formats data passed in by consumers and does not fetch or expand raw source data.
- Badge semantics (BADGE-001): PASS — shared diff-state badges will use centralized badge semantics instead of new inline mappings.
- Filament UI Action Surface Contract: PASS / N/A — no Filament Resource, RelationManager, or Page action surface is added or changed in this feature.
- Filament UI UX-001: PASS / N/A — the feature adds reusable partials, not a new screen-level layout.
## Project Structure
### Documentation (this feature)
```text
specs/141-shared-diff-presentation-foundation/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── shared-diff-presentation.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Support/
│ ├── Diff/
│ │ ├── DiffRowStatus.php # NEW enum for shared change states
│ │ ├── DiffRow.php # NEW immutable row DTO
│ │ ├── DiffSummary.php # NEW summary DTO
│ │ ├── DiffPresentation.php # NEW required presenter result wrapper DTO
│ │ ├── DiffPresenter.php # NEW stateless simple-compare presenter
│ │ └── ValueStringifier.php # NEW shared display formatter
│ └── Badges/
│ ├── BadgeDomain.php # MODIFY if new diff-state domain(s) are added
│ ├── BadgeCatalog.php # MODIFY registration if new diff-state mapper(s) are added
│ └── Domains/
│ └── DiffRowStatusBadge.php # NEW centralized badge semantics for diff states
app/Filament/
├── Resources/
│ ├── FindingResource.php # FUTURE CONSUMER; likely untouched in this feature
│ ├── PolicyVersionResource.php # FUTURE CONSUMER; likely untouched in this feature
│ └── RestoreRunResource.php # FUTURE CONSUMER; likely untouched in this feature
resources/
└── views/
└── filament/
├── partials/
│ └── diff/
│ ├── summary-badges.blade.php # NEW reusable summary partial
│ ├── row.blade.php # NEW shared row entry partial
│ ├── row-changed.blade.php # NEW changed-row partial
│ ├── row-unchanged.blade.php # NEW unchanged-row partial
│ ├── row-added.blade.php # NEW added-row partial
│ ├── row-removed.blade.php # NEW removed-row partial
│ └── inline-list.blade.php # NEW simple list diff partial
└── infolists/
└── entries/
├── normalized-diff.blade.php # EXISTING specialized renderer, no migration in this feature
├── rbac-role-definition-diff.blade.php # EXISTING future adopter
├── assignments-diff.blade.php # EXISTING future adopter
└── scope-tags-diff.blade.php # EXISTING future adopter
docs/
└── ui/
└── shared-diff-presentation-foundation.md # NEW developer guidance for future consumers
tests/
├── Unit/
│ ├── Support/
│ │ └── Diff/
│ │ ├── DiffRowStatusTest.php # NEW enum/state coverage
│ │ ├── DiffRowTest.php # NEW DTO invariants coverage
│ │ ├── DiffPresenterTest.php # NEW presenter classification coverage
│ │ └── ValueStringifierTest.php # NEW formatting coverage
│ └── Badges/
│ └── DiffRowStatusBadgeTest.php # NEW centralized badge semantics coverage
└── Feature/
└── Support/
└── Diff/
├── SharedDiffSummaryPartialTest.php # NEW summary rendering coverage
├── SharedDiffRowPartialTest.php # NEW per-state row rendering coverage
└── SharedInlineListDiffPartialTest.php# NEW inline list rendering coverage
```
**Structure Decision**: Keep the feature inside the existing Laravel/Filament monolith. The implementation centers on a new `app/Support/Diff` presentation layer, centralized diff badge semantics in the existing badge system, reusable Blade partials under `resources/views/filament/partials/diff`, and focused Pest coverage without modifying consumer screens yet.
## Complexity Tracking
> No Constitution Check violations. No justifications needed.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Phase 0 — Research (DONE)
Output:
- `specs/141-shared-diff-presentation-foundation/research.md`
Key findings captured:
- Existing diff rendering is split across `normalized-diff`, `rbac-role-definition-diff`, `assignments-diff`, and `scope-tags-diff` Blade templates, with duplicated value formatting and state semantics.
- `VersionDiff` already emits simple `added`, `removed`, and `changed` buckets suitable for future adaptation into the shared presenter.
- `RestoreDiffGenerator` already produces policy-level preview summaries and per-policy diff payloads that can adopt the foundation later without changing restore business rules.
- The repo already centralizes status-like badge semantics through `BadgeCatalog`, making it the right path for shared diff-state badges.
- The current `normalized-diff` renderer contains script-aware and grouped diff logic that should remain specialized in this feature.
## Phase 1 — Design & Contracts (DONE)
Outputs:
- `specs/141-shared-diff-presentation-foundation/data-model.md`
- `specs/141-shared-diff-presentation-foundation/contracts/shared-diff-presentation.openapi.yaml`
- `specs/141-shared-diff-presentation-foundation/quickstart.md`
Design highlights:
- Add a presentation-only support layer under `app/Support/Diff` with immutable row and summary types, a small stateless presenter, and a reusable value stringifier.
- Reuse the existing badge catalog to centralize diff-state summary semantics instead of hardcoding colors in Blade.
- Deliver the UI through composable Blade partials under `resources/views/filament/partials/diff` so future consumers can adopt the foundation from existing `ViewEntry` and Blade workflows.
- Keep specialized renderers such as `normalized-diff` out of scope while making RBAC, assignments, scope tags, restore preview/results, and future baseline/evidence screens clear follow-on adopters.
## Phase 1 — Agent Context Update (DONE)
Run:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
### Step 1 — Introduce shared presentation primitives
Goal: implement the shared row-state vocabulary, row/summary DTOs, and reusable stringifier that satisfy FR-001 through FR-009.
Changes:
- Create `DiffRowStatus` as the canonical presentation enum.
- Add immutable `DiffRow`, `DiffSummary`, and optional result-wrapper DTOs.
- Implement `ValueStringifier` for nulls, booleans, scalars, simple lists, and compact structured values.
Tests:
- Add unit coverage for enum values and DTO invariants.
- Add unit coverage for stringification across null, boolean, scalar, empty list, non-empty list, and compact associative arrays.
### Step 2 — Build the stateless simple-compare presenter
Goal: implement FR-003 through FR-007, FR-012, and FR-016 through FR-017.
Changes:
- Add `DiffPresenter` that accepts baseline/current associative data plus optional changed keys, labels, and metadata.
- Classify rows as unchanged, changed, added, or removed.
- Detect simple list-like values and prepare inline list fragments deterministically.
- Produce summary counts derived from the row collection.
Tests:
- Add unit coverage for unchanged, changed, added-only, removed-only, mixed-order, and list-value comparisons.
- Add unit coverage proving presenter output remains deterministic when optional labels or metadata are absent.
### Step 3 — Centralize diff-state badge semantics
Goal: satisfy FR-002, FR-010, FR-013, and BADGE-001 alignment.
Changes:
- Add a new badge domain and mapper for shared diff states if the existing badge catalog needs explicit support.
- Ensure shared summary and row partials consume centralized diff-state label and color semantics rather than inline mappings.
Tests:
- Add unit coverage for diff-state badge labels, colors, and unknown fallback behavior if applicable.
### Step 4 — Create shared Blade partials
Goal: implement FR-010 through FR-015.
Changes:
- Add summary badges partial with no-data fallback.
- Add shared row partials for changed, unchanged, added, and removed states.
- Add inline list diff partial for medium-sized simple lists.
- Ensure row rendering supports compact mode, dimmed unchanged content, semantic labels, and dark-mode-safe classes.
Tests:
- Add view-level tests for summary rendering with expected counts and fallback.
- Add view-level tests for each row state, inline list rendering, and a representative 25-item list fixture.
- Add explicit assertions that state is conveyed with text/icons, semantic labels, and logical reading order rather than color alone.
### Step 5 — Add lightweight developer guidance
Goal: implement FR-018 and make future adoption predictable.
Changes:
- Add concise developer-facing guidance explaining what the foundation is for, what it is not for, how to use the presenter, when to use the stringifier standalone, and when a specialized diff view should stay specialized.
- Keep the guidance lightweight and aligned with existing repo documentation norms.
Tests:
- No automated test required for prose itself; verify examples match actual class and partial names during implementation.
### Step 6 — Prepare consumer-ready regression safety
Goal: ensure the foundation is ready for follow-up specs without forcing migration now.
Changes:
- Keep existing diff consumers untouched unless a low-risk local reuse is needed for validation.
- Verify the new foundation does not require route, authorization, or persistence changes.
- Document early adopter targets: RBAC diff first, then assignments/scope tags, then baseline/evidence/simple compare surfaces.
Tests:
- Run focused Pest coverage for the new support layer and partials.
- Run Pint on dirty files through Sail during implementation.
## Constitution Check (Post-Design)
Re-check result: PASS.
- Livewire v4.0+ compliance: preserved because the feature remains within the existing Filament v5 and Livewire v4 stack.
- Provider registration location: unchanged; no panel or provider changes are introduced, so `bootstrap/providers.php` remains the correct registration location.
- Global-search resource rule: unchanged because no Resource or global-search behavior is added.
- Destructive actions and authorization: unchanged because this feature adds no mutation action surface; future consumers remain responsible for confirmation and server-side enforcement.
- Asset strategy: no new heavy assets; shared Blade partials rely on existing panel assets, and the existing deployment step for `php artisan filament:assets` remains sufficient.

View File

@ -0,0 +1,69 @@
# Quickstart: Shared Diff Presentation Foundation
## Purpose
Use this foundation when a screen already has simple structured compare data and needs consistent change-state semantics, summary badges, row rendering, inline list add/remove display, and shared value formatting.
Do not use it to replace specialized normalized diff, script diff, or other advanced compare experiences that need domain-specific layout or token/line-level logic.
## Prerequisites
- The consuming surface already has authorized baseline/current compare data.
- The values can be represented as simple keyed fields or simple lists.
- The consumer does not need a new route, new persistence model, or new compare engine.
## Basic adoption flow
1. Build or adapt a simple compare input:
- Baseline associative values
- Current associative values
- Optional changed keys
- Optional label map
2. Pass that input into `DiffPresenter`.
3. Render the returned summary with the shared summary partial.
4. Loop over the returned rows and render each row through the shared row partial.
5. If a row is list-like, let the row partial delegate to the inline list diff partial.
## Example adoption pattern
### Presenter usage
```php
$presentation = app(DiffPresenter::class)->present(
baseline: $baseline,
current: $current,
changedKeys: $changedKeys,
labels: $labels,
);
```
### Blade usage
```blade
@include('filament.partials.diff.summary-badges', ['summary' => $presentation->summary])
@foreach ($presentation->rows as $row)
@include('filament.partials.diff.row', [
'row' => $row,
'compact' => false,
'dimUnchanged' => true,
])
@endforeach
```
## Standalone stringifier usage
If a specialized view should not use `DiffPresenter`, it can still reuse `ValueStringifier` to standardize how nulls, booleans, scalars, simple lists, and compact structured values appear.
## When not to use the foundation
- The screen requires line-level or token-level script diff behavior.
- The screen needs heavily specialized comparison layout that would become less clear if flattened into generic rows.
- The screen would need new domain comparison rules rather than presentation reuse.
## Testing expectations
- Add Pest unit tests for any new presenter or stringifier behavior.
- Add view-level tests for shared partial usage and state-specific rendering.
- If a consumer adds a new diff-state badge domain mapping, cover it with badge tests.
- Keep `docs/ui/shared-diff-presentation-foundation.md` aligned with the same presenter and partial names when adoption guidance changes.

View File

@ -0,0 +1,66 @@
# Research: Shared Diff Presentation Foundation
## Decision 1: Keep the foundation in a dedicated presentation support layer under `app/Support/Diff`
- **Decision**: Introduce the shared diff primitives in `app/Support/Diff/` as presentation-only support classes: `DiffRowStatus`, `DiffRow`, `DiffSummary`, `DiffPresenter`, and `ValueStringifier`.
- **Rationale**: The repo already uses support-oriented layers for reusable UI and rendering concerns, while current diff behavior is fragmented across Blade templates and a few domain services. A support-layer location makes the new foundation easy to consume from Findings, PolicyVersion, RestoreRun, and future baseline/evidence screens without implying domain ownership or database coupling.
- **Alternatives considered**:
- Put the foundation inside `app/Services/Intune/`: rejected because the consumers are broader than Intune policy versioning and restore preview flows.
- Put the foundation inside one Filament resource: rejected because the feature is intentionally consumer-agnostic.
- Build a generic diff engine package: rejected because the spec explicitly forbids a full generic diff framework.
## Decision 2: Treat existing compare producers as inputs, not migration targets
- **Decision**: The foundation will adapt simple structured compare payloads from existing producers such as `VersionDiff`, `RestoreDiffGenerator`, and diff payloads already rendered in Findings and PolicyVersion surfaces, while leaving specialized normalized diff rendering out of scope for now.
- **Rationale**: Current compare outputs already vary: `normalized-diff` renders grouped dot-path changes with script-specific handling, RBAC diff uses side-by-side baseline/current cards, assignments and scope tags use custom section layouts, and restore preview/results wrap policy-level diff summaries. Forcing them into one canonical business payload now would add risk and block incremental adoption.
- **Alternatives considered**:
- Rewrite all existing compare producers to emit one canonical format: rejected because it would expand the feature into a cross-domain refactor.
- Limit the feature to one local consumer such as RBAC diff only: rejected because the product needs a reusable base for multiple upcoming compare surfaces.
## Decision 3: Centralize state badge semantics through the existing badge catalog
- **Decision**: Map shared diff states and summary badges through the existing badge catalog pattern rather than hardcoding colors in new partials.
- **Rationale**: The repo already centralizes badge semantics via `BadgeCatalog`, `BadgeDomain`, and focused badge tests. Existing diff templates still inline `success`, `danger`, and `warning` decisions; this feature is the right place to stop that drift for the shared foundation. Centralizing diff-state badge semantics aligns with BADGE-001 and keeps future consumers consistent.
- **Alternatives considered**:
- Inline state-to-color mappings in each partial: rejected because it recreates the current inconsistency problem.
- Use Tailwind-only classes without a semantic mapping layer: rejected because the repo already standardizes status-like badges centrally.
## Decision 4: Use composable Blade partials under `resources/views/filament/partials/diff`
- **Decision**: Build low-magic Blade partials for summary badges, generic row rendering by state, and inline list diff rendering under `resources/views/filament/partials/diff/`.
- **Rationale**: Current consumers already use `ViewEntry` and Blade partials as the main extension point. Adding composable partials fits existing Filament patterns, works inside infolists and custom page content, and avoids introducing a custom Filament field or entry framework before it is justified.
- **Alternatives considered**:
- Introduce a custom Filament field/entry component framework now: rejected because the spec explicitly says to avoid that unless Blade composition proves insufficient later.
- Create a single master diff page component: rejected because consumers vary between summary-first views, side-by-side views, and specialized normalized diffs.
## Decision 5: Keep Blade presentational and move formatting/row shaping into PHP
- **Decision**: Put row-state classification, list-fragment preparation, and value stringification into PHP support classes, leaving Blade responsible only for rendering.
- **Rationale**: Existing templates duplicate stringification rules and state grouping logic. The new foundation exists to improve consistency and testability, so the plan shifts logic into unit-testable support classes while keeping the partial APIs simple.
- **Alternatives considered**:
- Continue using per-template closures for formatting: rejected because it duplicates logic and weakens regression coverage.
- Put all formatting in one presenter and no standalone stringifier: rejected because the spec requires value formatting to be reusable even without the row presenter.
## Decision 6: Define a small internal contract rather than a new HTTP API
- **Decision**: Document the foundation contract as an internal OpenAPI-style schema with no new paths and component schemas describing presenter input, row output, summary counts, and inline list fragments.
- **Rationale**: The feature adds no routes, but SpecKit still expects a contract artifact. A pathless schema captures the internal handoff clearly for follow-up specs and keeps the feature aligned with its “no new routes” boundary.
- **Alternatives considered**:
- Skip the contracts artifact entirely: rejected because the planning workflow expects one.
- Invent a fake endpoint solely for the plan: rejected because it would contradict the specification.
## Decision 7: Cover rendering behavior with focused Pest unit and view-level tests
- **Decision**: Add unit tests for enum, row, presenter, and stringifier behavior plus view-level tests that render the shared partials with representative fixtures and assert accessible state cues, summary counts, dark-mode-safe class presence, and inline list diff output.
- **Rationale**: The spec requires both PHP-layer determinism and reusable rendering safety. The repo already uses Pest heavily, including badge mapping tests and feature tests around Filament rendering. This keeps the foundation verifiable without a full browser workflow.
- **Alternatives considered**:
- Rely on manual UI verification only: rejected because the foundation is intended for repeated reuse.
- Add browser tests first: rejected because the behavior under test is mostly server-side presentation and Blade output.
## Key integration observations
- `app/Services/Intune/VersionDiff.php` already produces simple `added`, `removed`, and `changed` buckets for normalized comparisons.
- `app/Services/Intune/RestoreDiffGenerator.php` already computes policy-level preview summaries and truncation metadata that a consumer can adapt into the shared presentation layer.
- `resources/views/filament/infolists/entries/normalized-diff.blade.php` contains substantial specialized logic including stringification, grouped rows, and script-aware diff handling; it should remain specialized in this feature.
- `resources/views/filament/infolists/entries/rbac-role-definition-diff.blade.php`, `assignments-diff.blade.php`, and `scope-tags-diff.blade.php` show three separate simple diff rendering patterns that are strong future adoption candidates.
- `app/Support/Badges/BadgeCatalog.php` and existing badge tests provide the canonical place to centralize diff state badge semantics.

View File

@ -0,0 +1,121 @@
title + explanation + exactly 1 CTA, and tables provide search/sort/filters for core dimensions.
# Feature Specification: Shared Diff Presentation Foundation
**Feature Branch**: `141-shared-diff-presentation-foundation`
**Created**: 2026-03-14
**Status**: Draft
**Input**: User description: "Introduce a small reusable diff presentation foundation for TenantPilot so diff UIs across findings, baseline/drift, policy compare, and future evidence screens can share consistent change states, summary badges, list diff rendering, and value formatting without building a full generic diff framework or refactoring all existing diff views at once."
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: Existing diff-capable admin and workspace comparison surfaces may consume the foundation over time; this spec introduces no new routes.
- **Data Ownership**: Existing findings, baseline comparisons, restore previews, policy comparisons, and related comparison payloads remain owned by their current domain services and records. This feature introduces presentation-only shared assets and does not create or persist new business data.
- **RBAC**: Existing membership and capability checks remain unchanged. The foundation only renders data already authorized by consuming surfaces and must not widen visibility.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Review Changes Consistently (Priority: P1)
An operator reviewing a supported compare surface can immediately tell which values were added, removed, changed, or left unchanged because every adopting surface uses the same shared presentation language.
**Why this priority**: Consistent change semantics are the core value of the feature. Without them, every compare view continues to create operator confusion and duplicate UI decisions.
**Independent Test**: Can be fully tested by rendering representative compare inputs that contain all four change states and verifying that summary counts and detailed rows express the same states consistently.
**Acceptance Scenarios**:
1. **Given** a compare result with added, removed, changed, and unchanged values, **When** an adopting surface renders the result, **Then** each state is presented using the same shared labels and visual semantics.
2. **Given** a compare result with no differences, **When** an adopting surface renders the result, **Then** the summary and row presentation clearly communicate that no changes were found.
---
### User Story 2 - Scan Diff Details Efficiently (Priority: P2)
An operator can move from high-level summary counts to row-level details without re-learning each screen's visual language, and unchanged content remains quieter than meaningful changes.
**Why this priority**: After consistent change states exist, the next highest value is faster review of the data that actually changed.
**Independent Test**: Can be tested by rendering summary and row views for mixed compare data and confirming that changed content is visually distinct, unchanged content is de-emphasized, and list-style differences remain readable.
**Acceptance Scenarios**:
1. **Given** a compare result with many unchanged values and a small number of differences, **When** the rows are rendered, **Then** changed content receives stronger emphasis than unchanged content.
2. **Given** a compare result that includes simple lists, **When** the list detail is rendered, **Then** added and removed items are clearly distinguishable without requiring raw side-by-side inspection.
---
### User Story 3 - Adopt the Foundation Incrementally (Priority: P3)
A product team adding or upgrading a simple compare surface can reuse the shared presentation foundation without being forced to migrate unrelated or specialized diff views at the same time.
**Why this priority**: Incremental adoption reduces delivery risk and avoids forcing highly specialized compare experiences into an overgeneralized model.
**Independent Test**: Can be tested by validating that the foundation supports simple structured compare inputs while leaving existing specialized diff screens unchanged unless a follow-up feature chooses to adopt it.
**Acceptance Scenarios**:
1. **Given** an existing specialized diff screen outside the adoption scope, **When** this feature is delivered, **Then** that screen remains unchanged and operational.
2. **Given** a future simple compare surface, **When** it adopts the foundation, **Then** it can use shared summary, row, and value presentation behavior without changing underlying comparison ownership.
### Edge Cases
- What happens when both sides of a compare input are empty or absent? The foundation must provide a clear no-differences or no-data outcome without rendering misleading change states.
- How does the system handle missing values versus explicit empty values? The foundation must keep these cases distinguishable where that distinction matters to operator review.
- What happens when values include mixed types such as nulls, booleans, scalars, lists, or compact structured objects? The foundation must render them deterministically using one shared display vocabulary.
- How does the system handle long but still simple lists? The foundation must keep added and removed items readable without depending on heavy client-side behavior.
## Requirements *(mandatory)*
This feature is presentation-only. It introduces no Microsoft Graph calls, no write workflow, no queued or scheduled work, no OperationRun usage, and no new authorization rules.
This feature does not add or modify a Filament Resource, RelationManager, or Page action surface. It provides reusable presentation primitives that future consumer specs may adopt. Any later consumer spec that changes actions or page layouts must supply its own UI Action Matrix and UX-001 review.
### Functional Requirements
- **FR-001**: The system MUST define one shared change-state vocabulary for supported diff presentation consisting of changed, unchanged, added, and removed.
- **FR-002**: The system MUST apply that shared vocabulary consistently to both summary presentation and row-level detail presentation.
- **FR-003**: The system MUST provide a reusable row-oriented presentation model that can represent a field label, a change state, prior and current values, and optional list-oriented detail needed for simple add/remove display.
- **FR-004**: The system MUST keep the row-oriented presentation model read-only after construction so consumers receive deterministic output.
- **FR-005**: The system MUST provide a stateless presentation adapter that converts simple structured compare inputs into a deterministic sequence of row presentations.
- **FR-006**: The presentation adapter MUST support unchanged rows, changed rows, added-only rows, and removed-only rows.
- **FR-007**: The presentation adapter MUST remain presentation-focused and MUST NOT become the authoritative source of business comparison, drift, restore, or baseline rules.
- **FR-008**: The system MUST provide one shared value-display policy for null values, booleans, scalars, simple lists, and compact structured values used in supported compare views.
- **FR-009**: The shared value-display policy MUST avoid ad hoc stringification rules inside individual templates.
- **FR-010**: The system MUST provide reusable summary presentation that can show counts for changed, added, removed, and unchanged states, including a clear fallback for empty or unavailable summary data.
- **FR-011**: The system MUST provide reusable row presentation primitives for changed, unchanged, added, and removed states without forcing every consumer into one full-page layout.
- **FR-012**: The system MUST support simple inline list diff presentation that makes added and removed items explicit and allows unchanged items to appear in a quieter style when shown.
- **FR-013**: The system MUST express change states using more than color alone so supported diff views remain understandable for operators using assistive technologies or low-contrast conditions.
- **FR-014**: The system MUST preserve legibility in light and dark themes for all shared diff state treatments.
- **FR-015**: The system MUST preserve logical reading order and meaningful labeling so row content remains understandable to screen readers and keyboard-only users.
- **FR-016**: The system MUST be adoptable incrementally by future compare surfaces without requiring all existing diff screens to migrate in this feature.
- **FR-017**: The system MUST leave existing specialized compare experiences unchanged unless a later feature explicitly opts them into the shared foundation.
- **FR-018**: The system MUST include developer guidance that explains when to use the shared foundation and when a specialized diff view should remain specialized.
## UI Action Matrix *(mandatory when Filament is changed)*
Not applicable for this feature. The foundation introduces shared rendering assets only and does not add or modify a specific Filament Resource, RelationManager, or Page action surface.
### Key Entities *(include if feature involves data)*
- **Diff Presentation State**: The user-facing classification that tells an operator whether a value is changed, unchanged, added, or removed.
- **Diff Presentation Row**: A render-ready representation of one reviewed field or item, including its label, current state, and the values needed to explain the comparison.
- **Diff Summary**: A compact count-based overview of how many rows fall into each shared presentation state.
- **Structured Compare Input**: Existing before-and-after data already produced by a consumer surface and adapted into the shared presentation model without changing business ownership.
## Assumptions
- Existing compare-producing services already provide enough structured data for simple field and list comparisons, even if their internal shapes differ.
- Highly specialized diff experiences can continue using custom rendering where simple row-based presentation would reduce clarity.
- Consumer adoption will happen in follow-up features rather than as a mandatory migration in this specification.
- Existing authorization and tenant isolation remain the responsibility of the consuming surface, not the shared presentation foundation.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In acceptance review with representative compare inputs, 100% of supported shared diff presentations express added, removed, changed, and unchanged states using one consistent vocabulary in both summary and detail views.
- **SC-002**: In acceptance review with representative mixed compare inputs, summary counts match the detailed rows for every shared diff state with no mismatches.
- **SC-003**: Operators reviewing a supported simple diff can distinguish change state without relying on color alone in all acceptance scenarios.
- **SC-004**: Supported simple list comparisons with up to 25 items remain readable enough for an operator to identify added and removed entries without switching to a raw payload view.
- **SC-005**: Follow-up simple compare features can adopt the shared presentation foundation without redefining state labels, summary vocabulary, or value-display rules.

View File

@ -0,0 +1,192 @@
# Tasks: Shared Diff Presentation Foundation
**Input**: Design documents from `/specs/141-shared-diff-presentation-foundation/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/shared-diff-presentation.openapi.yaml, quickstart.md
**Tests**: Tests are REQUIRED for this feature because it adds runtime presentation behavior, shared Blade partials, centralized badge semantics, and reusable PHP support logic.
**Operations**: No new `OperationRun`, queued workflow, remote call, or audit-log mutation flow is introduced.
**RBAC**: Existing workspace and tenant authorization remain unchanged. The foundation must render only data already authorized by consuming surfaces and must not add any new scope behavior.
**Filament UI**: The feature adds shared Blade partials only. It does not modify a specific Filament Resource, RelationManager, or Page action surface in this spec.
**Badges**: Shared diff-state summary and row badges must use centralized semantics through `BadgeCatalog` and `BadgeRenderer` rather than inline mappings.
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Create the scaffolding for shared support classes, partials, and Pest coverage.
- [X] T001 Create unit-test scaffolds in tests/Unit/Support/Diff/DiffRowStatusTest.php, tests/Unit/Support/Diff/DiffRowTest.php, tests/Unit/Support/Diff/DiffPresenterTest.php, and tests/Unit/Support/Diff/ValueStringifierTest.php
- [X] T002 [P] Create badge and feature-test scaffolds in tests/Unit/Badges/DiffRowStatusBadgeTest.php, tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php, tests/Feature/Support/Diff/SharedDiffRowPartialTest.php, and tests/Feature/Support/Diff/SharedInlineListDiffPartialTest.php
- [X] T003 [P] Create shared partial stubs in resources/views/filament/partials/diff/summary-badges.blade.php, resources/views/filament/partials/diff/row.blade.php, resources/views/filament/partials/diff/row-changed.blade.php, resources/views/filament/partials/diff/row-unchanged.blade.php, resources/views/filament/partials/diff/row-added.blade.php, resources/views/filament/partials/diff/row-removed.blade.php, and resources/views/filament/partials/diff/inline-list.blade.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared namespace, DTO shells, and badge plumbing every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Create support-layer skeletons in app/Support/Diff/DiffRowStatus.php, app/Support/Diff/DiffRow.php, app/Support/Diff/DiffSummary.php, app/Support/Diff/DiffPresentation.php, app/Support/Diff/DiffPresenter.php, and app/Support/Diff/ValueStringifier.php
- [X] T005 [P] Register shared diff badge scaffolding in app/Support/Badges/BadgeDomain.php, app/Support/Badges/BadgeCatalog.php, and app/Support/Badges/Domains/DiffRowStatusBadge.php
**Checkpoint**: Shared diff infrastructure exists and user-story work can begin.
---
## Phase 3: User Story 1 - Review Changes Consistently (Priority: P1) 🎯 MVP
**Goal**: Give all simple diff consumers one shared state vocabulary, deterministic row model, summary counts, and row rendering primitives.
**Independent Test**: Render a representative compare payload with added, removed, changed, and unchanged values and verify the presenter output plus summary and row partials use one consistent vocabulary and matching counts.
### Tests for User Story 1
- [X] T006 [P] [US1] Add enum and DTO invariant coverage in tests/Unit/Support/Diff/DiffRowStatusTest.php and tests/Unit/Support/Diff/DiffRowTest.php
- [X] T007 [P] [US1] Add row-classification and summary-derivation coverage in tests/Unit/Support/Diff/DiffPresenterTest.php
- [X] T008 [P] [US1] Add summary and row partial rendering coverage in tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php and tests/Feature/Support/Diff/SharedDiffRowPartialTest.php
### Implementation for User Story 1
- [X] T009 [US1] Implement immutable row, summary, and presentation value objects in app/Support/Diff/DiffRow.php, app/Support/Diff/DiffSummary.php, and app/Support/Diff/DiffPresentation.php
- [X] T010 [US1] Implement the canonical diff-state enum and centralized badge mapping in app/Support/Diff/DiffRowStatus.php, app/Support/Badges/BadgeDomain.php, app/Support/Badges/BadgeCatalog.php, and app/Support/Badges/Domains/DiffRowStatusBadge.php
- [X] T011 [US1] Implement baseline/current row classification and summary derivation in app/Support/Diff/DiffPresenter.php
- [X] T012 [US1] Implement shared summary and state-specific row partials in resources/views/filament/partials/diff/summary-badges.blade.php, resources/views/filament/partials/diff/row.blade.php, resources/views/filament/partials/diff/row-changed.blade.php, resources/views/filament/partials/diff/row-unchanged.blade.php, resources/views/filament/partials/diff/row-added.blade.php, and resources/views/filament/partials/diff/row-removed.blade.php
**Checkpoint**: The shared diff foundation can render consistent change states and summaries for simple compare data.
---
## Phase 4: User Story 2 - Scan Diff Details Efficiently (Priority: P2)
**Goal**: Make row details easier to scan by standardizing value formatting, inline list rendering, quieter unchanged content, and accessible state cues.
**Independent Test**: Render mixed scalar and list-based compare rows and verify nulls, booleans, and simple arrays format consistently, list adds or removes are readable inline, unchanged content is quieter, and state remains understandable without color alone.
### Tests for User Story 2
- [X] T013 [P] [US2] Add value-formatting coverage for null, boolean, scalar, empty-list, non-empty-list, and associative-array cases in tests/Unit/Support/Diff/ValueStringifierTest.php
- [X] T014 [P] [US2] Add list-fragment and metadata-fallback presenter coverage in tests/Unit/Support/Diff/DiffPresenterTest.php
- [X] T015 [P] [US2] Add inline-list rendering coverage with a representative 25-item fixture plus muted-unchanged, semantic-label, and logical-reading-order assertions in tests/Feature/Support/Diff/SharedInlineListDiffPartialTest.php and tests/Feature/Support/Diff/SharedDiffRowPartialTest.php
### Implementation for User Story 2
- [X] T016 [US2] Implement shared display formatting in app/Support/Diff/ValueStringifier.php
- [X] T017 [US2] Implement list-like fragment preparation and metadata-safe fallbacks in app/Support/Diff/DiffPresenter.php and app/Support/Diff/DiffRow.php
- [X] T018 [US2] Implement the inline list diff partial and row-level list delegation in resources/views/filament/partials/diff/inline-list.blade.php and resources/views/filament/partials/diff/row.blade.php
- [X] T019 [US2] Add compact display, dimmed unchanged styling, semantic labels or icons, explicit reading-order-safe markup, and dark-mode-safe classes in resources/views/filament/partials/diff/summary-badges.blade.php, resources/views/filament/partials/diff/row-changed.blade.php, resources/views/filament/partials/diff/row-unchanged.blade.php, resources/views/filament/partials/diff/row-added.blade.php, and resources/views/filament/partials/diff/row-removed.blade.php
**Checkpoint**: Shared rows are readable, accessible, and efficient to scan for both scalar and simple list diffs.
---
## Phase 5: User Story 3 - Adopt the Foundation Incrementally (Priority: P3)
**Goal**: Make the foundation safe for future consumer specs by documenting how to adopt it, preserving specialized views, and proving no-data and fallback behavior stay consumer-safe.
**Independent Test**: Validate that the presenter and partials handle empty or sparse compare inputs cleanly, diff-state badge fallback remains safe, and the developer guidance explains when to use the shared foundation versus a specialized renderer.
### Tests for User Story 3
- [X] T020 [P] [US3] Add no-data and sparse-input fallback coverage in tests/Unit/Support/Diff/DiffPresenterTest.php and tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php
- [X] T021 [P] [US3] Add diff-state badge fallback coverage in tests/Unit/Badges/DiffRowStatusBadgeTest.php
### Implementation for User Story 3
- [X] T022 [US3] Finalize consumer-safe fallback behavior in app/Support/Diff/DiffPresentation.php, app/Support/Diff/DiffSummary.php, and resources/views/filament/partials/diff/summary-badges.blade.php for empty or missing compare data
- [X] T023 [US3] Add developer adoption guidance in docs/ui/shared-diff-presentation-foundation.md covering presenter usage, standalone stringifier usage, shared partial usage, and non-adoption cases
- [X] T024 [US3] Align the developer guidance with specs/141-shared-diff-presentation-foundation/quickstart.md and preserve non-adoption boundaries for resources/views/filament/infolists/entries/normalized-diff.blade.php, resources/views/filament/infolists/entries/rbac-role-definition-diff.blade.php, resources/views/filament/infolists/entries/assignments-diff.blade.php, and resources/views/filament/infolists/entries/scope-tags-diff.blade.php
**Checkpoint**: The foundation is documented, consumer-safe, and ready for follow-up adoption specs without forcing current specialized views to migrate.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final verification and implementation hygiene across all stories.
- [X] T025 [P] Run the focused shared diff foundation test pack with vendor/bin/sail artisan test --compact tests/Unit/Support/Diff/DiffRowStatusTest.php tests/Unit/Support/Diff/DiffRowTest.php tests/Unit/Support/Diff/DiffPresenterTest.php tests/Unit/Support/Diff/ValueStringifierTest.php tests/Unit/Badges/DiffRowStatusBadgeTest.php tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php tests/Feature/Support/Diff/SharedDiffRowPartialTest.php tests/Feature/Support/Diff/SharedInlineListDiffPartialTest.php
- [X] T026 [P] Run formatting on changed files with vendor/bin/sail bin pint --dirty --format agent
- [X] T027 [P] Validate the implementation examples against specs/141-shared-diff-presentation-foundation/quickstart.md and docs/ui/shared-diff-presentation-foundation.md
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the MVP slice.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because it extends the same presenter and row partials with formatting and list behavior.
- **User Story 3 (Phase 5)**: Depends on User Stories 1 and 2 because the documentation and fallback guarantees should reflect the final shared API and rendering behavior.
- **Polish (Phase 6)**: Depends on all selected user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: No dependency on other stories after Foundational.
- **User Story 2 (P2)**: Builds directly on the shared presenter and row partials from US1.
- **User Story 3 (P3)**: Builds on the completed presenter, badge semantics, and partial behavior from US1 and US2.
### Within Each User Story
- Write or update tests first and confirm they fail before implementing behavior.
- Implement PHP support logic before wiring Blade partial behavior.
- Finish shared view behavior before documenting or validating consumer adoption guidance.
### Parallel Opportunities
- `T002` and `T003` can run in parallel after `T001` starts.
- `T006`, `T007`, and `T008` can run in parallel inside US1.
- `T013`, `T014`, and `T015` can run in parallel inside US2.
- `T020` and `T021` can run in parallel inside US3.
- `T025`, `T026`, and `T027` can run in parallel in the polish phase.
---
## Parallel Example: User Story 1
```bash
# Launch the core shared-diff test work together:
Task: "Add enum and DTO invariant coverage in tests/Unit/Support/Diff/DiffRowStatusTest.php and tests/Unit/Support/Diff/DiffRowTest.php"
Task: "Add row-classification and summary-derivation coverage in tests/Unit/Support/Diff/DiffPresenterTest.php"
Task: "Add summary and row partial rendering coverage in tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php and tests/Feature/Support/Diff/SharedDiffRowPartialTest.php"
```
## Parallel Example: User Story 2
```bash
# Launch the formatting and list-diff test work together:
Task: "Add value-formatting coverage for null, boolean, scalar, empty-list, non-empty-list, and associative-array cases in tests/Unit/Support/Diff/ValueStringifierTest.php"
Task: "Add list-fragment and metadata-fallback presenter coverage in tests/Unit/Support/Diff/DiffPresenterTest.php"
Task: "Add inline-list rendering coverage with a representative 25-item fixture plus muted-unchanged, semantic-label, and logical-reading-order assertions in tests/Feature/Support/Diff/SharedInlineListDiffPartialTest.php and tests/Feature/Support/Diff/SharedDiffRowPartialTest.php"
```
## Parallel Example: User Story 3
```bash
# Launch the fallback-safety test work together:
Task: "Add no-data and sparse-input fallback coverage in tests/Unit/Support/Diff/DiffPresenterTest.php and tests/Feature/Support/Diff/SharedDiffSummaryPartialTest.php"
Task: "Add diff-state badge fallback coverage in tests/Unit/Badges/DiffRowStatusBadgeTest.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate that simple compare inputs now produce consistent shared states, summaries, and row rendering.
### Incremental Delivery
1. Deliver US1 to establish the shared state vocabulary, presenter contract, and base row or summary primitives.
2. Deliver US2 to make the shared rendering efficient to scan through standardized formatting and inline list behavior.
3. Deliver US3 to document adoption, lock in safe fallback behavior, and preserve specialized views until follow-up specs opt in.
### Team Strategy
1. One engineer can own the PHP support layer while another prepares Pest coverage and Blade partial scaffolding.
2. After US1 lands, one engineer can focus on `ValueStringifier` and presenter list fragments while another refines row and inline-list partial behavior.
3. Finish with a short pass on documentation, quickstart example validation, focused Sail tests, and Pint.

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
use App\Support\Diff\DiffRow;
use App\Support\Diff\DiffRowStatus;
it('renders changed rows with shared state labels and from or to values', function (): void {
$this->view('filament.partials.diff.row', [
'row' => new DiffRow(
key: 'description',
label: 'Description',
status: DiffRowStatus::Changed,
oldValue: 'Before',
newValue: 'After',
),
'compact' => false,
'dimUnchanged' => true,
])
->assertSee('Changed')
->assertSee('Description')
->assertSee('From')
->assertSee('Before')
->assertSee('To')
->assertSee('After');
});
it('renders added removed and unchanged rows through the shared dispatcher', function (): void {
$added = (string) $this->view('filament.partials.diff.row', [
'row' => new DiffRow(
key: 'new_setting',
label: 'New setting',
status: DiffRowStatus::Added,
newValue: 'Enabled',
),
'compact' => false,
'dimUnchanged' => true,
]);
$removed = (string) $this->view('filament.partials.diff.row', [
'row' => new DiffRow(
key: 'retired_setting',
label: 'Retired setting',
status: DiffRowStatus::Removed,
oldValue: 'Legacy',
),
'compact' => false,
'dimUnchanged' => true,
]);
$unchanged = (string) $this->view('filament.partials.diff.row', [
'row' => new DiffRow(
key: 'display_name',
label: 'Display name',
status: DiffRowStatus::Unchanged,
oldValue: 'TenantPilot',
newValue: 'TenantPilot',
),
'compact' => false,
'dimUnchanged' => true,
]);
expect($added)->toContain('Added')
->and($added)->toContain('Enabled')
->and($removed)->toContain('Removed')
->and($removed)->toContain('Legacy')
->and($unchanged)->toContain('Unchanged')
->and($unchanged)->toContain('TenantPilot');
});
it('delegates list-like rows to the inline list partial and dims unchanged content when requested', function (): void {
$listRow = (string) $this->view('filament.partials.diff.row', [
'row' => new DiffRow(
key: 'scope_tags',
label: 'Scope tags',
status: DiffRowStatus::Changed,
oldValue: ['Default', 'Legacy', 'Shared'],
newValue: ['Default', 'Shared', 'Workspace'],
isListLike: true,
addedItems: ['Workspace'],
removedItems: ['Legacy'],
unchangedItems: ['Default', 'Shared'],
),
'compact' => false,
'dimUnchanged' => true,
]);
$unchanged = (string) $this->view('filament.partials.diff.row', [
'row' => new DiffRow(
key: 'display_name',
label: 'Display name',
status: DiffRowStatus::Unchanged,
oldValue: 'TenantPilot',
newValue: 'TenantPilot',
),
'compact' => false,
'dimUnchanged' => true,
]);
expect($listRow)->toContain('Added items')
->and($listRow)->toContain('Removed items')
->and($listRow)->toContain('Unchanged items')
->and($unchanged)->toContain('text-gray-500')
->and($unchanged)->toContain('dark:text-gray-400');
});

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Support\Diff\DiffSummary;
it('renders all shared summary badge counts with centralized state labels', function (): void {
$this->view('filament.partials.diff.summary-badges', [
'summary' => new DiffSummary(
changedCount: 2,
addedCount: 1,
removedCount: 3,
unchangedCount: 4,
),
])
->assertSee('2 changed')
->assertSee('1 added')
->assertSee('3 removed')
->assertSee('4 unchanged')
->assertSee('fi-badge');
});
it('renders a clear fallback when no diff rows are available', function (): void {
$this->view('filament.partials.diff.summary-badges', [
'summary' => null,
])
->assertSee('0 changed')
->assertSee('0 added')
->assertSee('0 removed')
->assertSee('0 unchanged')
->assertSee('No diff data available.');
});

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Support\Diff\DiffRow;
use App\Support\Diff\DiffRowStatus;
it('renders inline list fragments with semantic labels muted unchanged items and stable reading order', function (): void {
$baseline = array_map(
static fn (int $index): string => sprintf('Group %02d', $index),
range(1, 24),
);
$current = array_map(
static fn (int $index): string => sprintf('Group %02d', $index),
range(2, 25),
);
$html = (string) $this->view('filament.partials.diff.inline-list', [
'row' => new DiffRow(
key: 'group_assignments',
label: 'Group assignments',
status: DiffRowStatus::Changed,
oldValue: $baseline,
newValue: $current,
isListLike: true,
addedItems: ['Group 25'],
removedItems: ['Group 01'],
unchangedItems: array_values(array_intersect($current, $baseline)),
),
'compact' => false,
'dimUnchanged' => true,
]);
expect($html)->toContain('Added items')
->and($html)->toContain('Removed items')
->and($html)->toContain('Unchanged items')
->and($html)->toContain('Group 25')
->and($html)->toContain('Group 01')
->and($html)->toContain('Group 12')
->and($html)->toContain('text-gray-500')
->and($html)->toContain('dark:text-gray-400')
->and(substr_count($html, 'rounded-full'))->toBe(25);
expect(strpos($html, 'Added items'))->toBeLessThan(strpos($html, 'Removed items'));
expect(strpos($html, 'Removed items'))->toBeLessThan(strpos($html, 'Unchanged items'));
});

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeSpec;
use App\Support\Diff\DiffRowStatus;
it('maps shared diff row states to canonical badge semantics', function (): void {
$changed = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, DiffRowStatus::Changed);
$added = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, DiffRowStatus::Added);
$removed = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, DiffRowStatus::Removed);
$unchanged = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, DiffRowStatus::Unchanged);
expect($changed->label)->toBe('Changed')
->and($changed->color)->toBe('warning')
->and($added->label)->toBe('Added')
->and($added->color)->toBe('success')
->and($removed->label)->toBe('Removed')
->and($removed->color)->toBe('danger')
->and($unchanged->label)->toBe('Unchanged')
->and($unchanged->color)->toBe('gray');
});
it('returns a safe unknown badge spec for unsupported diff states', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::DiffRowStatus, 'mystery-state');
expect($spec)->toBeInstanceOf(BadgeSpec::class)
->and($spec->label)->toBe('Unknown')
->and($spec->color)->toBe('gray');
});

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
use App\Support\Diff\DiffPresenter;
use App\Support\Diff\DiffRowStatus;
it('classifies rows across all shared states and derives matching summary counts', function (): void {
$presentation = app(DiffPresenter::class)->present(
baseline: [
'description' => 'Before',
'display_name' => 'TenantPilot',
'retired_setting' => 'Legacy',
],
current: [
'description' => 'After',
'display_name' => 'TenantPilot',
'new_setting' => 'Enabled',
],
labels: [
'description' => 'Description',
'display_name' => 'Display name',
'new_setting' => 'New setting',
'retired_setting' => 'Retired setting',
],
);
$rows = collect($presentation->rows)->keyBy('key');
expect($presentation->summary->changedCount)->toBe(1)
->and($presentation->summary->addedCount)->toBe(1)
->and($presentation->summary->removedCount)->toBe(1)
->and($presentation->summary->unchangedCount)->toBe(1)
->and($presentation->summary->hasRows)->toBeTrue()
->and($rows->get('description')?->status)->toBe(DiffRowStatus::Changed)
->and($rows->get('display_name')?->status)->toBe(DiffRowStatus::Unchanged)
->and($rows->get('new_setting')?->status)->toBe(DiffRowStatus::Added)
->and($rows->get('retired_setting')?->status)->toBe(DiffRowStatus::Removed);
});
it('returns rows in deterministic label order', function (): void {
$presentation = app(DiffPresenter::class)->present(
baseline: [
'zeta_value' => 'same',
'alpha_value' => 'before',
],
current: [
'alpha_value' => 'after',
'middle_value' => 'new',
'zeta_value' => 'same',
],
labels: [
'alpha_value' => 'Alpha value',
'middle_value' => 'Middle value',
'zeta_value' => 'Zeta value',
],
);
expect(array_map(
static fn ($row): string => $row->label,
$presentation->rows,
))->toBe([
'Alpha value',
'Middle value',
'Zeta value',
]);
});
it('prepares inline list fragments for simple list comparisons', function (): void {
$presentation = app(DiffPresenter::class)->present(
baseline: [
'scope_tags' => ['Default', 'Legacy', 'Shared'],
],
current: [
'scope_tags' => ['Default', 'Shared', 'Workspace'],
],
labels: [
'scope_tags' => 'Scope tags',
],
);
$row = $presentation->rows[0] ?? null;
expect($row)->not->toBeNull()
->and($row?->isListLike)->toBeTrue()
->and($row?->addedItems)->toBe(['Workspace'])
->and($row?->removedItems)->toBe(['Legacy'])
->and($row?->unchangedItems)->toBe(['Default', 'Shared']);
});
it('falls back to generated labels and safe empty meta when optional metadata is missing or invalid', function (): void {
$presentation = app(DiffPresenter::class)->present(
baseline: [
'customSettingFoo' => 'Before',
],
current: [
'customSettingFoo' => 'After',
],
meta: [
'customSettingFoo' => 'invalid-meta-shape',
],
);
$row = $presentation->rows[0] ?? null;
expect($row)->not->toBeNull()
->and($row?->label)->toBe('Custom Setting Foo')
->and($row?->meta)->toBe([]);
});
it('returns a no-data presentation for empty or sparse compare payloads', function (): void {
$presentation = app(DiffPresenter::class)->present(
baseline: [],
current: [],
changedKeys: ['ghost_key'],
labels: ['ghost_key' => 'Ghost key'],
meta: ['ghost_key' => ['note' => 'unused']],
);
expect($presentation->rows)->toBe([])
->and($presentation->summary->hasRows)->toBeFalse()
->and($presentation->summary->changedCount)->toBe(0)
->and($presentation->summary->addedCount)->toBe(0)
->and($presentation->summary->removedCount)->toBe(0)
->and($presentation->summary->unchangedCount)->toBe(0)
->and($presentation->summary->message)->toBe('No diff data available.');
});

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use App\Support\Diff\DiffRowStatus;
it('defines the canonical shared diff row states', function (): void {
expect(array_map(
static fn (DiffRowStatus $status): string => $status->value,
DiffRowStatus::cases(),
))->toBe([
'unchanged',
'changed',
'added',
'removed',
]);
});

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Support\Diff\DiffRow;
use App\Support\Diff\DiffRowStatus;
it('requires a non-empty key and label', function (): void {
expect(fn (): DiffRow => new DiffRow(
key: '',
label: 'Display name',
status: DiffRowStatus::Changed,
))->toThrow(\InvalidArgumentException::class, 'DiffRow key must be a non-empty string.');
expect(fn (): DiffRow => new DiffRow(
key: 'display_name',
label: ' ',
status: DiffRowStatus::Changed,
))->toThrow(\InvalidArgumentException::class, 'DiffRow label must be a non-empty string.');
});
it('stores optional list fragments and meta for rendering', function (): void {
$row = new DiffRow(
key: 'assignments',
label: 'Assignments',
status: DiffRowStatus::Changed,
oldValue: ['old-a', 'shared'],
newValue: ['new-b', 'shared'],
isListLike: true,
addedItems: ['new-b'],
removedItems: ['old-a'],
unchangedItems: ['shared'],
meta: ['hint' => 'policy level'],
);
expect($row->key)->toBe('assignments')
->and($row->label)->toBe('Assignments')
->and($row->status)->toBe(DiffRowStatus::Changed)
->and($row->isListLike)->toBeTrue()
->and($row->addedItems)->toBe(['new-b'])
->and($row->removedItems)->toBe(['old-a'])
->and($row->unchangedItems)->toBe(['shared'])
->and($row->meta)->toBe(['hint' => 'policy level']);
});

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Support\Diff\ValueStringifier;
it('formats null booleans scalars lists and compact structured values consistently', function (): void {
$stringifier = new ValueStringifier;
expect($stringifier->stringify(null))->toBe('—')
->and($stringifier->stringify(true))->toBe('Enabled')
->and($stringifier->stringify(false))->toBe('Disabled')
->and($stringifier->stringify('TenantPilot'))->toBe('TenantPilot')
->and($stringifier->stringify(''))->toBe('""')
->and($stringifier->stringify(42))->toBe('42')
->and($stringifier->stringify([]))->toBe('[]')
->and($stringifier->stringify(['Alpha', 'Beta']))->toBe('Alpha, Beta')
->and($stringifier->stringify(['label' => 'Primary', 'enabled' => true]))->toBe('{"label":"Primary","enabled":true}');
});