feat: tag badge catalog (060) #72

Merged
ahmido merged 1 commits from 060-tag-badge-catalog into dev 2026-01-23 23:05:56 +00:00
31 changed files with 1841 additions and 117 deletions

View File

@ -17,6 +17,8 @@
use App\Services\OperationRunService;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -39,7 +41,6 @@
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
@ -175,9 +176,13 @@ public static function table(Table $table): Table
return $table
->defaultSort('next_run_at', 'asc')
->columns([
IconColumn::make('is_enabled')
TextColumn::make('is_enabled')
->label('Enabled')
->boolean()
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled))
->alignCenter(),
TextColumn::make('name')
@ -187,16 +192,8 @@ public static function table(Table $table): Table
TextColumn::make('frequency')
->label('Frequency')
->badge()
->formatStateUsing(fn (?string $state): string => match ($state) {
'daily' => 'Daily',
'weekly' => 'Weekly',
default => (string) $state,
})
->color(fn (?string $state): string => match ($state) {
'daily' => 'success',
'weekly' => 'warning',
default => 'gray',
}),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::BackupScheduleFrequency))
->color(TagBadgeRenderer::color(TagBadgeDomain::BackupScheduleFrequency)),
TextColumn::make('time_of_day')
->label('Time')

View File

@ -10,6 +10,8 @@
use App\Services\OperationRunService;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -53,7 +55,8 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state) => static::typeMeta($state)['label'] ?? $state),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('restore_mode')
->label('Restore')
->badge()
@ -73,7 +76,10 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('policy_identifier')
->label('Policy ID')
->copyable(),
Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('assignments')
->label('Assignments')
->badge()

View File

@ -5,6 +5,8 @@
use App\Filament\Resources\EntraGroupResource\Pages;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use BackedEnum;
use Filament\Actions;
use Filament\Infolists\Components\TextEntry;
@ -46,8 +48,20 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('type')
->badge()
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
TextEntry::make('security_enabled')->label('Security')->badge(),
TextEntry::make('mail_enabled')->label('Mail')->badge(),
TextEntry::make('security_enabled')
->label('Security')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
TextEntry::make('mail_enabled')
->label('Mail')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
])
->columns(2)

View File

@ -8,6 +8,10 @@
use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum;
@ -49,12 +53,18 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('policy_type')
->label('Type')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['label'] ?? (string) $record->policy_type),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
TextEntry::make('category')
->badge()
->state(fn (InventoryItem $record): string => $record->category
?: (static::typeMeta($record->policy_type)['category'] ?? 'Unknown')),
TextEntry::make('platform')->badge(),
->state(fn (InventoryItem $record): ?string => $record->category
?: (static::typeMeta($record->policy_type)['category'] ?? null))
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
TextEntry::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
TextEntry::make('external_id')->label('External ID'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime(),
TextEntry::make('last_seen_run_id')
@ -70,11 +80,19 @@ public static function infolist(Schema $schema): Schema
TextEntry::make('support_restore')
->label('Restore')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
TextEntry::make('support_risk')
->label('Risk')
->badge()
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal'),
->state(fn (InventoryItem $record): string => static::typeMeta($record->policy_type)['risk'] ?? 'normal')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRisk))
->color(BadgeRenderer::color(BadgeDomain::PolicyRisk))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRisk))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
])
->columns(2)
->columnSpanFull(),
@ -144,17 +162,52 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state): string => static::typeMeta($state)['label'] ?? (string) $state),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('category')
->badge(),
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
Tables\Columns\TextColumn::make('platform')
->badge(),
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('last_seen_at')
->label('Last seen')
->since(),
Tables\Columns\TextColumn::make('lastSeenRun.status')
->label('Run')
->badge(),
->badge()
->formatStateUsing(function (?string $state): string {
if (! filled($state)) {
return '—';
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label;
})
->color(function (?string $state): string {
if (! filled($state)) {
return 'gray';
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color;
})
->icon(function (?string $state): ?string {
if (! filled($state)) {
return null;
}
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon;
})
->iconColor(function (?string $state): ?string {
if (! filled($state)) {
return 'gray';
}
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state);
return $spec->iconColor ?? $spec->color;
}),
])
->filters([
Tables\Filters\SelectFilter::make('policy_type')

View File

@ -16,6 +16,8 @@
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -241,17 +243,28 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state, Policy $record) => static::typeMeta($record->policy_type)['label'] ?? $state),
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType))
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('category')
->label('Category')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? 'Unknown'),
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['category'] ?? null)
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyCategory))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyCategory)),
Tables\Columns\TextColumn::make('restore_mode')
->label('Restore')
->badge()
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled'),
->state(fn (Policy $record) => static::typeMeta($record->policy_type)['restore'] ?? 'enabled')
->formatStateUsing(BadgeRenderer::label(BadgeDomain::PolicyRestoreMode))
->color(BadgeRenderer::color(BadgeDomain::PolicyRestoreMode))
->icon(BadgeRenderer::icon(BadgeDomain::PolicyRestoreMode))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRestoreMode)),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
->sortable(),
Tables\Columns\TextColumn::make('settings_status')
->label('Settings')

View File

@ -6,6 +6,8 @@
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Intune\RestoreService;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
@ -24,7 +26,11 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('policy_type')->badge()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType))
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('version_number', 'desc')
->filters([])

View File

@ -16,6 +16,8 @@
use App\Services\Intune\VersionDiff;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use BackedEnum;
@ -52,8 +54,14 @@ public static function infolist(Schema $schema): Schema
->schema([
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
Infolists\Components\TextEntry::make('version_number')->label('Version'),
Infolists\Components\TextEntry::make('policy_type'),
Infolists\Components\TextEntry::make('platform'),
Infolists\Components\TextEntry::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Infolists\Components\TextEntry::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
Tabs::make()
@ -177,8 +185,14 @@ public static function table(Table $table): Table
->columns([
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('policy_type')->badge(),
Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('policy_type')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform)),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
])

View File

@ -19,6 +19,8 @@
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -128,12 +130,8 @@ public static function table(Table $table): Table
->searchable(),
Tables\Columns\TextColumn::make('environment')
->badge()
->color(fn (?string $state) => match ($state) {
'prod' => 'danger',
'dev' => 'warning',
'staging' => 'info',
default => 'gray',
})
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::TenantEnvironment))
->color(TagBadgeRenderer::color(TagBadgeDomain::TenantEnvironment))
->sortable(),
Tables\Columns\TextColumn::make('policies_count')
->label('Policies')

View File

@ -11,6 +11,8 @@
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -83,11 +85,15 @@ public function table(Table $table): Table
TextColumn::make('policy_type')
->label('Type')
->badge()
->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')),
->placeholder('—')
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType))
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)),
TextColumn::make('platform')
->label('Platform')
->badge()
->default('—')
->placeholder('—')
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
->color(TagBadgeRenderer::color(TagBadgeDomain::Platform))
->sortable(),
TextColumn::make('external_id')
->label('External ID')

View File

@ -0,0 +1,324 @@
<?php
namespace App\Support\Badges;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use BackedEnum;
use Illuminate\Support\Str;
use Stringable;
final class TagBadgeCatalog
{
/**
* @var array<string, array<string, mixed>>
*/
private static array $policyTypeMetaCache = [];
/**
* @var array<string, string>
*/
private static array $policyCategoryLabelCache = [];
public static function spec(TagBadgeDomain $domain, mixed $value): TagBadgeSpec
{
return match ($domain) {
TagBadgeDomain::PolicyType => self::policyType($value),
TagBadgeDomain::PolicyCategory => self::policyCategory($value),
TagBadgeDomain::Platform => self::platform($value),
TagBadgeDomain::TenantEnvironment => self::tenantEnvironment($value),
TagBadgeDomain::Visibility => self::visibility($value),
TagBadgeDomain::SnapshotType => self::snapshotType($value),
TagBadgeDomain::BackupScheduleFrequency => self::backupScheduleFrequency($value),
};
}
public static function normalizeValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if ($value instanceof Stringable) {
$value = (string) $value;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
private static function policyType(mixed $value): TagBadgeSpec
{
$type = self::normalizeValue($value);
if (! filled($type)) {
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::PolicyType));
}
$meta = self::policyTypeMeta($type);
$label = $meta['label'] ?? null;
if (is_string($label) && $label !== '') {
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::PolicyType));
}
return self::other(TagBadgeDomain::PolicyType, $type);
}
private static function policyCategory(mixed $value): TagBadgeSpec
{
$category = self::normalizeValue($value);
if (! filled($category)) {
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::PolicyCategory));
}
$normalized = Str::of($category)
->trim()
->lower()
->replaceMatches('/\s+/', ' ')
->toString();
$label = self::policyCategoryLabels()[$normalized] ?? null;
if (is_string($label) && $label !== '') {
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::PolicyCategory));
}
return self::other(TagBadgeDomain::PolicyCategory, $category);
}
private static function platform(mixed $value): TagBadgeSpec
{
$platform = self::normalizeValue($value);
if (! filled($platform)) {
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::Platform));
}
$normalized = Str::of($platform)
->trim()
->lower()
->replace(['_', '-'], ' ')
->replaceMatches('/\s+/', ' ')
->toString();
$label = match ($normalized) {
'windows' => 'Windows',
'android' => 'Android',
'ios' => 'iOS',
'macos' => 'macOS',
'all' => 'All',
'mobile' => 'Mobile',
default => null,
};
if (is_string($label)) {
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::Platform));
}
return self::other(TagBadgeDomain::Platform, $platform);
}
private static function tenantEnvironment(mixed $value): TagBadgeSpec
{
$environment = self::normalizeValue($value);
if (! filled($environment)) {
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::TenantEnvironment));
}
$normalized = Str::of($environment)
->trim()
->lower()
->replace(['_', '-'], ' ')
->replaceMatches('/\s+/', ' ')
->toString();
$label = match ($normalized) {
'prod', 'production' => 'Prod',
'staging' => 'Staging',
'dev', 'development' => 'Dev',
default => null,
};
if (is_string($label)) {
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::TenantEnvironment));
}
return self::other(TagBadgeDomain::TenantEnvironment, $environment);
}
private static function visibility(mixed $value): TagBadgeSpec
{
$visibility = self::normalizeValue($value);
if (! filled($visibility)) {
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::Visibility));
}
$normalized = Str::of($visibility)
->trim()
->lower()
->replace(['_', '-'], ' ')
->replaceMatches('/\s+/', ' ')
->toString();
$label = match ($normalized) {
'active' => 'Active',
'archived' => 'Archived',
default => null,
};
if (is_string($label)) {
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::Visibility));
}
return self::other(TagBadgeDomain::Visibility, $visibility);
}
private static function snapshotType(mixed $value): TagBadgeSpec
{
$type = self::normalizeValue($value);
if (! filled($type)) {
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::SnapshotType));
}
$normalized = Str::of($type)
->trim()
->lower()
->replace(['_', '-'], ' ')
->replaceMatches('/\s+/', ' ')
->toString();
$label = match ($normalized) {
'full' => 'Full',
'incremental' => 'Incremental',
default => null,
};
if (is_string($label)) {
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::SnapshotType));
}
return self::other(TagBadgeDomain::SnapshotType, $type);
}
private static function backupScheduleFrequency(mixed $value): TagBadgeSpec
{
$frequency = self::normalizeValue($value);
if (! filled($frequency)) {
return new TagBadgeSpec('Other: —', self::colorForDomain(TagBadgeDomain::BackupScheduleFrequency));
}
$normalized = Str::of($frequency)
->trim()
->lower()
->replace(['_', '-'], ' ')
->replaceMatches('/\s+/', ' ')
->toString();
$label = match ($normalized) {
'daily' => 'Daily',
'weekly' => 'Weekly',
default => null,
};
if (is_string($label)) {
return new TagBadgeSpec($label, self::colorForDomain(TagBadgeDomain::BackupScheduleFrequency));
}
return self::other(TagBadgeDomain::BackupScheduleFrequency, $frequency);
}
private static function other(TagBadgeDomain $domain, string $value): TagBadgeSpec
{
$normalizedValue = Str::of($value)
->trim()
->replaceMatches('/\s+/', ' ')
->toString();
return new TagBadgeSpec(
label: 'Other: '.$normalizedValue,
color: self::colorForDomain($domain),
);
}
private static function colorForDomain(TagBadgeDomain $domain): string
{
return match ($domain) {
TagBadgeDomain::PolicyType => 'primary',
TagBadgeDomain::PolicyCategory => 'gray',
TagBadgeDomain::Platform => 'info',
TagBadgeDomain::TenantEnvironment => 'gray',
TagBadgeDomain::Visibility => 'gray',
TagBadgeDomain::SnapshotType => 'primary',
TagBadgeDomain::BackupScheduleFrequency => 'info',
};
}
/**
* @return array<string, mixed>
*/
private static function policyTypeMeta(string $type): array
{
if (self::$policyTypeMetaCache === []) {
self::$policyTypeMetaCache = InventoryPolicyTypeMeta::byType();
}
return self::$policyTypeMetaCache[$type] ?? [];
}
/**
* @return array<string, string>
*/
private static function policyCategoryLabels(): array
{
if (self::$policyCategoryLabelCache !== []) {
return self::$policyCategoryLabelCache;
}
$labels = [];
foreach (InventoryPolicyTypeMeta::all() as $meta) {
if (! is_array($meta)) {
continue;
}
$category = $meta['category'] ?? null;
if (! is_string($category) || trim($category) === '') {
continue;
}
$normalized = Str::of($category)
->trim()
->lower()
->replaceMatches('/\s+/', ' ')
->toString();
$labels[$normalized] ??= $category;
}
self::$policyCategoryLabelCache = $labels;
return self::$policyCategoryLabelCache;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Support\Badges;
enum TagBadgeDomain: string
{
case PolicyType = 'policy_type';
case PolicyCategory = 'policy_category';
case Platform = 'platform';
case TenantEnvironment = 'tenant_environment';
case Visibility = 'visibility';
case SnapshotType = 'snapshot_type';
case BackupScheduleFrequency = 'backup_schedule_frequency';
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Support\Badges;
use Closure;
final class TagBadgeRenderer
{
public static function label(TagBadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): string {
return TagBadgeCatalog::spec($domain, $state)->label;
};
}
public static function color(TagBadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): string {
return TagBadgeCatalog::spec($domain, $state)->color;
};
}
public static function icon(TagBadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): ?string {
return TagBadgeCatalog::spec($domain, $state)->icon;
};
}
public static function iconColor(TagBadgeDomain $domain): Closure
{
return static function (mixed $state, mixed ...$args) use ($domain): ?string {
$spec = TagBadgeCatalog::spec($domain, $state);
return $spec->iconColor ?? $spec->color;
};
}
public static function spec(TagBadgeDomain $domain, mixed $state): TagBadgeSpec
{
return TagBadgeCatalog::spec($domain, $state);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Support\Badges;
use InvalidArgumentException;
final class TagBadgeSpec
{
/**
* @var array<int, string>
*/
private const ALLOWED_COLORS = [
'gray',
'primary',
'info',
];
public function __construct(
public readonly string $label,
public readonly string $color,
public readonly ?string $icon = null,
public readonly ?string $iconColor = null,
) {
if (trim($this->label) === '') {
throw new InvalidArgumentException('TagBadgeSpec label must be a non-empty string.');
}
if (! in_array($this->color, self::ALLOWED_COLORS, true)) {
throw new InvalidArgumentException('TagBadgeSpec color must be one of: '.implode(', ', self::ALLOWED_COLORS));
}
if ($this->icon !== null && trim($this->icon) === '') {
throw new InvalidArgumentException('TagBadgeSpec icon must be null or a non-empty string.');
}
if ($this->iconColor !== null && ! in_array($this->iconColor, self::ALLOWED_COLORS, true)) {
throw new InvalidArgumentException('TagBadgeSpec iconColor must be null or one of: '.implode(', ', self::ALLOWED_COLORS));
}
}
/**
* @return array<int, string>
*/
public static function allowedColors(): array
{
return self::ALLOWED_COLORS;
}
}

View File

@ -1,4 +1,8 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
@ -113,7 +117,7 @@
$isJsonValue = is_array($value) && ! (array_is_list($value) && array_reduce($value, fn ($carry, $item) => $carry && is_scalar($item), true));
$isListValue = is_array($value) && array_is_list($value) && array_reduce($value, fn ($carry, $item) => $carry && is_scalar($item), true);
$isBooleanValue = is_bool($value);
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true);
$isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled', 'yes', 'no', '1', '0'], true);
$isNumericValue = is_numeric($value);
$badgeItems = null;
@ -173,22 +177,29 @@
@elseif (is_array($badgeItems) && $badgeItems !== [])
<div class="flex flex-wrap gap-2">
@foreach ($badgeItems as $item)
<x-filament::badge :color="$isPlatform ? 'info' : 'gray'" size="sm">
{{ $item }}
</x-filament::badge>
@if ($isPlatform)
@php
$spec = TagBadgeCatalog::spec(TagBadgeDomain::Platform, $item);
@endphp
<x-filament::badge :color="$spec->color" size="sm">
{{ $spec->label }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="sm">
{{ $item }}
</x-filament::badge>
@endif
@endforeach
</div>
@elseif ($isJsonValue)
<pre class="whitespace-pre-wrap rounded-lg border border-gray-200 bg-gray-50 p-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-200">{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}</pre>
@elseif ($isBooleanValue || $isBooleanString)
@php
$boolValue = $isBooleanValue
? $value
: in_array(strtolower($value), ['true', 'enabled'], true);
$boolLabel = $boolValue ? 'Enabled' : 'Disabled';
$boolSpec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
@endphp
<x-filament::badge :color="$boolValue ? 'success' : 'gray'" size="sm">
{{ $boolLabel }}
<x-filament::badge :color="$boolSpec->color" :icon="$boolSpec->icon" size="sm">
{{ $boolSpec->label }}
</x-filament::badge>
@elseif ($isNumericValue)
<div class="text-sm font-semibold text-gray-900 dark:text-white tabular-nums">

View File

@ -1,4 +1,7 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeSpec;
use Illuminate\Support\Str;
// Extract state from Filament ViewEntry
@ -60,22 +63,10 @@
return true;
};
$asEnabledDisabledBadgeValue = function (mixed $value): ?bool {
if (is_bool($value)) {
return $value;
}
$asEnabledDisabledBadgeSpec = function (mixed $value): ?BadgeSpec {
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
if (! is_string($value)) {
return null;
}
$normalized = strtolower(trim($value));
return match ($normalized) {
'enabled', 'true', 'yes', '1' => true,
'disabled', 'false', 'no', '0' => false,
default => null,
};
return $spec->label === 'Unknown' ? null : $spec;
};
@endphp
@ -117,12 +108,12 @@
<dd class="mt-1 sm:mt-0 sm:col-span-2">
<span class="text-sm text-gray-900 dark:text-white">
@php
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
@endphp
@if(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
@if($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@elseif(is_numeric($row['value']))
<span class="font-mono font-semibold">{{ $row['value'] }}</span>
@ -171,12 +162,12 @@
</dt>
<dd class="mt-1 sm:mt-0 sm:col-span-2">
@php
$badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null);
$badgeSpec = $asEnabledDisabledBadgeSpec($row['value'] ?? null);
@endphp
@if(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
@if($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@elseif(is_numeric($row['value']))
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
@ -185,9 +176,19 @@
@elseif($shouldRenderBadges($row['value'] ?? null))
<div class="flex flex-wrap gap-1.5">
@foreach(($row['value'] ?? []) as $item)
<x-filament::badge color="gray" size="sm">
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
</x-filament::badge>
@php
$itemSpec = $asEnabledDisabledBadgeSpec($item);
@endphp
@if($itemSpec)
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
{{ $itemSpec->label }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="sm">
{{ is_null($item) ? '—' : (string) $item }}
</x-filament::badge>
@endif
@endforeach
</div>
@else
@ -225,7 +226,7 @@
$isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true)
&& (bool) config('tenantpilot.display.show_script_content', false);
$badgeValue = $asEnabledDisabledBadgeValue($rawValue);
$badgeSpec = $asEnabledDisabledBadgeSpec($rawValue);
@endphp
@if($isScriptContent)
@ -305,14 +306,24 @@
@elseif($shouldRenderBadges($rawValue))
<div class="flex flex-wrap gap-1.5">
@foreach(($rawValue ?? []) as $item)
<x-filament::badge color="gray" size="sm">
{{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }}
</x-filament::badge>
@php
$itemSpec = $asEnabledDisabledBadgeSpec($item);
@endphp
@if($itemSpec)
<x-filament::badge :color="$itemSpec->color" :icon="$itemSpec->icon" size="sm">
{{ $itemSpec->label }}
</x-filament::badge>
@else
<x-filament::badge color="gray" size="sm">
{{ is_null($item) ? '—' : (string) $item }}
</x-filament::badge>
@endif
@endforeach
</div>
@elseif(! is_null($badgeValue))
<x-filament::badge :color="$badgeValue ? 'success' : 'gray'" size="sm">
{{ $badgeValue ? 'Enabled' : 'Disabled' }}
@elseif($badgeSpec)
<x-filament::badge :color="$badgeSpec->color" :icon="$badgeSpec->icon" size="sm">
{{ $badgeSpec->label }}
</x-filament::badge>
@else
<span class="text-sm text-gray-900 dark:text-white break-words">

View File

@ -1,4 +1,6 @@
@php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use Illuminate\Support\Str;
// Extract groups from Filament ViewEntry state using $getState()
@ -71,25 +73,34 @@
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 break-words">
{{ $setting['label'] }}
</dt>
<dd class="mt-1 md:mt-0 md:col-span-2 break-words">
<div class="flex flex-wrap items-center gap-3">
@if(is_bool($setting['value_raw']))
<x-filament::badge :color="$setting['value_raw'] ? 'success' : 'gray'" size="sm">
{{ $setting['value_display'] }}
</x-filament::badge>
@elseif(is_int($setting['value_raw']))
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{{ $setting['value_display'] }}
</span>
@elseif(str_contains(strtolower($setting['value_display']), 'enabled') || str_contains(strtolower($setting['value_display']), 'disabled'))
<x-filament::badge :color="str_contains(strtolower($setting['value_display']), 'enabled') ? 'success' : 'gray'" size="sm">
{{ $setting['value_display'] }}
</x-filament::badge>
@else
<span class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
{{ $setting['value_display'] }}
</span>
@endif
<dd class="mt-1 md:mt-0 md:col-span-2 break-words">
<div class="flex flex-wrap items-center gap-3">
@php
$rawValue = $setting['value_raw'] ?? null;
$displayValue = $setting['value_display'] ?? null;
$booleanSpec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $rawValue);
if ($booleanSpec->label === 'Unknown') {
$booleanSpec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $displayValue);
}
$hasBooleanBadge = $booleanSpec->label !== 'Unknown';
@endphp
@if($hasBooleanBadge)
<x-filament::badge :color="$booleanSpec->color" :icon="$booleanSpec->icon" size="sm">
{{ $booleanSpec->label }}
</x-filament::badge>
@elseif(is_int($rawValue))
<span class="font-mono text-sm font-semibold text-gray-900 dark:text-white">
{{ $displayValue }}
</span>
@else
<span class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
{{ $displayValue }}
</span>
@endif
@if(strlen($setting['value_display'] ?? '') > 200)
<button

View File

@ -15,13 +15,49 @@
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($supportedPolicyTypes as $row)
@php
$typeSpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyType, $row['type'] ?? null);
$categorySpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
$restoreSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $row['restore'] ?? 'enabled');
$riskSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRisk, $row['risk'] ?? 'n/a');
@endphp
<tr>
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
<td class="py-2 pr-4">{{ ($row['dependencies'] ?? false) ? '✅' : '—' }}</td>
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
<td class="py-2 pr-4">
<span class="font-mono text-xs">{{ $row['type'] ?? '' }}</span>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$typeSpec->color" size="sm">
{{ $typeSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$categorySpec->color" size="sm">
{{ $categorySpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
@if ($row['dependencies'] ?? false)
<x-filament::icon
icon="heroicon-m-check-circle"
class="h-5 w-5 text-success-600 dark:text-success-400"
/>
@else
<x-filament::icon
icon="heroicon-m-minus-circle"
class="h-5 w-5 text-gray-400 dark:text-gray-500"
/>
@endif
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$restoreSpec->color" :icon="$restoreSpec->icon" size="sm">
{{ $restoreSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$riskSpec->color" :icon="$riskSpec->icon" size="sm">
{{ $riskSpec->label }}
</x-filament::badge>
</td>
</tr>
@endforeach
</tbody>
@ -45,13 +81,49 @@
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
@foreach ($foundationTypes as $row)
@php
$typeSpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyType, $row['type'] ?? null);
$categorySpec = \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::PolicyCategory, $row['category'] ?? null);
$restoreSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRestoreMode, $row['restore'] ?? 'enabled');
$riskSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::PolicyRisk, $row['risk'] ?? 'n/a');
@endphp
<tr>
<td class="py-2 pr-4">{{ $row['type'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['label'] ?? '' }}</td>
<td class="py-2 pr-4">{{ $row['category'] ?? '' }}</td>
<td class="py-2 pr-4">{{ ($row['dependencies'] ?? false) ? '✅' : '—' }}</td>
<td class="py-2 pr-4">{{ $row['restore'] ?? 'enabled' }}</td>
<td class="py-2 pr-4">{{ $row['risk'] ?? 'normal' }}</td>
<td class="py-2 pr-4">
<span class="font-mono text-xs">{{ $row['type'] ?? '' }}</span>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$typeSpec->color" size="sm">
{{ $typeSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$categorySpec->color" size="sm">
{{ $categorySpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
@if ($row['dependencies'] ?? false)
<x-filament::icon
icon="heroicon-m-check-circle"
class="h-5 w-5 text-success-600 dark:text-success-400"
/>
@else
<x-filament::icon
icon="heroicon-m-minus-circle"
class="h-5 w-5 text-gray-400 dark:text-gray-500"
/>
@endif
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$restoreSpec->color" :icon="$restoreSpec->icon" size="sm">
{{ $restoreSpec->label }}
</x-filament::badge>
</td>
<td class="py-2 pr-4">
<x-filament::badge :color="$riskSpec->color" :icon="$riskSpec->icon" size="sm">
{{ $riskSpec->label }}
</x-filament::badge>
</td>
</tr>
@endforeach
</tbody>

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Tag Badge Catalog (Neutral, Suite-wide) v1
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-22
**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 run: 2026-01-22 — all items pass.
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@ -0,0 +1,29 @@
# Guardrails Contract — Tag Badge Catalog (v1)
This feature enforces a single source of truth for tag-like (metadata) badge rendering and prevents regression back to ad-hoc tag colors.
## What is enforced (v1)
- Tag-like badges must not use forbidden health/status colors (`success|warning|danger`).
- Tag-like badges should be rendered via the centralized tag badge catalog on targeted surfaces.
Status-like badges remain governed by BADGE-001 and are out of scope for these guardrails.
## Guard strategy (v1)
### Mapping tests
- Tag badge mapping tests assert:
- Canonical label normalization for representative values.
- Palette invariants (allowlist only).
- Unknown/unmapped fallback (“Other: <value>”).
### Lightweight repo guard
- A test scans the codebase and flags newly introduced forbidden colors used on tag-like badge surfaces.
- The guard is intentionally lightweight (pattern-based) to keep noise low and avoid blocking legitimate status-like semantics.
## Examples of patterns the guard should flag
- Tag-like table columns or Blade badges using `success`, `warning`, or `danger` colors to represent metadata.
- Newly introduced inline per-value color maps for tag domains that bypass the central catalog.
The exact patterns are defined by the guard test implementation and may be refined to reduce false positives.

View File

@ -0,0 +1,56 @@
# Tag Badge Semantics Contract — Tag Badge Catalog (v1)
This feature does not introduce HTTP APIs. These contracts define stable UI semantics for tag-like (metadata) badges across the admin panel.
## Scope (v1)
### In scope (tag-like / metadata)
- Policy type and policy category.
- Platform.
- Tenant environment.
- Visibility/lifecycle tags when used as metadata chips (not health/status).
- Snapshot type.
### Out of scope (status-like)
- Status/health/progress signals (queued/running/completed; succeeded/failed/partial; enabled/disabled; availability; severity/risk).
- Any status-like boolean that is intentionally “attention-worthy”.
## Neutral palette policy (v1)
### Allowed colors
- `gray`
- `primary`
- `info`
### Forbidden colors for tag-like badges
- `success`
- `warning`
- `danger`
## Domain → color mapping (v1)
Tag colors are assigned per domain (domain → color), not per individual tag value:
- Policy type → `gray`
- Policy category → `primary`
- Platform → `info`
- Tenant environment → `primary`
- Visibility/lifecycle → `gray`
- Snapshot type → `gray`
## Canonical labels (v1)
### Platform casing
Platform labels must be Title Case:
- “Windows”, “Android”, “iOS”, “macOS”, “All”
### Unknown / unmapped values
Unknown/unmapped values must render as:
- Label: “Other: <value>” (normalized)
- Color: neutral (from allowlist)
## Invariants (v1)
- Tag-like badges must not masquerade as health/status (no `success|warning|danger` colors).
- Unknown values must render safely and transparently (“Other: …”), never as an “official” curated tag.
- Tag badge mapping must be pure and must not trigger outbound HTTP, queued work, or DB lookups beyond already-loaded data.

View File

@ -0,0 +1,55 @@
# Data Model — Tag Badge Catalog (Neutral, Suite-wide) v1
This feature is UI-only. It introduces no database schema changes.
## Entities (existing)
### Tenant
- **Role**: scope boundary for all queries and UI rendering.
- **Tag-like fields used**:
- `environment` (for example: `prod|staging|dev|other`)
- **Status-like fields (explicitly out of scope)**:
- `status` (`active|archived`) is status-like/lifecycle and is standardized separately.
### Policy
- **Role**: policy inventory and entry point for policy-related UI.
- **Tag-like fields used** (source may be top-level columns and/or `metadata`):
- policy type
- policy category
- platform
- **Status-like fields (explicitly out of scope)**:
- `ignored_at` is a boolean-like status signal (“ignored”) and is handled via centralized status-like semantics.
### PolicyVersion
- **Role**: immutable snapshots/versions of a policy.
- **Tag-like fields used** (from `metadata` and/or `snapshot`):
- policy type
- platform
- snapshot type (for example: `full|incremental`)
### InventoryItem
- **Role**: “last observed” inventory record for an Intune object.
- **Tag-like fields used** (source may include `meta_jsonb`):
- inventory type/category
- platform
### BackupSet / BackupItem / BackupSchedule (selected)
- **Role**: backup metadata surfaces may display tag-like chips (schedule/frequency/type).
- **Tag-like fields used**: schedule/frequency/snapshot metadata where presented as chips (domain discovered during migration sweep).
## Entities (code-only)
### Tag badge domain
- **Role**: a named namespace for metadata tags that must render consistently (for example, “platform”, “policy category”, “tenant environment”).
- **Uniqueness**: domain name is unique within the application.
### Tag badge spec
- **Role**: the resolved UI meaning of a tag value (label + neutral color + optional icon).
- **Constraints**:
- Must be pure (no DB queries, no HTTP, no side effects).
- Color must be from the neutral allowlist: `gray|primary|info`.
- Unknown values must render as “Other: <value>” (normalized).
## State transitions
This feature does not introduce new state machines. It standardizes how existing metadata values render as neutral tag badges.

View File

@ -0,0 +1,75 @@
# Discovery: Tag-like Badges (060-tag-badge-catalog)
**Purpose**: Inventory current *tag-like* (non-status) badge usage and observed values before migrating to the centralized `TagBadgeCatalog`.
**Hard rule reminder**: Status-like badges (health/workflow/severity/risk/availability) remain governed by `BadgeCatalog` / `BadgeRenderer` (Feature 059). This discovery focuses on tag-like metadata only.
## Scope scanned (initial)
- `app/Filament/**` (Resources / RelationManagers / Pages / Widgets)
- `app/Livewire/**`
- `resources/views/filament/**`
## Canonical tag domains (v1)
- **Policy Type** (`policy_type`)
- Examples (from config): `deviceConfiguration`, `settingsCatalogPolicy`, `groupPolicyConfiguration`, `deviceCompliancePolicy`
- **Policy Category** (`category`)
- Examples (from config): `Configuration`, `Compliance`, `Update Management`, `Apps/MAM`
- **Platform** (`platform`)
- Examples observed/expected: `windows`, `android`, `iOS`, `macOS`, `all`, `mobile`
- **Tenant Environment** (`environment`)
- Examples: `prod`, `staging`, `dev`, (fallback: other)
- **Backup Schedule Frequency** (`frequency`)
- Examples: `daily`, `weekly`
## File/line hit list (pre-migration snapshot)
### Policies (list)
- `app/Filament/Resources/PolicyResource.php:243``policy_type` badge (tag-like)
- `app/Filament/Resources/PolicyResource.php:250``category` badge (tag-like, derived from policy type meta)
- `app/Filament/Resources/PolicyResource.php:260``platform` badge (tag-like)
### Policy Versions (list + relation manager)
- `app/Filament/Resources/PolicyVersionResource.php:180``policy_type` badge (tag-like)
- `app/Filament/Resources/PolicyVersionResource.php:181``platform` badge (tag-like)
- `app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php:27``policy_type` badge (tag-like)
### Inventory Items (list + detail)
- `app/Filament/Resources/InventoryItemResource.php:146``policy_type` badge (tag-like)
- `app/Filament/Resources/InventoryItemResource.php:150``category` badge (tag-like)
- `app/Filament/Resources/InventoryItemResource.php:152``platform` badge (tag-like)
### Tenants (list)
- `app/Filament/Resources/TenantResource.php:129``environment` badge (tag-like, currently treated with status-like colors)
### Backups (tables / pickers)
- `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php:53``policy_type` badge (tag-like)
- `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php:76``platform` badge (tag-like)
- `app/Livewire/BackupSetPolicyPickerTable.php:83``policy_type` badge (tag-like)
- `app/Livewire/BackupSetPolicyPickerTable.php:87``platform` badge (tag-like)
- `app/Filament/Resources/BackupScheduleResource.php:187``frequency` badge (tag-like, currently treated with status-like colors)
### Policy detail (Blade)
- `resources/views/filament/infolists/entries/policy-general.blade.php:176` — list badge color varies by whether the field is “platform” (`info`) or not (`gray`)
## Top inconsistencies (pre-migration)
- Environment tags in Tenants list are using status-like palette (`danger`/`warning`), which reads as health severity rather than metadata.
- Backup schedule frequency tags are using `success`/`warning`, which reads like health/progress.
- Platform labels are not consistently canonicalized (mixture of `macOS` / `macos` / `iOS` / `ios` possible across sources).
- Several tag badges rely on default Filament color (or ad-hoc formatting) instead of a central catalog.
## Post-migration checklist (to be completed)
- [X] All listed tag badges render via `TagBadgeCatalog` / `TagBadgeRenderer`
- [X] Tag badges use only `gray|primary|info`
- [X] Platform labels display as “Windows / Android / iOS / macOS / All”
- [X] Tenants environment badge no longer uses `danger|warning|success`
- [X] Backup schedule frequency badge no longer uses `success|warning`

View File

@ -0,0 +1,145 @@
# Implementation Plan: Tag Badge Catalog (Neutral, Suite-wide) v1
**Branch**: `060-tag-badge-catalog` | **Date**: 2026-01-23 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Introduce a centralized, suite-wide tag badge catalog for non-status metadata (type/category/platform/environment/visibility/snapshot type).
- Enforce a neutral, explicit palette allowlist (`gray|primary|info`) and domain-based color assignment (domain → color), so tags dont read as health signals.
- Migrate the minimum targeted surfaces plus any additional tag-like badges found during the discovery sweep.
- Add automated guardrails to prevent ad-hoc tag badge styling from reappearing.
## Technical Context
**Language/Version**: PHP 8.4.15 (Laravel 12)
**Primary Dependencies**: Filament v5 + Livewire v4
**Storage**: PostgreSQL
**Testing**: Pest v4 (PHPUnit v12 runtime via `php artisan test`)
**Target Platform**: Web application (Filament admin panel)
**Project Type**: Web (Laravel monolith)
**Performance Goals**: Tag badge mapping is constant-time; no added queries; no N+1; typical list pages render <2s for normal tenant sizes.
**Constraints**: Tenant-scoped; tag badge rendering must be DB-only and must not trigger outbound HTTP or job dispatch during render/polling/hydration.
**Scale/Scope**: Suite-wide migration for tag-like (metadata) badges across tables, detail views, and dashboard lists.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / Snapshots-second: no changes to inventory or snapshot semantics (UI-only rendering changes).
- Read/write separation: no write behavior introduced.
- Graph contract path: no Graph calls introduced.
- Deterministic capabilities: no capability derivation changes.
- Tenant isolation: all surfaces remain tenant-scoped; catalog mapping is pure.
- Run observability: no new operations/jobs/queues introduced; render remains DB-only.
- Automation: N/A (no queued/scheduled work introduced by this feature).
- Data minimization: no new data storage.
- Badge semantics (BADGE-001): status-like badges remain governed by `BadgeCatalog` / `BadgeRenderer`; this feature applies to tag-like badges only.
Status: ✅ No constitution violations (UI-only semantics; no new Graph calls; no new write behavior; tag badge mapping is pure and tenant-safe).
## Project Structure
### Documentation (this feature)
```text
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/
├── Filament/
│ ├── Pages/ # Update: replace tag badge usage where present
│ ├── Resources/ # Update: migrate tag-like badges to the tag catalog
│ └── Widgets/ # Update: migrate dashboard tag chips
└── Support/
└── Badges/ # New/Update: introduce tag badge catalog (separate from status-like catalog)
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/views/
└── filament/ # Update: migrate any tag badge Blade usage
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/
├── Feature/Guards/ # New/Update: lightweight “no forbidden tag colors” guard
└── Unit/ # New: tag badge mapping tests per domain
```
**Structure Decision**: Laravel monolith + Filament v5 conventions. Keep status-like badge semantics in `app/Support/Badges/*` (Feature 059). Add a separate, neutral tag badge catalog under the same namespace (but not mixed with status domains) and consume it from Filament resources/pages/widgets + Blade views.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
None.
## Phase 0 — Outline & Research (complete)
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/research.md`
- Key decisions captured:
- Neutral palette allowlist: `gray|primary|info`.
- Domain-based colors (domain → color), not per-tag-value colors.
- Unknown/unmapped fallback: “Other: <value>” (normalized).
- v1 coverage: minimum targeted surfaces plus any additional tag-like badges found in discovery.
## Phase 1 — Design & Contracts (complete)
### Data model
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/data-model.md`
- No schema changes required; tag badge semantics derive from existing metadata fields.
### Contracts
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/contracts/tag-badge-semantics.md`
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/contracts/guardrails.md`
### Quickstart
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/quickstart.md`
### Agent context update
- Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/Agents.md`
### Provider registration (Laravel 11+)
- Panel providers remain registered in `bootstrap/providers.php` (no changes required for this feature unless adding a new provider).
### Livewire / Filament version safety
- Livewire v4.0+ (required by Filament v5) is in use.
### Asset strategy
- No new assets expected. If new panel assets are introduced during implementation, ensure deployment runs `php artisan filament:assets`.
### Destructive actions
- None introduced in this feature.
### Constitution re-check (post-design)
- ✅ Inventory-first / Snapshots-second: unaffected (UI-only semantics).
- ✅ Read/write separation: this feature is read-only.
- ✅ Graph contract path: no Graph calls added.
- ✅ Tenant isolation: tag badge mapping is pure and uses already-available tenant-scoped data.
- ✅ Run observability: no new long-running work introduced.
- ✅ Data minimization: no new payload storage.
- ✅ BADGE-001: status-like badge semantics remain centralized; tag badge semantics are handled separately and remain neutral.
**Gate status (post-design)**: PASS
## Phase 2 — Implementation Plan (next)
### Story 1 (P1): Consistent, neutral tags on core inventory surfaces
- Add a centralized tag badge catalog covering at least: policy type, policy category, platform, tenant environment, visibility/lifecycle, snapshot type.
- Migrate Policies / Policy Versions / Inventory / Tenants to the tag badge catalog.
- Enforce: domain-based colors and neutral palette allowlist.
### Story 2 (P2): Suite-wide consistency via discovery-driven migration
- Produce a discovery report of existing tag badge surfaces and values.
- Migrate additional tag-like badges found during discovery (dashboards, detail views, backup/snapshot metadata) while keeping status-like badges on the status catalog.
### Story 3 (P3): Guardrails keep consistency over time
- Add mapping tests per tag domain, plus an invariant test enforcing the allowed palette.
- Add a lightweight guard test that flags forbidden tag colors (`success|warning|danger`) on tag-like badge surfaces.

View File

@ -0,0 +1,26 @@
# Quickstart — Tag Badge Catalog (v1)
## Prereqs
- Run everything via Sail.
## Setup
- `vendor/bin/sail up -d`
- `vendor/bin/sail composer install`
## Run tests (targeted)
When implemented, run:
- Tag badge mapping tests (new).
- Tag badge palette invariant test (new).
- Tag badge guard test (new, lightweight scan).
## Manual QA (tenant-scoped)
- Policies list: policy type/category/platform chips are consistent and neutral (no health/status colors).
- Policy versions + inventory: the same platform value renders the same label and neutral styling.
- Tenants list: environment tag is neutral and consistent.
- Unknown/new tag values: rendered as “Other: <value>” in a neutral color (and still readable).
- Dark mode: tag chips remain readable and do not resemble health alerts.
## Frontend assets
If UI changes dont show:
- `vendor/bin/sail npm run dev`
- or `vendor/bin/sail npm run build`

View File

@ -0,0 +1,77 @@
# Research — Tag Badge Catalog (Neutral, Suite-wide) v1
## Goal
Standardize non-status metadata badges (“tags/chips”) suite-wide so operators can reliably scan the admin UI without confusing metadata with health/status signals.
## Existing Code & Patterns (to reuse)
### Central badge semantics pattern (status-like)
- Status-like badge semantics are centralized in:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeCatalog.php`
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Badges/BadgeRenderer.php`
This feature must not mix tag-like semantics into the status-like catalog; it should follow a similar centralization pattern, but remain neutral and separate.
### Filament badge surfaces (current)
- Tables commonly use `TextColumn::badge()` to display chips.
- Blade views sometimes use `<x-filament::badge>` directly for badge-like UI.
### Guard test pattern (current)
- The repo uses Pest “guard” tests that scan the codebase for forbidden patterns, for example:
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
## Key Decisions
### Decision: Centralize tag-like badge semantics behind a dedicated catalog
- **Decision**: Introduce a dedicated tag badge catalog for metadata tags (separate from the status-like `BadgeCatalog`).
- **Rationale**: Prevents per-page drift and makes tag presentation testable and enforceable without changing business semantics.
- **Alternatives considered**:
- **Inline mappings per resource/widget/view**: rejected (drifts quickly; hard to enforce).
- **Reusing status-like domains**: rejected (tags must remain neutral and not inherit health semantics).
### Decision: Neutral palette is an explicit allowlist
- **Decision**: Tag badges are restricted to an explicit allowlist: `gray`, `primary`, `info`.
- **Rationale**: Keeps tag chips clearly “metadata” and prevents accidental use of health/status semantics.
- **Alternatives considered**:
- **“Neutral by convention” without an allowlist**: rejected (too ambiguous; hard to test and enforce).
### Decision: Tag badge colors are assigned per domain (domain → color)
- **Decision**: The tag badge color is chosen by domain, not by individual tag value.
- **Rationale**: Keeps tags scannable (domain is visually recognizable) while avoiding a “rainbow” palette that becomes hard to maintain.
- **Alternatives considered**:
- **One color for everything**: rejected (loses useful grouping at-a-glance).
- **Per-tag-value colors**: rejected (too complex; tends to drift and become noisy).
### Decision: v1 migration coverage is discovery-driven but bounded
- **Decision**: v1 migrates the minimum targeted surfaces, plus any additional tag-like badges found during the discovery sweep.
- **Rationale**: Achieves meaningful suite-wide consistency without turning into an unbounded refactor.
- **Alternatives considered**:
- **Full sweep**: rejected (can sprawl and delay value).
- **Minimum only**: rejected (leaves too many inconsistent surfaces behind).
### Decision: Unknown/unmapped values are shown as “Other: <value>
- **Decision**: Unknown/unmapped tag values render as a neutral badge with label “Other: <value>” (normalized).
- **Rationale**: Preserves information for admins while clearly indicating it is not a curated/known tag.
- **Alternatives considered**:
- **Raw value only**: rejected (can look “official/curated”).
- **“Unknown” without value**: rejected (hides useful info).
- **Hide badge**: rejected (drops data).
### Decision: Canonical platform label casing is Title Case
- **Decision**: Platform labels use Title Case: “Windows”, “Android”, “iOS”, “macOS”, “All”.
- **Rationale**: Improves readability and matches common admin UI expectations.
## Initial Domain → Color Mapping (v1)
This mapping is intentionally small and neutral (allowlist only), and is applied consistently suite-wide:
- Policy type → `gray`
- Policy category → `primary`
- Platform → `info`
- Tenant environment → `primary`
- Visibility/lifecycle → `gray`
- Snapshot type → `gray`
## Open Questions
- Discovery may surface additional tag domains/values. v1 will add them as part of the migration sweep while keeping the neutral palette and domain-based color rule.

View File

@ -0,0 +1,118 @@
# Feature Specification: Tag Badge Catalog (Neutral, Suite-wide) v1
**Feature Branch**: `060-tag-badge-catalog`
**Created**: 2026-01-22
**Status**: Draft
**Input**: Standardize non-status metadata badges (“tags/chips”) across the admin suite with consistent labels and a neutral palette, without changing any status/severity/health semantics.
## Clarifications
### Session 2026-01-23
- Q: Tag badge palette strategy → A: Neutral colors are assigned per tag domain (domain → color), consistent across the suite (not per individual tag value).
- Q: v1 migration coverage → A: v1 migrates the minimum targeted surfaces plus any additional tag-like badges found in the discovery sweep.
- Q: Unknown/unmapped tag value label → A: Show “Other: <value>” (normalized), so the value remains visible but clearly uncurated.
- Q: Canonical platform label casing → A: Use Title Case labels (Windows, Android, iOS, macOS, All).
- Q: Scope of “neutral palette” → A: Use an explicit allowlist of neutral colors only: gray, primary, info.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Read policy metadata clearly (Priority: P1)
An administrator browsing policy lists can quickly understand policy metadata (type, category, platform, visibility) via consistent tag badges that do not imply health or risk.
**Why this priority**: Policy inventory is a primary workflow; inconsistent tag styling causes repeated confusion and slows review.
**Independent Test**: On the Policies list, tag badges are consistent (label + neutral color) for the same values across multiple rows, and none appear as “success/warning/danger” style indicators.
**Acceptance Scenarios**:
1. **Given** a Policies list with mixed policy types, **When** the user views the list, **Then** each policys type/category/platform appears as a neutral tag badge with a canonical label.
2. **Given** a Policies list that includes archived and active items, **When** the user views the list, **Then** visibility tags are clearly labeled and do not use health/status colors.
---
### User Story 2 - Consistent tags across key surfaces (Priority: P2)
An administrator sees the same tag values rendered the same way across the suite (lists, detail views, and dashboard snippets), so they can switch between areas without re-learning color meanings.
**Why this priority**: Inconsistency across surfaces makes the UI feel unreliable and increases misinterpretation of metadata.
**Independent Test**: On each targeted surface, the same tag value (e.g., “Windows” platform) renders with the same label and neutral styling.
**Acceptance Scenarios**:
1. **Given** an inventory item and a policy version that share a platform value, **When** the user views both pages, **Then** the platform tag badge matches in label and neutral styling.
2. **Given** a tenant list that includes multiple environments, **When** the user views the list, **Then** environment tags are neutral and consistent across rows.
---
### User Story 3 - Prevent regressions (Priority: P3)
The product team can extend or refine tag display rules without reintroducing ad-hoc badge styling across individual pages.
**Why this priority**: Without guardrails, inconsistent tag colors tend to reappear over time as new pages and fields are added.
**Independent Test**: A change that introduces a non-neutral tag color is detected by automated validation.
**Acceptance Scenarios**:
1. **Given** a newly introduced tag value, **When** it is displayed anywhere in the admin UI, **Then** it uses a neutral color and a canonical label (or a neutral fallback if unmapped).
2. **Given** a change that attempts to render a tag badge using a reserved health/status color, **When** automated validation runs, **Then** it fails with a clear signal that the neutral palette rule was violated.
### Edge Cases
- Unknown tag values (new/rare values): show a neutral badge labeled “Other: <value>” (normalized).
- Missing metadata values: no misleading empty badge is shown; the UI handles absence gracefully.
- Confusable values (e.g., “active” as lifecycle vs status): tag meaning is based on field context, not the label itself.
- Mixed casing/source formatting (e.g., `windows`, `Windows`, `WINDOWS`): the displayed label is consistent.
- Very long labels: tags remain readable without breaking layout or implying urgency.
## Requirements *(mandatory)*
**Constitution alignment:** This feature changes UI presentation only. It must not add external API calls, background work, or any write behavior simply by viewing pages.
**Badge semantics alignment:** This feature standardizes tag-like (metadata) badges only. Status-like badges (status/outcome/severity/risk/availability/boolean) remain governed by the existing centralized status badge semantics.
### Scope
**In scope**
- Standardize suite-wide display rules for non-status metadata tags (e.g., policy type/category, platform, tenant environment, visibility, snapshot type).
- Apply consistent, neutral tag badges across key admin surfaces where metadata badges are shown, plus additional tag-like badges discovered during the discovery sweep.
**Out of scope**
- Changing business meaning or introducing new tag values (beyond canonical label casing).
- Re-standardizing status/severity/health/workflow badges (handled separately).
- Any layout or information-architecture redesign beyond badge rendering consistency.
### Functional Requirements
- **FR-001**: System MUST provide a single source of truth for tag badge display rules (label + neutral color + optional icon) grouped by tag domain (e.g., policy type, platform).
- **FR-002**: System MUST render all tag-like badges via the centralized catalog on targeted surfaces (tables, detail views, and dashboard lists) where metadata badges are shown.
- **FR-003**: Tag badges MUST use a neutral palette and MUST NOT use the suites reserved health/status colors (e.g., green/yellow/red semantics). The neutral palette MUST be an explicit allowlist: gray, primary, info.
- **FR-003a**: Tag badge colors MUST be assigned per tag domain (domain → color) and remain consistent across the suite.
- **FR-004**: System MUST keep a hard separation between tag-like badges (metadata) and status-like badges (health/workflow/severity). This feature MUST NOT change status-like badge meaning, color, or wording.
- **FR-005**: System MUST provide canonical, consistent labels for mapped tag values (including consistent casing).
- **FR-005a**: Platform tag labels MUST use Title Case: “Windows”, “Android”, “iOS”, “macOS”, and “All”.
- **FR-006**: System MUST provide a neutral fallback for unknown/unmapped tag values that remains readable and does not imply health or risk. The fallback label MUST be “Other: <value>” (normalized).
- **FR-007**: The initial rollout MUST cover, at minimum: Policies, Policy Versions, Inventory, Tenants, and any backup/snapshot metadata badges already displayed in the suite, plus any additional tag-like badges discovered during the discovery sweep.
- **FR-008**: System MUST include automated guardrails that detect ad-hoc tag badge styling and enforce the neutral palette rule for tag-like badges.
- **FR-009**: Viewing pages with tag badges MUST NOT trigger external requests or background work beyond the pages normal data load.
- **FR-010**: The centralized catalog MUST cover, at minimum, these tag domains: policy type, policy category, platform, tenant environment, visibility/lifecycle, and snapshot type.
### Assumptions
- Feature 059 (centralized status + severity badges) is available and remains the source of truth for all status-like badges.
- Existing tag values are reused; this feature standardizes presentation and canonical label casing only.
- The suite has a consistent understanding of “tag-like” metadata fields versus “status-like” fields; any ambiguous field is treated as status-like and excluded from this feature.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: On all targeted surfaces, 100% of tag badges use only the approved neutral palette (gray, primary, info), verified via automated validation and spot-check QA.
- **SC-002**: In an internal review, at least 90% of administrators report that tag badges read as metadata and do not confuse them with health/status signals.
- **SC-003**: For each canonical tag domain (policy type/category, platform, tenant environment, visibility, snapshot type), the same value is displayed with the same label across at least two different areas of the suite.
- **SC-004**: Viewing pages containing tag badges does not introduce additional side effects (no unexpected external requests or background work triggered by badge rendering), verified via test/QA observation.

View File

@ -0,0 +1,174 @@
---
description: "Task list for feature implementation"
---
# Tasks: Tag Badge Catalog (Neutral, Suite-wide) v1
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/plan.md`, `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/060-tag-badge-catalog/spec.md`
**Tests**: Required (Pest) because UI/runtime behavior changes are introduced.
**Operations**: No new long-running/remote/queued/scheduled work introduced.
**Badges**: Status-like badges remain governed by BADGE-001 (`BadgeCatalog` / `BadgeRenderer`). This feature covers tag-like (metadata) badges only.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `- [ ] T### [P?] [US?] Description with file path`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[US#]**: Which user story this task belongs to (US1/US2/US3). Setup + Foundational + Polish tasks have no story label.
- Every task includes an exact file path (or command path).
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Ensure the local environment is ready for implementation and testing.
- [X] T001 Start local stack via `vendor/bin/sail` (run: `vendor/bin/sail up -d` and `vendor/bin/sail composer install`)
- [X] T002 Confirm baseline test green via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Feature/Guards/NoAdHocStatusBadgesTest.php`)
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Central tag badge semantics layer required by all user stories.
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
- [X] T003 [P] Add tag badge domain enum in `app/Support/Badges/TagBadgeDomain.php`
- [X] T004 [P] Add tag badge spec value object (neutral allowlist only) in `app/Support/Badges/TagBadgeSpec.php`
- [X] T005 Implement central catalog (domain → spec mapping + normalization + “Other: <value>” fallback) in `app/Support/Badges/TagBadgeCatalog.php`
- [X] T006 Implement Filament helper closures (label/color/icon/iconColor) in `app/Support/Badges/TagBadgeRenderer.php`
- [X] T007 [P] Add mapping tests for core domains (policy type/category/platform) in `tests/Unit/Badges/TagBadgeCatalogTest.php`
- [X] T008 [P] Add palette invariant test (never returns `success|warning|danger`) in `tests/Unit/Badges/TagBadgePaletteInvariantTest.php`
- [X] T009 Run foundational unit tests via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Unit/Badges/TagBadgeCatalogTest.php`)
**Checkpoint**: Tag badge catalog exists, is pure, and is unit-tested.
---
## Phase 3: User Story 1 - Read policy metadata clearly (Priority: P1) 🎯 MVP
**Goal**: Policies list shows consistent, neutral tag badges for type/category/platform without implying health or risk.
**Independent Test**: In the Policies list, the same policy type/category/platform values render with the same label + neutral color, and no tag badge uses `success|warning|danger`.
### Implementation for User Story 1
- [X] T010 [US1] Migrate policy type badges to TagBadgeRenderer in `app/Filament/Resources/PolicyResource.php`
- [X] T011 [US1] Migrate policy category badges to TagBadgeRenderer in `app/Filament/Resources/PolicyResource.php`
- [X] T012 [US1] Migrate platform badges to TagBadgeRenderer (Title Case labels) in `app/Filament/Resources/PolicyResource.php`
**Checkpoint**: User Story 1 is complete and independently verifiable on the Policies list.
---
## Phase 4: User Story 2 - Consistent tags across key surfaces (Priority: P2)
**Goal**: The same tag values render consistently across lists, detail views, relation managers, and key tools.
**Independent Test**: Pick a platform value (e.g., “Windows”) and verify it renders with the same label + neutral styling in Policies, Policy Versions, Inventory, Backup policy picker, and any detail view that shows platform tags.
### Implementation for User Story 2
- [X] T013 [US2] Create discovery report of tag-like badges (file/line hits + observed values) in `specs/060-tag-badge-catalog/discovery.md`
- [X] T014 [P] [US2] Migrate tenant environment badge to TagBadgeRenderer in `app/Filament/Resources/TenantResource.php`
- [X] T015 [P] [US2] Migrate inventory list badges (type/category/platform) to TagBadgeRenderer in `app/Filament/Resources/InventoryItemResource.php`
- [X] T016 [P] [US2] Migrate inventory detail badges (type/category/platform) to TagBadgeRenderer in `app/Filament/Resources/InventoryItemResource.php`
- [X] T017 [P] [US2] Migrate policy version list badges (policy_type/platform) to TagBadgeRenderer in `app/Filament/Resources/PolicyVersionResource.php`
- [X] T018 [P] [US2] Migrate policy version detail badges (policy_type/platform) to TagBadgeRenderer in `app/Filament/Resources/PolicyVersionResource.php`
- [X] T019 [P] [US2] Migrate policy versions relation manager tag badges to TagBadgeRenderer in `app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`
- [X] T020 [P] [US2] Migrate backup item tag badges (policy_type/platform) to TagBadgeRenderer in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`
- [X] T021 [P] [US2] Migrate backup set policy picker tag badges (policy_type/platform) to TagBadgeRenderer in `app/Livewire/BackupSetPolicyPickerTable.php`
- [X] T022 [P] [US2] Migrate backup schedule frequency badge to TagBadgeRenderer (neutral allowlist) in `app/Filament/Resources/BackupScheduleResource.php`
- [X] T023 [P] [US2] Migrate platform tag badges in policy detail view to TagBadgeCatalog in `resources/views/filament/infolists/entries/policy-general.blade.php`
- [X] T024 [US2] Update and close discovery report with a “post-migration” check in `specs/060-tag-badge-catalog/discovery.md`
**Checkpoint**: User Story 2 is complete and tag consistency holds across the targeted surfaces.
---
## Phase 5: User Story 3 - Prevent regressions (Priority: P3)
**Goal**: Prevent “random tag colors” and ad-hoc tag badge semantics from reappearing.
**Independent Test**: Automated validation fails when a tag-like badge uses `success|warning|danger` or bypasses the tag badge catalog on known tag surfaces.
### Implementation for User Story 3
- [X] T025 [P] [US3] Expand mapping tests to cover environment/visibility/snapshot type/frequency in `tests/Unit/Badges/TagBadgeCatalogTest.php`
- [X] T026 [P] [US3] Add lightweight guard test for forbidden tag colors in `tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php`
- [X] T027 [US3] Tighten guard exclusions to avoid status-like false positives in `tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php`
- [X] T028 [US3] Run guard + unit tests via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php`)
**Checkpoint**: Guardrails and tests prevent regressions.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final quality gates and verification before implementation work is considered done.
- [X] T029 Run formatting on changed files via `vendor/bin/sail` (run: `vendor/bin/sail php ./vendor/bin/pint --dirty`)
- [X] T030 Run full tag-badge test subset via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Unit/Badges/TagBadgeCatalogTest.php`)
- [X] T031 Re-run suite badge guards via `vendor/bin/sail` (run: `vendor/bin/sail artisan test tests/Feature/Guards/NoAdHocStatusBadgesTest.php`)
- [x] T032 Validate manual QA steps in `specs/060-tag-badge-catalog/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)****Foundational (Phase 2)****US1 (Phase 3)****US2 (Phase 4)****US3 (Phase 5)** → **Polish (Phase 6)**
### User Story Dependencies
- **US1 (P1)**: Depends on Foundational; no dependency on US2/US3.
- **US2 (P2)**: Depends on Foundational; can proceed after (or alongside) US1.
- **US3 (P3)**: Depends on Foundational; can be implemented in parallel with US1/US2, but must be complete before final validation.
---
## Parallel Examples
### Parallel Example: User Story 1
```bash
Task: "Migrate policy type badges to TagBadgeRenderer in app/Filament/Resources/PolicyResource.php"
Task: "Migrate policy category badges to TagBadgeRenderer in app/Filament/Resources/PolicyResource.php"
Task: "Migrate platform badges to TagBadgeRenderer (Title Case labels) in app/Filament/Resources/PolicyResource.php"
```
### Parallel Example: User Story 2
```bash
Task: "Migrate tenant environment badge to TagBadgeRenderer in app/Filament/Resources/TenantResource.php"
Task: "Migrate policy version list badges (policy_type/platform) to TagBadgeRenderer in app/Filament/Resources/PolicyVersionResource.php"
Task: "Migrate inventory list badges (type/category/platform) to TagBadgeRenderer in app/Filament/Resources/InventoryItemResource.php"
Task: "Migrate backup schedule frequency badge to TagBadgeRenderer (neutral allowlist) in app/Filament/Resources/BackupScheduleResource.php"
```
### Parallel Example: User Story 3
```bash
Task: "Expand mapping tests to cover environment/visibility/snapshot type/frequency in tests/Unit/Badges/TagBadgeCatalogTest.php"
Task: "Add lightweight guard test for forbidden tag colors in tests/Feature/Guards/NoForbiddenTagBadgeColorsTest.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1 (Setup)
2. Complete Phase 2 (Foundational)
3. Complete Phase 3 (US1)
4. Run Phase 6 tasks T029T032 (at minimum: pint + targeted tests) before considering MVP done
### Incremental Delivery
1. Foundation ready → deliver US1 (Policies list)
2. Deliver US2 (cross-surface consistency + discovery-driven migration)
3. Deliver US3 (guardrails) and re-run all targeted tests

View File

@ -0,0 +1,135 @@
<?php
use Illuminate\Support\Collection;
it('does not contain forbidden status-like colors for tag badges', function () {
$root = base_path();
$self = realpath(__FILE__);
$directories = [
$root.'/app/Filament',
$root.'/app/Livewire',
];
$excludedPaths = [
$root.'/vendor',
$root.'/storage',
$root.'/specs',
$root.'/spechistory',
$root.'/references',
$root.'/public/build',
];
$tagLikeFieldPattern = '/::make\\(\\s*[\'"](?:policy_type|category|platform|environment|visibility|frequency|snapshot_type)[\'"]/';
$badgePattern = '/->badge\\s*\\(/';
$colorCallStartPattern = '/->(?:color|iconColor)\\s*\\(/';
$forbiddenColorLiteralPattern = '/[\'"](?:success|warning|danger)[\'"]/';
$lookaheadLines = 45;
$elementEndPattern = '/\\),\\s*$/';
/** @var Collection<int, string> $files */
$files = collect($directories)
->filter(fn (string $dir): bool => is_dir($dir))
->flatMap(function (string $dir): array {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
$paths = [];
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$path = $file->getPathname();
if (! str_ends_with($path, '.php')) {
continue;
}
$paths[] = $path;
}
return $paths;
})
->filter(function (string $path) use ($excludedPaths, $self): bool {
if ($self && realpath($path) === $self) {
return false;
}
foreach ($excludedPaths as $excluded) {
if (str_starts_with($path, $excluded)) {
return false;
}
}
return true;
})
->values();
$hits = [];
foreach ($files as $path) {
$contents = file_get_contents($path);
if (! is_string($contents) || $contents === '') {
continue;
}
$lines = preg_split('/\R/', $contents) ?: [];
foreach ($lines as $index => $line) {
if (! preg_match($tagLikeFieldPattern, $line)) {
continue;
}
$elementEndOffset = null;
$windowLines = array_slice($lines, $index, $lookaheadLines);
foreach ($windowLines as $offset => $windowLine) {
if (preg_match($elementEndPattern, (string) $windowLine)) {
$elementEndOffset = $offset;
break;
}
}
$elementLines = $elementEndOffset === null
? $windowLines
: array_slice($windowLines, 0, $elementEndOffset + 1);
$element = implode("\n", $elementLines);
if (! preg_match($badgePattern, $element)) {
continue;
}
$hasForbiddenColor = false;
foreach ($elementLines as $offset => $windowLine) {
if (! preg_match($colorCallStartPattern, (string) $windowLine)) {
continue;
}
$colorWindow = implode("\n", array_slice($elementLines, $offset));
if (! preg_match($forbiddenColorLiteralPattern, $colorWindow)) {
continue;
}
$hasForbiddenColor = true;
break;
}
if (! $hasForbiddenColor) {
continue;
}
$relative = str_replace($root.'/', '', $path);
$hits[] = $relative.':'.($index + 1).' -> '.trim($line);
}
}
expect($hits)->toBeEmpty("Forbidden tag badge colors found:\n".implode("\n", $hits));
});

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps enabled/disabled values to canonical badge semantics', function (mixed $value, string $label, string $color, ?string $icon): void {
$spec = BadgeCatalog::spec(BadgeDomain::BooleanEnabled, $value);
expect($spec->label)->toBe($label);
expect($spec->color)->toBe($color);
expect($spec->icon)->toBe($icon);
})->with([
'bool true' => [true, 'Enabled', 'success', 'heroicon-m-check-circle'],
'bool false' => [false, 'Disabled', 'gray', 'heroicon-m-minus-circle'],
'int 1' => [1, 'Enabled', 'success', 'heroicon-m-check-circle'],
'int 0' => [0, 'Disabled', 'gray', 'heroicon-m-minus-circle'],
'string enabled' => ['enabled', 'Enabled', 'success', 'heroicon-m-check-circle'],
'string disabled' => ['disabled', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
'string yes' => ['yes', 'Enabled', 'success', 'heroicon-m-check-circle'],
'string no' => ['no', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
'string true' => ['true', 'Enabled', 'success', 'heroicon-m-check-circle'],
'string false' => ['false', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
'string 1' => ['1', 'Enabled', 'success', 'heroicon-m-check-circle'],
'string 0' => ['0', 'Disabled', 'gray', 'heroicon-m-minus-circle'],
]);

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeSpec;
it('maps policy type tags to canonical label + domain color', function (): void {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, 'deviceConfiguration');
expect($spec->label)->toBe('Device Configuration');
expect($spec->color)->toBe('primary');
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
});
it('maps policy category tags to canonical label + domain color', function (): void {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, 'Configuration');
expect($spec->label)->toBe('Configuration');
expect($spec->color)->toBe('gray');
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
});
it('falls back to Other: <value> for unknown policy category values', function (): void {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyCategory, 'Mystery Category');
expect($spec->label)->toBe('Other: Mystery Category');
expect($spec->color)->toBe('gray');
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
});
it('maps platform tags to canonical Title Case labels', function (): void {
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'windows')->label)->toBe('Windows');
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'Android')->label)->toBe('Android');
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'iOS')->label)->toBe('iOS');
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'macOS')->label)->toBe('macOS');
expect(TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'all')->label)->toBe('All');
});
it('falls back to Other: <value> for unknown platform values', function (): void {
$spec = TagBadgeCatalog::spec(TagBadgeDomain::Platform, 'plan9');
expect($spec->label)->toBe('Other: plan9');
expect($spec->color)->toBe('info');
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
});
it('maps tenant environment tags to canonical labels + neutral colors', function (): void {
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'prod')->label)->toBe('Prod');
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'staging')->label)->toBe('Staging');
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'dev')->label)->toBe('Dev');
expect(TagBadgeCatalog::spec(TagBadgeDomain::TenantEnvironment, 'production')->label)->toBe('Prod');
});
it('maps visibility tags to canonical labels + neutral colors', function (): void {
$active = TagBadgeCatalog::spec(TagBadgeDomain::Visibility, 'active');
expect($active->label)->toBe('Active');
expect($active->color)->toBe('gray');
expect($active->color)->toBeIn(TagBadgeSpec::allowedColors());
$archived = TagBadgeCatalog::spec(TagBadgeDomain::Visibility, 'archived');
expect($archived->label)->toBe('Archived');
expect($archived->color)->toBe('gray');
expect($archived->color)->toBeIn(TagBadgeSpec::allowedColors());
});
it('maps snapshot type tags to canonical labels + neutral colors', function (): void {
$full = TagBadgeCatalog::spec(TagBadgeDomain::SnapshotType, 'full');
expect($full->label)->toBe('Full');
expect($full->color)->toBe('primary');
expect($full->color)->toBeIn(TagBadgeSpec::allowedColors());
$incremental = TagBadgeCatalog::spec(TagBadgeDomain::SnapshotType, 'incremental');
expect($incremental->label)->toBe('Incremental');
expect($incremental->color)->toBe('primary');
expect($incremental->color)->toBeIn(TagBadgeSpec::allowedColors());
});
it('maps backup schedule frequency tags to canonical labels + neutral colors', function (): void {
$daily = TagBadgeCatalog::spec(TagBadgeDomain::BackupScheduleFrequency, 'daily');
expect($daily->label)->toBe('Daily');
expect($daily->color)->toBe('info');
expect($daily->color)->toBeIn(TagBadgeSpec::allowedColors());
$weekly = TagBadgeCatalog::spec(TagBadgeDomain::BackupScheduleFrequency, 'weekly');
expect($weekly->label)->toBe('Weekly');
expect($weekly->color)->toBe('info');
expect($weekly->color)->toBeIn(TagBadgeSpec::allowedColors());
});

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Support\Badges\TagBadgeCatalog;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeSpec;
use App\Support\Inventory\InventoryPolicyTypeMeta;
it('never returns status-like colors for tag badges', function (): void {
$forbidden = ['success', 'warning', 'danger'];
foreach (TagBadgeDomain::cases() as $domain) {
$known = TagBadgeCatalog::spec($domain, 'known');
expect($known->color)->not->toBeIn($forbidden);
expect($known->color)->toBeIn(TagBadgeSpec::allowedColors());
$unknown = TagBadgeCatalog::spec($domain, 'unknown-value');
expect($unknown->color)->not->toBeIn($forbidden);
expect($unknown->color)->toBeIn(TagBadgeSpec::allowedColors());
}
});
it('uses only neutral colors for all configured policy types', function (): void {
$forbidden = ['success', 'warning', 'danger'];
foreach (InventoryPolicyTypeMeta::all() as $meta) {
if (! is_array($meta)) {
continue;
}
$type = $meta['type'] ?? null;
if (! is_string($type) || trim($type) === '') {
continue;
}
$spec = TagBadgeCatalog::spec(TagBadgeDomain::PolicyType, $type);
expect($spec->color)->not->toBeIn($forbidden);
expect($spec->color)->toBeIn(TagBadgeSpec::allowedColors());
}
});