feat: implement spec 286 UI copy, IA & localization neutralization (#345)

## Summary

Implements feature branch `286-ui-copy-ia-localization-neutralization`.

This change set:
- aligns chooser, managed-environment landing, dashboard, shell, and workspace context copy to environment-first terminology
- neutralizes the bounded policy and baseline helper copy called out by Spec 286
- adds focused feature, guard, and browser coverage plus the complete Spec 286 artifact set
- records the discovered `Capture snapshot` modal issue as out-of-scope runtime debt in the Spec 286 close-out notes

## Validation

- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/EnvironmentContextTerminologyTest.php tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php tests/Feature/Filament/Localization/PolicyInventoryLocalizationTest.php tests/Feature/Guards/EnvironmentCopyNeutralizationGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes

- Target branch: `platform-dev`
- Filament remains on v5 with Livewire v4.
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
- No new destructive actions, asset strategy changes, or global-search posture changes are introduced in this slice.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #345
This commit is contained in:
ahmido 2026-05-09 23:29:11 +00:00
parent c7b38606a9
commit aeef285d1d
61 changed files with 1751 additions and 191 deletions

View File

@ -27,10 +27,13 @@ class ChooseTenant extends Page
protected static ?string $slug = 'choose-tenant'; protected static ?string $slug = 'choose-tenant';
protected static ?string $title = 'Choose tenant';
protected string $view = 'filament.pages.choose-tenant'; protected string $view = 'filament.pages.choose-tenant';
public function getTitle(): string
{
return __('localization.shell.choose_environment');
}
/** /**
* Disable the simple-layout topbar to prevent lazy-loaded * Disable the simple-layout topbar to prevent lazy-loaded
* DatabaseNotifications from triggering Livewire update 404s. * DatabaseNotifications from triggering Livewire update 404s.

View File

@ -209,7 +209,7 @@ protected function getHeaderActions(): array
if ($activeTenant instanceof ManagedEnvironment) { if ($activeTenant instanceof ManagedEnvironment) {
$actions[] = Action::make('operate_hub_show_all_tenants') $actions[] = Action::make('operate_hub_show_all_tenants')
->label('Show all tenants') ->label(__('localization.shell.show_all_environments'))
->color('gray') ->color('gray')
->action(function (): void { ->action(function (): void {
Filament::setTenant(null, true); Filament::setTenant(null, true);
@ -256,13 +256,13 @@ public function landingHierarchySummary(): array
return [ return [
'scope_label' => $operateHubShell->scopeLabel(request()), 'scope_label' => $operateHubShell->scopeLabel(request()),
'scope_body' => $activeTenant instanceof ManagedEnvironment 'scope_body' => $activeTenant instanceof ManagedEnvironment
? 'The landing is currently narrowed to one tenant inside the active workspace.' ? 'The landing is currently narrowed to one environment inside the active workspace.'
: 'The landing is currently showing workspace-wide monitoring across all entitled tenants.', : 'The landing is currently showing workspace-wide monitoring across all entitled environments.',
'return_label' => $returnLabel, 'return_label' => $returnLabel,
'return_body' => $returnBody, 'return_body' => $returnBody,
'scope_reset_label' => $activeTenant instanceof ManagedEnvironment ? 'Show all tenants' : null, 'scope_reset_label' => $activeTenant instanceof ManagedEnvironment ? __('localization.shell.show_all_environments') : null,
'scope_reset_body' => $activeTenant instanceof ManagedEnvironment 'scope_reset_body' => $activeTenant instanceof ManagedEnvironment
? 'Reset the landing back to workspace-wide monitoring when tenant-specific context is no longer needed.' ? 'Reset the landing back to workspace-wide monitoring when environment-specific context is no longer needed.'
: null, : null,
'inspect_body' => 'Open a run from the table to enter the canonical monitoring detail viewer.', 'inspect_body' => 'Open a run from the table to enter the canonical monitoring detail viewer.',
]; ];

View File

@ -734,25 +734,25 @@ public function canonicalContextBanner(): ?array
'tone' => 'slate', 'tone' => 'slate',
'title' => 'Workspace-level operation', 'title' => 'Workspace-level operation',
'body' => $activeTenant instanceof ManagedEnvironment 'body' => $activeTenant instanceof ManagedEnvironment
? 'This canonical workspace view is not tied to the current tenant context ('.$activeTenant->name.').' ? 'This canonical workspace view is not tied to the current environment context ('.$activeTenant->name.').'
: 'This canonical workspace view is not tied to any tenant.', : 'This canonical workspace view is not tied to any environment.',
]; ];
} }
$messages = ['Operation tenant: '.$runTenant->name.'.']; $messages = ['Operation environment: '.$runTenant->name.'.'];
$tone = 'sky'; $tone = 'sky';
$title = null; $title = null;
if ($activeTenant instanceof ManagedEnvironment && ! $activeTenant->is($runTenant)) { if ($activeTenant instanceof ManagedEnvironment && ! $activeTenant->is($runTenant)) {
$title = 'Current tenant context differs from this operation'; $title = 'Current environment context differs from this operation';
array_unshift($messages, 'Current tenant context: '.$activeTenant->name.'.'); array_unshift($messages, 'Current environment context: '.$activeTenant->name.'.');
$messages[] = 'This canonical workspace view remains valid without switching tenant context.'; $messages[] = 'This canonical workspace view remains valid without switching environment context.';
} }
$referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant); $referencedTenant = ReferencedTenantLifecyclePresentation::forOperationRun($runTenant);
if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) { if ($selectorAvailabilityMessage = $referencedTenant->selectorAvailabilityMessage()) {
$title ??= 'Operation tenant is not available in the current tenant selector'; $title ??= 'Operation environment is not available in the current environment selector';
$tone = 'amber'; $tone = 'amber';
$messages[] = $selectorAvailabilityMessage; $messages[] = $selectorAvailabilityMessage;
@ -761,7 +761,7 @@ public function canonicalContextBanner(): ?array
} }
} elseif (! $activeTenant instanceof ManagedEnvironment) { } elseif (! $activeTenant instanceof ManagedEnvironment) {
$title ??= 'Canonical workspace view'; $title ??= 'Canonical workspace view';
$messages[] = 'No tenant context is currently selected.'; $messages[] = 'No environment context is currently selected.';
} }
if ($title === null) { if ($title === null) {

View File

@ -56,7 +56,7 @@ class TenantDashboard extends Dashboard
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
{ {
return __('localization.dashboard.tenant_title'); return __('localization.dashboard.environment_title');
} }
public function getTitle(): string | Htmlable public function getTitle(): string | Htmlable
@ -64,7 +64,7 @@ public function getTitle(): string | Htmlable
$tenant = Filament::getTenant(); $tenant = Filament::getTenant();
if (! $tenant instanceof ManagedEnvironment) { if (! $tenant instanceof ManagedEnvironment) {
return __('localization.dashboard.tenant_title'); return __('localization.dashboard.environment_title');
} }
$summary = $this->dashboardSummary(); $summary = $this->dashboardSummary();

View File

@ -25,12 +25,15 @@ class ManagedTenantsLanding extends Page
protected static string $layout = 'filament-panels::components.layout.simple'; protected static string $layout = 'filament-panels::components.layout.simple';
protected static ?string $title = 'Managed tenants';
protected string $view = 'filament.pages.workspaces.managed-tenants-landing'; protected string $view = 'filament.pages.workspaces.managed-tenants-landing';
public Workspace $workspace; public Workspace $workspace;
public function getTitle(): string
{
return __('localization.shell.managed_environments_title');
}
/** /**
* The Filament simple layout renders the topbar by default, which includes * The Filament simple layout renders the topbar by default, which includes
* lazy-loaded database notifications. On this workspace-scoped landing page, * lazy-loaded database notifications. On this workspace-scoped landing page,

View File

@ -28,7 +28,7 @@ protected function getViewData(): array
return [ return [
'context' => [ 'context' => [
'workspace' => __('localization.dashboard.overview.context_workspace'), 'workspace' => __('localization.dashboard.overview.context_workspace'),
'tenant' => __('localization.dashboard.overview.context_no_tenant'), 'tenant' => __('localization.dashboard.overview.context_no_environment'),
'provider' => null, 'provider' => null,
'providerKey' => null, 'providerKey' => null,
'latestActivity' => null, 'latestActivity' => null,

View File

@ -123,7 +123,7 @@ public function onboardingEntryDescriptor(int $resumableDraftCount): TenantActio
default => new TenantActionDescriptor( default => new TenantActionDescriptor(
key: 'add_tenant', key: 'add_tenant',
family: TenantActionFamily::OnboardingWorkflow, family: TenantActionFamily::OnboardingWorkflow,
label: 'Add tenant', label: 'Add environment',
icon: 'heroicon-m-plus', icon: 'heroicon-m-plus',
group: 'primary', group: 'primary',
), ),

View File

@ -105,7 +105,7 @@ public static function forTenant(?ManagedEnvironment $tenant): self
if (! $assignment instanceof BaselineTenantAssignment) { if (! $assignment instanceof BaselineTenantAssignment) {
return self::empty( return self::empty(
'no_assignment', 'no_assignment',
'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.', 'This environment has no baseline assignment. A workspace manager can assign a baseline profile to this environment.',
findingAttentionCounts: $findingAttentionCounts, findingAttentionCounts: $findingAttentionCounts,
); );
} }

View File

@ -242,7 +242,7 @@ private function headline(
}, },
BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Baseline compare is in progress.', BaselineCompareSummaryAssessment::STATE_IN_PROGRESS => 'Baseline compare is in progress.',
default => match ($stats->state) { default => match ($stats->state) {
'no_assignment' => 'This tenant does not have an assigned baseline yet.', 'no_assignment' => 'This environment does not have an assigned baseline yet.',
'no_snapshot' => 'The current baseline snapshot is not available for compare.', 'no_snapshot' => 'The current baseline snapshot is not available for compare.',
'idle' => 'A current baseline compare result is not available yet.', 'idle' => 'A current baseline compare result is not available yet.',
default => 'A usable baseline compare result is not currently available.', default => 'A usable baseline compare result is not currently available.',

View File

@ -104,7 +104,7 @@ public static function forTenantRegistry(string $backLinkUrl, ?int $tenantId = n
sourceSurface: 'tenant_registry', sourceSurface: 'tenant_registry',
canonicalRouteName: ListTenants::getRouteName(Filament::getPanel('admin')), canonicalRouteName: ListTenants::getRouteName(Filament::getPanel('admin')),
tenantId: $tenantId, tenantId: $tenantId,
backLinkLabel: 'Back to tenant registry', backLinkLabel: __('localization.shell.back_to_environment_registry'),
backLinkUrl: $backLinkUrl, backLinkUrl: $backLinkUrl,
); );
} }

View File

@ -32,10 +32,10 @@ public function scopeLabel(?Request $request = null): string
$activeTenant = $this->activeEntitledTenant($request); $activeTenant = $this->activeEntitledTenant($request);
if ($activeTenant instanceof ManagedEnvironment) { if ($activeTenant instanceof ManagedEnvironment) {
return 'ManagedEnvironment scope: '.$activeTenant->name; return __('localization.shell.environment_scope').': '.$activeTenant->name;
} }
return 'All tenants'; return __('localization.shell.all_environments');
} }
/** /**

View File

@ -62,7 +62,7 @@ public static function forLifecycle(TenantLifecycle $lifecycle): self
badgeIcon: 'heroicon-m-arrow-path', badgeIcon: 'heroicon-m-arrow-path',
badgeIconColor: null, badgeIconColor: null,
shortDescription: 'Onboarding is in progress.', shortDescription: 'Onboarding is in progress.',
longDescription: 'This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.', longDescription: 'This environment is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.',
isInvalidFallback: false, isInvalidFallback: false,
lifecycle: $lifecycle, lifecycle: $lifecycle,
), ),
@ -72,8 +72,8 @@ public static function forLifecycle(TenantLifecycle $lifecycle): self
badgeColor: 'success', badgeColor: 'success',
badgeIcon: 'heroicon-m-check-circle', badgeIcon: 'heroicon-m-check-circle',
badgeIconColor: null, badgeIconColor: null,
shortDescription: 'Active tenant available for normal operations.', shortDescription: 'Active environment available for normal operations.',
longDescription: 'This tenant is active and available across normal management, tenant selection, and operational follow-up flows.', longDescription: 'This environment is active and available across normal management, environment selection, and operational follow-up flows.',
isInvalidFallback: false, isInvalidFallback: false,
lifecycle: $lifecycle, lifecycle: $lifecycle,
), ),
@ -83,8 +83,8 @@ public static function forLifecycle(TenantLifecycle $lifecycle): self
badgeColor: 'gray', badgeColor: 'gray',
badgeIcon: 'heroicon-m-archive-box', badgeIcon: 'heroicon-m-archive-box',
badgeIconColor: null, badgeIconColor: null,
shortDescription: 'Archived tenant retained for inspection only.', shortDescription: 'Archived environment retained for inspection only.',
longDescription: 'This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.', longDescription: 'This environment remains available for inspection and audit history, but it is not selectable as active context until you restore it.',
isInvalidFallback: false, isInvalidFallback: false,
lifecycle: $lifecycle, lifecycle: $lifecycle,
), ),

View File

@ -135,7 +135,7 @@ public function build(Workspace $workspace, User $user): array
$attentionEmptyState = [ $attentionEmptyState = [
'title' => $calmness['title'], 'title' => $calmness['title'],
'body' => $calmness['body'], 'body' => $calmness['body'],
'action_label' => $calmness['next_action']['label'] ?? 'Choose tenant', 'action_label' => $calmness['next_action']['label'] ?? __('localization.shell.choose_environment'),
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'), 'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
]; ];
@ -993,7 +993,7 @@ private function summaryMetrics(
value: $accessibleTenantCount, value: $accessibleTenantCount,
category: 'scope', category: 'scope',
description: $accessibleTenantCount > 0 description: $accessibleTenantCount > 0
? 'ManagedEnvironment drill-down stays explicit from this workspace home.' ? 'Environment drill-down stays explicit from this workspace home.'
: 'No managed environments are available in this workspace yet.', : 'No managed environments are available in this workspace yet.',
color: $accessibleTenantCount > 0 ? 'primary' : 'warning', color: $accessibleTenantCount > 0 ? 'primary' : 'warning',
destination: $accessibleTenantCount > 0 destination: $accessibleTenantCount > 0
@ -1008,7 +1008,7 @@ private function summaryMetrics(
description: 'Affected visible tenants with overdue findings, governance expiry, lapsed governance, or compare posture that needs review.', description: 'Affected visible tenants with overdue findings, governance expiry, lapsed governance, or compare posture that needs review.',
color: $governanceAttentionTenantCount > 0 ? 'danger' : 'gray', color: $governanceAttentionTenantCount > 0 ? 'danger' : 'gray',
destination: $governanceAttentionTenantCount > 0 destination: $governanceAttentionTenantCount > 0
? $this->chooseTenantTarget('Choose tenant') ? $this->chooseTenantTarget(__('localization.shell.choose_environment'))
: null, : null,
), ),
$this->makeSummaryMetric( $this->makeSummaryMetric(
@ -1272,15 +1272,15 @@ private function attentionMetricDestination(array $tenantContexts, User $user, s
TenantBackupHealthAssessment::POSTURE_DEGRADED, TenantBackupHealthAssessment::POSTURE_DEGRADED,
], ],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, 'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
], 'Choose tenant'), ], __('localization.shell.choose_environment')),
'has_recovery_attention' => $this->filteredTenantRegistryTarget([ 'has_recovery_attention' => $this->filteredTenantRegistryTarget([
'recovery_evidence' => [ 'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED, TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED, TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
], ],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST, 'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
], 'Choose tenant'), ], __('localization.shell.choose_environment')),
default => $this->chooseTenantTarget('Choose tenant'), default => $this->chooseTenantTarget(__('localization.shell.choose_environment')),
}; };
} }
@ -1451,8 +1451,8 @@ private function quickActions(
$actions = [ $actions = [
[ [
'key' => 'choose_tenant', 'key' => 'choose_tenant',
'label' => 'Choose tenant', 'label' => __('localization.shell.choose_environment'),
'description' => 'Deliberately enter tenant context from this workspace.', 'description' => 'Deliberately enter environment context from this workspace.',
'url' => ChooseTenant::getUrl(panel: 'admin'), 'url' => ChooseTenant::getUrl(panel: 'admin'),
'icon' => 'heroicon-o-building-office-2', 'icon' => 'heroicon-o-building-office-2',
'color' => 'primary', 'color' => 'primary',
@ -1529,12 +1529,12 @@ private function tenantRouteKey(ManagedEnvironment $tenant): string
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function chooseTenantTarget(string $label = 'Choose tenant'): array private function chooseTenantTarget(?string $label = null): array
{ {
return $this->destination( return $this->destination(
kind: 'choose_tenant', kind: 'choose_tenant',
url: ChooseTenant::getUrl(panel: 'admin'), url: ChooseTenant::getUrl(panel: 'admin'),
label: $label, label: $label ?? __('localization.shell.choose_environment'),
); );
} }
@ -1542,12 +1542,12 @@ private function chooseTenantTarget(string $label = 'Choose tenant'): array
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function filteredTenantRegistryTarget(array $filters, string $label = 'Choose tenant'): array private function filteredTenantRegistryTarget(array $filters, ?string $label = null): array
{ {
return $this->destination( return $this->destination(
kind: 'choose_tenant', kind: 'choose_tenant',
url: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), $filters), url: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), $filters),
label: $label, label: $label ?? __('localization.shell.choose_environment'),
filters: $filters, filters: $filters,
); );
} }

View File

@ -69,7 +69,7 @@
'empty_no_assignment' => 'Keine Baseline zugewiesen', 'empty_no_assignment' => 'Keine Baseline zugewiesen',
'empty_no_snapshot' => 'Kein Snapshot verfügbar', 'empty_no_snapshot' => 'Kein Snapshot verfügbar',
'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.', 'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.',
'rbac_summary_title' => 'Intune-RBAC-Rollendefinitionen', 'rbac_summary_title' => 'RBAC-Rollendefinitionen',
'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.', 'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
'rbac_summary_compared' => 'Verglichen', 'rbac_summary_compared' => 'Verglichen',
'rbac_summary_unchanged' => 'Unverändert', 'rbac_summary_unchanged' => 'Unverändert',

View File

@ -28,6 +28,23 @@
'choose_workspace' => 'Workspace auswählen', 'choose_workspace' => 'Workspace auswählen',
'switch_workspace' => 'Workspace wechseln', 'switch_workspace' => 'Workspace wechseln',
'workspace_home' => 'Workspace-Start', 'workspace_home' => 'Workspace-Start',
'choose_environment' => 'Umgebung auswählen',
'environment_count' => ':count Umgebung|:count Umgebungen',
'choose_environment_description' => 'Wählen Sie die Umgebung für Ihren normalen aktiven Betriebskontext aus.',
'workspace_wide_available_without_environment' => 'Keine Umgebung ausgewählt. Workspace-weite Seiten wie Operationen und Managed Environments bleiben verfügbar.',
'add_environment' => 'Umgebung hinzufügen',
'all_environments' => 'Alle Umgebungen',
'show_all_environments' => 'Alle Umgebungen anzeigen',
'managed_environments_title' => 'Managed Environments',
'no_managed_environments_yet' => 'Noch keine Managed Environments',
'managed_environments_empty_state_description' => 'Fügen Sie Ihre erste Managed Environment hinzu, um Inventar, Backups, Drift-Erkennung und Richtlinien zu verwalten.',
'environment_scope' => 'Umgebungskontext',
'select_environment' => 'Umgebung auswählen',
'selected_environment' => 'Ausgewählte Umgebung',
'no_environment_selected' => 'Keine Umgebung ausgewählt',
'switch_environment' => 'Umgebung wechseln',
'clear_environment_scope' => 'Umgebungskontext löschen',
'back_to_environment_registry' => 'Zur Umgebungsregistrierung zurück',
'tenant_scope' => 'Tenant-Kontext', 'tenant_scope' => 'Tenant-Kontext',
'select_tenant' => 'Tenant auswählen', 'select_tenant' => 'Tenant auswählen',
'selected_tenant' => 'Ausgewählter Tenant', 'selected_tenant' => 'Ausgewählter Tenant',
@ -37,9 +54,13 @@
'context_unavailable' => 'Kontext nicht verfügbar', 'context_unavailable' => 'Kontext nicht verfügbar',
'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.', 'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.',
'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.', 'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.',
'no_active_environments' => 'Keine aktiven Umgebungen verfügbar',
'no_active_environments_description' => 'In diesem Workspace sind keine auswählbaren aktiven Umgebungen für den normalen Betriebskontext verfügbar. Workspace-weite Seiten funktionieren weiterhin ohne ausgewählte Umgebung, und Sie können Onboarding- oder archivierte Einträge über Managed Environments prüfen.',
'view_managed_environments' => 'Managed Environments anzeigen',
'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.', 'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.',
'view_managed_tenants' => 'Managed Tenants anzeigen', 'view_managed_tenants' => 'Managed Tenants anzeigen',
'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.', 'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.',
'search_environments' => 'Umgebungen suchen...',
'search_tenants' => 'Tenants suchen...', 'search_tenants' => 'Tenants suchen...',
'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.', 'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.',
], ],
@ -76,6 +97,7 @@
], ],
'dashboard' => [ 'dashboard' => [
'tenant_title' => 'Tenant-Dashboard', 'tenant_title' => 'Tenant-Dashboard',
'environment_title' => 'Umgebungs-Dashboard',
'system_title' => 'System-Dashboard', 'system_title' => 'System-Dashboard',
'more_actions' => 'Mehr', 'more_actions' => 'Mehr',
'request_support' => 'Support anfragen', 'request_support' => 'Support anfragen',
@ -126,11 +148,12 @@
'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert', 'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert',
'recovery_mode_ended' => 'Wiederherstellungsmodus beendet', 'recovery_mode_ended' => 'Wiederherstellungsmodus beendet',
'overview' => [ 'overview' => [
'page_subheading' => 'Tenant-Governance-Übersicht', 'page_subheading' => 'Umgebungs-Governance-Übersicht',
'context_workspace' => 'Aktueller Workspace', 'context_workspace' => 'Aktueller Workspace',
'context_no_tenant' => 'Kein Tenant ausgewählt', 'context_no_tenant' => 'Kein Tenant ausgewählt',
'context_no_environment' => 'Keine Umgebung ausgewählt',
'context_workspace_chip' => 'Workspace: :workspace', 'context_workspace_chip' => 'Workspace: :workspace',
'context_provider_chip' => ':provider-Tenant', 'context_provider_chip' => ':provider-Umgebung',
'context_latest_activity_chip' => 'Letzte Aktivität: :time', 'context_latest_activity_chip' => 'Letzte Aktivität: :time',
'status_unavailable' => 'Nicht verfügbar', 'status_unavailable' => 'Nicht verfügbar',
'status_blocked' => 'Blockiert', 'status_blocked' => 'Blockiert',
@ -141,6 +164,8 @@
'status_needs_action' => 'Aufmerksamkeit erforderlich', 'status_needs_action' => 'Aufmerksamkeit erforderlich',
'tenant_context_unavailable_headline' => 'Tenant-Kontext ist nicht verfügbar.', 'tenant_context_unavailable_headline' => 'Tenant-Kontext ist nicht verfügbar.',
'tenant_context_unavailable_summary' => 'Wählen Sie einen Tenant aus, um die entscheidungsorientierte Dashboard-Übersicht anzuzeigen.', 'tenant_context_unavailable_summary' => 'Wählen Sie einen Tenant aus, um die entscheidungsorientierte Dashboard-Übersicht anzuzeigen.',
'environment_context_unavailable_headline' => 'Umgebungskontext ist nicht verfügbar.',
'environment_context_unavailable_summary' => 'Wählen Sie eine Umgebung aus, um die entscheidungsorientierte Dashboard-Übersicht anzuzeigen.',
'posture_blocked_headline' => 'Provider-Berechtigungen blockieren Tenant-Workflows.', 'posture_blocked_headline' => 'Provider-Berechtigungen blockieren Tenant-Workflows.',
'posture_blocked_summary' => 'Erforderliche Anwendungsberechtigungen fehlen. Provider-gestützte Abläufe können deshalb nicht als bereit bewertet werden.', 'posture_blocked_summary' => 'Erforderliche Anwendungsberechtigungen fehlen. Provider-gestützte Abläufe können deshalb nicht als bereit bewertet werden.',
'posture_calm_headline' => 'Kein unmittelbarer Tenant-Blocker ist sichtbar.', 'posture_calm_headline' => 'Kein unmittelbarer Tenant-Blocker ist sichtbar.',
@ -539,11 +564,11 @@
'sync_action_primary' => 'Richtlinien synchronisieren', 'sync_action_primary' => 'Richtlinien synchronisieren',
'sync_action_secondary' => 'Synchronisieren', 'sync_action_secondary' => 'Synchronisieren',
'sync_modal_heading' => 'Richtlinien-Inventar synchronisieren', 'sync_modal_heading' => 'Richtlinien-Inventar synchronisieren',
'sync_modal_description' => 'Diese Aktion reiht eine Hintergrundssynchronisierung für unterstützte Richtlinientypen im aktuellen Tenant ein.', 'sync_modal_description' => 'Diese Aktion reiht eine Hintergrundssynchronisierung für unterstützte Richtlinientypen in der aktuellen Umgebung ein.',
'sync_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu synchronisieren.', 'sync_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu synchronisieren.',
'capture_snapshot_action' => 'Snapshot erfassen', 'capture_snapshot_action' => 'Snapshot erfassen',
'capture_snapshot_modal_heading' => 'Snapshot jetzt erfassen', 'capture_snapshot_modal_heading' => 'Snapshot jetzt erfassen',
'capture_snapshot_modal_subheading' => 'Diese Aktion reiht einen Hintergrundjob ein, der die aktuelle Konfiguration aus Microsoft Graph abruft und eine neue Richtlinienversion speichert.', 'capture_snapshot_modal_subheading' => 'Diese Aktion reiht einen Hintergrundjob ein, der die aktuelle Konfiguration für die aktuelle Umgebung erfasst und eine neue Richtlinienversion speichert.',
'capture_snapshot_include_assignments' => 'Zuweisungen einschließen', 'capture_snapshot_include_assignments' => 'Zuweisungen einschließen',
'capture_snapshot_include_assignments_helper' => 'Erfasst Include-/Exclude-Ziele und Filter für Zuweisungen.', 'capture_snapshot_include_assignments_helper' => 'Erfasst Include-/Exclude-Ziele und Filter für Zuweisungen.',
'capture_snapshot_include_scope_tags' => 'Scope-Tags einschließen', 'capture_snapshot_include_scope_tags' => 'Scope-Tags einschließen',
@ -593,7 +618,7 @@
'ignore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu ignorieren.', 'ignore_permission_tooltip' => 'Sie haben keine Berechtigung, Richtlinien zu ignorieren.',
'policy_ignored' => 'Richtlinie ignoriert', 'policy_ignored' => 'Richtlinie ignoriert',
'empty_state_heading' => 'Noch keine Richtlinien im Inventory', 'empty_state_heading' => 'Noch keine Richtlinien im Inventory',
'empty_state_description' => 'Starte eine Synchronisierung, um das Richtlinien-Inventar dieses Tenants mit Versionen, Wiederherstellbarkeit und Governance-Evidence aufzubauen.', 'empty_state_description' => 'Starte eine Synchronisierung, um das Richtlinien-Inventar dieser Umgebung mit Versionen, Wiederherstellbarkeit und Governance-Evidence aufzubauen.',
'delete_queued_body' => 'Löschung für :count Richtlinien eingeplant.', 'delete_queued_body' => 'Löschung für :count Richtlinien eingeplant.',
], ],
'versions' => [ 'versions' => [
@ -640,7 +665,7 @@
'metadata_only_tooltip' => 'Für reine Metadaten-Snapshots deaktiviert (Graph hat keine Richtlinieneinstellungen geliefert).', 'metadata_only_tooltip' => 'Für reine Metadaten-Snapshots deaktiviert (Graph hat keine Richtlinieneinstellungen geliefert).',
'restore_disabled_metadata_title' => 'Wiederherstellung für reinen Metadaten-Snapshot deaktiviert', 'restore_disabled_metadata_title' => 'Wiederherstellung für reinen Metadaten-Snapshot deaktiviert',
'restore_disabled_metadata_body' => 'Dieser Snapshot enthält nur Metadaten; Graph hat keine Richtlinieneinstellungen für eine Wiederherstellung geliefert.', 'restore_disabled_metadata_body' => 'Dieser Snapshot enthält nur Metadaten; Graph hat keine Richtlinieneinstellungen für eine Wiederherstellung geliefert.',
'different_tenant_title' => 'Richtlinienversion gehört zu einem anderen Tenant', 'different_tenant_title' => 'Richtlinienversion gehört zu einer anderen Umgebung',
'missing_policy_title' => 'Richtlinie für diese Version konnte nicht gefunden werden', 'missing_policy_title' => 'Richtlinie für diese Version konnte nicht gefunden werden',
'backup_set_name' => 'Richtlinienversions-Wiederherstellung - :policy - v:version', 'backup_set_name' => 'Richtlinienversions-Wiederherstellung - :policy - v:version',
'archive' => 'Archivieren', 'archive' => 'Archivieren',
@ -666,9 +691,9 @@
'fallback_display_name' => 'Version :version', 'fallback_display_name' => 'Version :version',
], ],
'relation' => [ 'relation' => [
'restore_to_microsoft_intune' => 'In Microsoft Intune wiederherstellen', 'restore_to_microsoft_intune' => 'In die Umgebung wiederherstellen',
'restore_heading' => 'Version :version in Microsoft Intune wiederherstellen?', 'restore_heading' => 'Version :version in die Umgebung wiederherstellen?',
'restore_subheading' => 'Erstellt einen Wiederherstellungslauf mit diesem Richtlinienversions-Snapshot.', 'restore_subheading' => 'Erstellt einen Wiederherstellungslauf für die aktuelle Umgebung mit diesem Richtlinienversions-Snapshot.',
'missing_context_title' => 'Tenant- oder Benutzerkontext fehlt.', 'missing_context_title' => 'Tenant- oder Benutzerkontext fehlt.',
'restore_run_failed_title' => 'Wiederherstellungslauf konnte nicht gestartet werden', 'restore_run_failed_title' => 'Wiederherstellungslauf konnte nicht gestartet werden',
'restore_run_started_title' => 'Wiederherstellungslauf gestartet', 'restore_run_started_title' => 'Wiederherstellungslauf gestartet',

View File

@ -95,7 +95,7 @@
// Findings section // Findings section
'findings_description' => 'The tenant configuration drifted from the baseline profile.', 'findings_description' => 'The tenant configuration drifted from the baseline profile.',
'rbac_summary_title' => 'Intune RBAC Role Definitions', 'rbac_summary_title' => 'RBAC role definitions',
'rbac_summary_description' => 'Role Assignments are not included in this baseline compare release.', 'rbac_summary_description' => 'Role Assignments are not included in this baseline compare release.',
'rbac_summary_compared' => 'Compared', 'rbac_summary_compared' => 'Compared',
'rbac_summary_unchanged' => 'Unchanged', 'rbac_summary_unchanged' => 'Unchanged',

View File

@ -28,6 +28,23 @@
'choose_workspace' => 'Choose workspace', 'choose_workspace' => 'Choose workspace',
'switch_workspace' => 'Switch workspace', 'switch_workspace' => 'Switch workspace',
'workspace_home' => 'Workspace Home', 'workspace_home' => 'Workspace Home',
'choose_environment' => 'Choose environment',
'environment_count' => ':count environment|:count environments',
'choose_environment_description' => 'Select the environment for your normal active operating context.',
'workspace_wide_available_without_environment' => 'No environment selected is still a valid workspace state on workspace-wide pages such as operations and managed environments.',
'add_environment' => 'Add environment',
'all_environments' => 'All environments',
'show_all_environments' => 'Show all environments',
'managed_environments_title' => 'Managed environments',
'no_managed_environments_yet' => 'No managed environments yet',
'managed_environments_empty_state_description' => 'Add your first managed environment to start managing inventory, backups, drift detection, and policies.',
'environment_scope' => 'Environment scope',
'select_environment' => 'Select environment',
'selected_environment' => 'Selected environment',
'no_environment_selected' => 'No environment selected',
'switch_environment' => 'Switch environment',
'clear_environment_scope' => 'Clear environment scope',
'back_to_environment_registry' => 'Back to environment registry',
'tenant_scope' => 'Tenant scope', 'tenant_scope' => 'Tenant scope',
'select_tenant' => 'Select tenant', 'select_tenant' => 'Select tenant',
'selected_tenant' => 'Selected tenant', 'selected_tenant' => 'Selected tenant',
@ -37,9 +54,13 @@
'context_unavailable' => 'Context unavailable', 'context_unavailable' => 'Context unavailable',
'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.', 'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.',
'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.', 'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.',
'no_active_environments' => 'No active environments available',
'no_active_environments_description' => 'There are no selectable active environments for the normal operating context in this workspace. Workspace-level pages still work with no environment selected, and you can inspect onboarding or archived records through managed environments.',
'view_managed_environments' => 'View managed environments',
'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.', 'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.',
'view_managed_tenants' => 'View managed tenants', 'view_managed_tenants' => 'View managed tenants',
'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.', 'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.',
'search_environments' => 'Search environments...',
'search_tenants' => 'Search tenants...', 'search_tenants' => 'Search tenants...',
'choose_workspace_first' => 'Choose a workspace first.', 'choose_workspace_first' => 'Choose a workspace first.',
], ],
@ -76,6 +97,7 @@
], ],
'dashboard' => [ 'dashboard' => [
'tenant_title' => 'Tenant dashboard', 'tenant_title' => 'Tenant dashboard',
'environment_title' => 'Environment dashboard',
'system_title' => 'System dashboard', 'system_title' => 'System dashboard',
'more_actions' => 'More', 'more_actions' => 'More',
'request_support' => 'Request support', 'request_support' => 'Request support',
@ -126,11 +148,12 @@
'recovery_mode_enabled' => 'Recovery mode enabled', 'recovery_mode_enabled' => 'Recovery mode enabled',
'recovery_mode_ended' => 'Recovery mode ended', 'recovery_mode_ended' => 'Recovery mode ended',
'overview' => [ 'overview' => [
'page_subheading' => 'Tenant governance overview', 'page_subheading' => 'Environment governance overview',
'context_workspace' => 'Current workspace', 'context_workspace' => 'Current workspace',
'context_no_tenant' => 'No tenant selected', 'context_no_tenant' => 'No tenant selected',
'context_no_environment' => 'No environment selected',
'context_workspace_chip' => 'Workspace: :workspace', 'context_workspace_chip' => 'Workspace: :workspace',
'context_provider_chip' => ':provider tenant', 'context_provider_chip' => ':provider environment',
'context_latest_activity_chip' => 'Latest activity: :time', 'context_latest_activity_chip' => 'Latest activity: :time',
'status_unavailable' => 'Unavailable', 'status_unavailable' => 'Unavailable',
'status_blocked' => 'Blocked', 'status_blocked' => 'Blocked',
@ -141,6 +164,8 @@
'status_needs_action' => 'Needs attention', 'status_needs_action' => 'Needs attention',
'tenant_context_unavailable_headline' => 'Tenant context is not available.', 'tenant_context_unavailable_headline' => 'Tenant context is not available.',
'tenant_context_unavailable_summary' => 'Select a tenant to view the decision-first dashboard overview.', 'tenant_context_unavailable_summary' => 'Select a tenant to view the decision-first dashboard overview.',
'environment_context_unavailable_headline' => 'Environment context is not available.',
'environment_context_unavailable_summary' => 'Select an environment to view the decision-first dashboard overview.',
'posture_blocked_headline' => 'Provider permissions are blocking tenant workflows.', 'posture_blocked_headline' => 'Provider permissions are blocking tenant workflows.',
'posture_blocked_summary' => 'Required application permissions are missing, so provider-backed operations cannot be treated as healthy readiness.', 'posture_blocked_summary' => 'Required application permissions are missing, so provider-backed operations cannot be treated as healthy readiness.',
'posture_calm_headline' => 'No immediate tenant blocker is visible.', 'posture_calm_headline' => 'No immediate tenant blocker is visible.',
@ -539,11 +564,11 @@
'sync_action_primary' => 'Sync policies', 'sync_action_primary' => 'Sync policies',
'sync_action_secondary' => 'Sync', 'sync_action_secondary' => 'Sync',
'sync_modal_heading' => 'Sync policy inventory', 'sync_modal_heading' => 'Sync policy inventory',
'sync_modal_description' => 'This queues a background sync operation for supported policy types in the current tenant.', 'sync_modal_description' => 'This queues a background sync operation for supported policy types in the current environment.',
'sync_permission_tooltip' => 'You do not have permission to sync policies.', 'sync_permission_tooltip' => 'You do not have permission to sync policies.',
'capture_snapshot_action' => 'Capture snapshot', 'capture_snapshot_action' => 'Capture snapshot',
'capture_snapshot_modal_heading' => 'Capture snapshot now', 'capture_snapshot_modal_heading' => 'Capture snapshot now',
'capture_snapshot_modal_subheading' => 'This queues a background job that fetches the latest configuration from Microsoft Graph and stores a new policy version.', 'capture_snapshot_modal_subheading' => 'This queues a background job that captures the latest configuration for the current environment and stores a new policy version.',
'capture_snapshot_include_assignments' => 'Include assignments', 'capture_snapshot_include_assignments' => 'Include assignments',
'capture_snapshot_include_assignments_helper' => 'Captures assignment include/exclude targeting and filters.', 'capture_snapshot_include_assignments_helper' => 'Captures assignment include/exclude targeting and filters.',
'capture_snapshot_include_scope_tags' => 'Include scope tags', 'capture_snapshot_include_scope_tags' => 'Include scope tags',
@ -593,7 +618,7 @@
'ignore_permission_tooltip' => 'You do not have permission to ignore policies.', 'ignore_permission_tooltip' => 'You do not have permission to ignore policies.',
'policy_ignored' => 'Policy ignored', 'policy_ignored' => 'Policy ignored',
'empty_state_heading' => 'No policies in inventory yet', 'empty_state_heading' => 'No policies in inventory yet',
'empty_state_description' => 'Run a sync to build this tenant\'s policy inventory, including versions, restore readiness, and governance evidence.', 'empty_state_description' => 'Run a sync to build this environment\'s policy inventory, including versions, restore readiness, and governance evidence.',
'delete_queued_body' => 'Queued deletion for :count policies.', 'delete_queued_body' => 'Queued deletion for :count policies.',
], ],
'versions' => [ 'versions' => [
@ -640,7 +665,7 @@
'metadata_only_tooltip' => 'Disabled for metadata-only snapshots (Graph did not provide policy settings).', 'metadata_only_tooltip' => 'Disabled for metadata-only snapshots (Graph did not provide policy settings).',
'restore_disabled_metadata_title' => 'Restore disabled for metadata-only snapshot', 'restore_disabled_metadata_title' => 'Restore disabled for metadata-only snapshot',
'restore_disabled_metadata_body' => 'This snapshot only contains metadata; Graph did not provide policy settings to restore.', 'restore_disabled_metadata_body' => 'This snapshot only contains metadata; Graph did not provide policy settings to restore.',
'different_tenant_title' => 'Policy version belongs to a different tenant', 'different_tenant_title' => 'Policy version belongs to a different environment',
'missing_policy_title' => 'Policy could not be found for this version', 'missing_policy_title' => 'Policy could not be found for this version',
'backup_set_name' => 'Policy version restore - :policy - v:version', 'backup_set_name' => 'Policy version restore - :policy - v:version',
'archive' => 'Archive', 'archive' => 'Archive',
@ -666,9 +691,9 @@
'fallback_display_name' => 'Version :version', 'fallback_display_name' => 'Version :version',
], ],
'relation' => [ 'relation' => [
'restore_to_microsoft_intune' => 'Restore to Microsoft Intune', 'restore_to_microsoft_intune' => 'Restore to environment',
'restore_heading' => 'Restore version :version to Microsoft Intune?', 'restore_heading' => 'Restore version :version to environment?',
'restore_subheading' => 'Creates a restore run using this policy version snapshot.', 'restore_subheading' => 'Creates a restore run for the current environment using this policy version snapshot.',
'missing_context_title' => 'Missing tenant or user context.', 'missing_context_title' => 'Missing tenant or user context.',
'restore_run_failed_title' => 'Restore run failed to start', 'restore_run_failed_title' => 'Restore run failed to start',
'restore_run_started_title' => 'Restore run started', 'restore_run_started_title' => 'Restore run started',

View File

@ -33,7 +33,7 @@
<x-filament::section> <x-filament::section>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-gray-600 dark:text-gray-300"> <div class="text-sm text-gray-600 dark:text-gray-300">
Launch the compare matrix with the currently known baseline profile and any carried subject focus from this tenant landing. Launch the compare matrix with the currently known baseline profile and any carried subject focus from this environment landing.
</div> </div>
<x-filament::button tag="a" :href="$openCompareMatrixUrl" color="gray" icon="heroicon-o-squares-2x2"> <x-filament::button tag="a" :href="$openCompareMatrixUrl" color="gray" icon="heroicon-o-squares-2x2">

View File

@ -2,6 +2,7 @@
@php @php
$tenants = $this->getTenants(); $tenants = $this->getTenants();
$workspace = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspace(); $workspace = app(\App\Support\Workspaces\WorkspaceContext::class)->currentWorkspace();
$environmentCount = $tenants->count();
@endphp @endphp
@if ($tenants->isEmpty()) @if ($tenants->isEmpty())
@ -22,9 +23,9 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400"
/> />
</div> </div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No active tenants available</h3> <h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ __('localization.shell.no_active_environments') }}</h3>
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400"> <p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
There are no selectable active tenants for the normal operating context in this workspace. Workspace-level pages still work with no tenant selected, and you can inspect onboarding or archived records through managed tenants. {{ __('localization.shell.no_active_environments_description') }}
</p> </p>
<div class="mt-6 flex flex-col items-center gap-3"> <div class="mt-6 flex flex-col items-center gap-3">
@ -34,7 +35,7 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400"
icon="heroicon-m-arrow-right" icon="heroicon-m-arrow-right"
size="lg" size="lg"
> >
View managed tenants {{ __('localization.shell.view_managed_environments') }}
</x-filament::button> </x-filament::button>
<a href="{{ route('filament.admin.pages.choose-workspace') }}" <a href="{{ route('filament.admin.pages.choose-workspace') }}"
@ -58,13 +59,13 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
</div> </div>
@endif @endif
<span class="text-sm text-gray-500 dark:text-gray-400"> <span class="text-sm text-gray-500 dark:text-gray-400">
&middot; {{ $tenants->count() }} {{ \Illuminate\Support\Str::plural('tenant', $tenants->count()) }} &middot; {{ trans_choice('localization.shell.environment_count', $environmentCount, ['count' => $environmentCount]) }}
</span> </span>
</div> </div>
</div> </div>
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">Select the tenant for your normal active operating context.</p> <p class="mb-2 text-sm text-gray-500 dark:text-gray-400">{{ __('localization.shell.choose_environment_description') }}</p>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">No tenant selected is still a valid workspace state on workspace-wide pages such as operations and managed tenants.</p> <p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ __('localization.shell.workspace_wide_available_without_environment') }}</p>
{{-- ManagedEnvironment cards --}} {{-- ManagedEnvironment cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
@ -138,7 +139,7 @@ class="h-4 w-4 text-gray-400 dark:text-gray-500"
<a href="{{ route('admin.onboarding') }}" <a href="{{ route('admin.onboarding') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" /> <x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" />
Add tenant {{ __('localization.shell.add_environment') }}
</a> </a>
<a href="{{ route('filament.admin.pages.choose-workspace') }}" <a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">

View File

@ -1,6 +1,7 @@
<x-filament-panels::page> <x-filament-panels::page>
@php @php
$tenants = $this->getTenants(); $tenants = $this->getTenants();
$environmentCount = $tenants->count();
@endphp @endphp
@if ($tenants->isEmpty()) @if ($tenants->isEmpty())
@ -21,9 +22,9 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400"
/> />
</div> </div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">No managed tenants yet</h3> <h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ __('localization.shell.no_managed_environments_yet') }}</h3>
<p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400"> <p class="mx-auto mt-2 max-w-xs text-sm text-gray-500 dark:text-gray-400">
Connect your first Microsoft Entra tenant to start managing inventory, backups, drift detection, and policies. {{ __('localization.shell.managed_environments_empty_state_description') }}
</p> </p>
<div class="mt-6 flex flex-col items-center gap-3"> <div class="mt-6 flex flex-col items-center gap-3">
@ -33,7 +34,7 @@ class="h-7 w-7 text-primary-500 dark:text-primary-400"
icon="heroicon-m-plus" icon="heroicon-m-plus"
size="lg" size="lg"
> >
Add tenant {{ __('localization.shell.add_environment') }}
</x-filament::button> </x-filament::button>
<a href="{{ route('filament.admin.pages.choose-workspace') }}" <a href="{{ route('filament.admin.pages.choose-workspace') }}"
@ -55,7 +56,7 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
{{ $this->workspace->name }} {{ $this->workspace->name }}
</div> </div>
<span class="text-sm text-gray-500 dark:text-gray-400"> <span class="text-sm text-gray-500 dark:text-gray-400">
&middot; {{ $tenants->count() }} {{ \Illuminate\Support\Str::plural('tenant', $tenants->count()) }} &middot; {{ trans_choice('localization.shell.environment_count', $environmentCount, ['count' => $environmentCount]) }}
</span> </span>
</div> </div>
@ -66,7 +67,7 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
icon="heroicon-m-arrow-right" icon="heroicon-m-arrow-right"
icon-position="after" icon-position="after"
> >
Choose tenant {{ __('localization.shell.choose_environment') }}
</x-filament::button> </x-filament::button>
</div> </div>
@ -129,7 +130,7 @@ class="h-4 w-4 text-gray-400 dark:text-gray-500"
<a href="{{ route('admin.onboarding') }}" <a href="{{ route('admin.onboarding') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" /> <x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" />
Add tenant {{ __('localization.shell.add_environment') }}
</a> </a>
<a href="{{ route('filament.admin.pages.choose-workspace') }}" <a href="{{ route('filament.admin.pages.choose-workspace') }}"
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">

View File

@ -31,7 +31,7 @@
@endphp @endphp
@php @php
$tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected'); $tenantLabel = $currentTenantName ?? __('localization.shell.no_environment_selected');
$workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace'); $workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace');
$hasActiveTenant = $currentTenantName !== null; $hasActiveTenant = $currentTenantName !== null;
$managedTenantsUrl = $workspace $managedTenantsUrl = $workspace
@ -64,7 +64,7 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t
<x-slot name="trigger"> <x-slot name="trigger">
<button <button
type="button" type="button"
aria-label="{{ $workspace ? __('localization.shell.tenant_scope') : __('localization.shell.select_tenant') }}" aria-label="{{ $workspace ? __('localization.shell.environment_scope') : __('localization.shell.select_environment') }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10" class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
> >
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}"> <span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
@ -125,7 +125,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.selected_tenant') }} {{ __('localization.shell.selected_environment') }}
</div> </div>
</div> </div>
@ -138,7 +138,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
href="{{ ChooseTenant::getUrl(panel: 'admin') }}" href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
> >
{{ __('localization.shell.switch_tenant') }} {{ __('localization.shell.switch_environment') }}
</a> </a>
</div> </div>
@ -147,7 +147,7 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_tenant_scope') }} {{ __('localization.shell.clear_environment_scope') }}
</button> </button>
</form> </form>
@endif @endif
@ -155,23 +155,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@else @else
@if ($tenants->isEmpty()) @if ($tenants->isEmpty())
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
<div>{{ __('localization.shell.no_active_tenants') }}</div> <div>{{ __('localization.shell.no_active_environments') }}</div>
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"> <a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" /> <x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
{{ __('localization.shell.view_managed_tenants') }} {{ __('localization.shell.view_managed_environments') }}
</a> </a>
</div> </div>
@else @else
@if (! $hasActiveTenant) @if (! $hasActiveTenant)
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400"> <div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
{{ __('localization.shell.workspace_wide_available') }} {{ __('localization.shell.workspace_wide_available_without_environment') }}
</div> </div>
@endif @endif
<input <input
type="text" type="text"
class="fi-input fi-text-input w-full" class="fi-input fi-text-input w-full"
placeholder="{{ __('localization.shell.search_tenants') }}" placeholder="{{ __('localization.shell.search_environments') }}"
x-model="query" x-model="query"
/> />
@ -208,7 +208,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_tenant_scope') }} {{ __('localization.shell.clear_environment_scope') }}
</button> </button>
</form> </form>
@endif @endif

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\PolicyResource;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\ProviderConnection;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
it('smokes environment-first chooser and policy helper copy', function (): void {
$workspace = Workspace::factory()->create([
'name' => 'Spec 286 Workspace',
]);
$environment = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec 286 Production',
'slug' => 'spec-286-production',
]);
$secondaryEnvironment = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Spec 286 Secondary',
'slug' => 'spec-286-secondary',
]);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
foreach ([$environment, $secondaryEnvironment] as $memberEnvironment) {
$user->tenants()->syncWithoutDetaching([
(int) $memberEnvironment->getKey() => ['role' => 'owner'],
]);
}
ProviderConnection::factory()->platform()->consentGranted()->create([
'managed_environment_id' => (int) $environment->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'is_default' => true,
]);
$policy = Policy::factory()->create([
'managed_environment_id' => (int) $environment->getKey(),
'display_name' => 'Spec 286 Policy',
]);
PolicyVersion::factory()->create([
'managed_environment_id' => (int) $environment->getKey(),
'policy_id' => (int) $policy->getKey(),
'metadata' => [],
]);
$this->actingAs($user);
$landing = visit(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
->waitForText('Managed environments')
->assertSee('Choose environment')
->assertSee('Spec 286 Production')
->assertSee('Spec 286 Secondary')
->assertDontSee('Managed tenants')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
$landing
->click('[wire\:key="tenant-'.$environment->getKey().'"]')
->waitForText('Spec 286 Production')
->waitForText('Environment governance overview')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
visit(PolicyResource::getUrl('view', ['record' => $policy], tenant: $environment))
->waitForText('Capture snapshot')
->assertSee('Restore to environment')
->assertDontSee('Restore to Microsoft Intune')
->click('Capture snapshot')
->waitForText('Capture snapshot now')
->assertSee('current environment')
->assertSee('Source: Microsoft Intune')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -23,7 +23,8 @@
$stats = BaselineCompareStats::forTenant($tenant); $stats = BaselineCompareStats::forTenant($tenant);
expect($stats->state)->toBe('no_assignment') expect($stats->state)->toBe('no_assignment')
->and($stats->profileName)->toBeNull(); ->and($stats->profileName)->toBeNull()
->and($stats->message)->toBe('This environment has no baseline assignment. A workspace manager can assign a baseline profile to this environment.');
}); });
it('returns no_snapshot state when profile has no consumable snapshot', function (): void { it('returns no_snapshot state when profile has no consumable snapshot', function (): void {

View File

@ -26,7 +26,7 @@
$this $this
->actingAs($user) ->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $workspace]))
->assertOk() ->assertOk()
->assertSee('Workspace overview') ->assertSee('Workspace overview')
->assertSee('No accessible tenants in this workspace'); ->assertSee('No accessible tenants in this workspace');
@ -62,10 +62,10 @@
$this $this
->actingAs($user) ->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $workspace]))
->assertOk() ->assertOk()
->assertSee('Workspace overview') ->assertSee('Workspace overview')
->assertSee('Choose tenant'); ->assertSee(__('localization.shell.choose_environment'));
}); });
it('renders the workspace overview when a workspace is selected and has exactly one tenant', function (): void { it('renders the workspace overview when a workspace is selected and has exactly one tenant', function (): void {
@ -96,8 +96,8 @@
$this $this
->actingAs($user) ->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $workspace]))
->assertOk() ->assertOk()
->assertSee('Workspace overview') ->assertSee('Workspace overview')
->assertSee('Choose tenant'); ->assertSee(__('localization.shell.choose_environment'));
}); });

View File

@ -22,6 +22,6 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('ManagedEnvironment archived') ->assertSee('ManagedEnvironment archived')
->assertSee(UiTooltips::TENANT_ARCHIVED) ->assertSee(UiTooltips::TENANT_ARCHIVED)
->assertSee('This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.') ->assertSee('This environment remains available for inspection and audit history, but it is not selectable as active context until you restore it.')
->assertDontSee('deactivated'); ->assertDontSee('deactivated');
}); });

View File

@ -105,11 +105,12 @@
]); ]);
Livewire::test(BaselineCompareLanding::class) Livewire::test(BaselineCompareLanding::class)
->assertSee('Intune RBAC Role Definitions') ->assertSee('RBAC role definitions')
->assertSee('Compared') ->assertSee('Compared')
->assertSee('Modified') ->assertSee('Modified')
->assertSee('Missing') ->assertSee('Missing')
->assertSee('Unexpected') ->assertSee('Unexpected')
->assertSee('Role Assignments are not included') ->assertSee('Role Assignments are not included')
->assertDontSee('Intune RBAC Role Definitions')
->assertDontSee('RBAC restore'); ->assertDontSee('RBAC restore');
}); });

View File

@ -33,8 +33,8 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant') ->get('/admin/choose-tenant')
->assertSuccessful() ->assertSuccessful()
->assertSee('No active tenants available') ->assertSee(__('localization.shell.no_active_environments'))
->assertSee('View managed tenants') ->assertSee(__('localization.shell.view_managed_environments'))
->assertDontSee('Register tenant') ->assertDontSee('Register tenant')
->assertDontSee('Add tenant'); ->assertDontSee('Add tenant');
}); });
@ -53,7 +53,7 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin/choose-tenant') ->get('/admin/choose-tenant')
->assertSuccessful() ->assertSuccessful()
->assertSee('No active tenants available') ->assertSee(__('localization.shell.no_active_environments'))
->assertSee('Switch workspace') ->assertSee('Switch workspace')
->assertDontSee('Register tenant'); ->assertDontSee('Register tenant');
}); });

View File

@ -308,7 +308,7 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
$emptyStateCreate = getPlacementEmptyStateAction($component, 'add_tenant'); $emptyStateCreate = getPlacementEmptyStateAction($component, 'add_tenant');
expect($emptyStateCreate)->not->toBeNull(); expect($emptyStateCreate)->not->toBeNull();
expect($emptyStateCreate?->getLabel())->toBe('Add tenant'); expect($emptyStateCreate?->getLabel())->toBe('Add environment');
$headerCreate = getHeaderAction($component, 'add_tenant'); $headerCreate = getHeaderAction($component, 'add_tenant');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
@ -326,7 +326,7 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
$headerCreate = getHeaderAction($component, 'add_tenant'); $headerCreate = getHeaderAction($component, 'add_tenant');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
expect($headerCreate?->isVisible())->toBeTrue(); expect($headerCreate?->isVisible())->toBeTrue();
expect($headerCreate?->getLabel())->toBe('Add tenant'); expect($headerCreate?->getLabel())->toBe('Add environment');
}); });
it('labels the empty-state tenant action as resume onboarding when one draft exists', function (): void { it('labels the empty-state tenant action as resume onboarding when one draft exists', function (): void {

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('renders environment-first context actions on the dashboard', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner');
Filament::setTenant($environment, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get(TenantDashboard::getUrl(tenant: $environment))
->assertOk()
->assertSee('Switch environment')
->assertSee('Clear environment scope')
->assertDontSee('Switch tenant')
->assertDontSee('Clear tenant scope');
});
it('renders all-environments shell wording on tenantless monitoring pages', function (): void {
[$user, $environment] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get(route('admin.operations.index', ['workspace' => $environment->workspace]))
->assertOk()
->assertSee('All environments')
->assertDontSee('All tenants');
});

View File

@ -44,10 +44,16 @@ function getPolicyInventoryEmptyStateAction(Testable $component, string $name):
->assertSee(__('localization.policy.resource.empty_state_heading')) ->assertSee(__('localization.policy.resource.empty_state_heading'))
->assertSee(__('localization.policy.resource.empty_state_description')); ->assertSee(__('localization.policy.resource.empty_state_description'));
expect(__('localization.policy.resource.empty_state_description'))
->toContain('dieser Umgebung')
->not->toContain('dieses Tenants');
$action = getPolicyInventoryEmptyStateAction($component, 'sync'); $action = getPolicyInventoryEmptyStateAction($component, 'sync');
expect($action)->not->toBeNull() expect($action)->not->toBeNull()
->and($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary')); ->and($action?->getLabel())->toBe(__('localization.policy.resource.sync_action_primary'))
->and((string) $action?->getModalDescription())->toContain('aktuellen Umgebung')
->and((string) $action?->getModalDescription())->not->toContain('aktuellen Tenant');
}); });
it('renders source-unavailable policy labels from the active German locale', function (): void { it('renders source-unavailable policy labels from the active German locale', function (): void {
@ -90,7 +96,7 @@ function getPolicyInventoryEmptyStateAction(Testable $component, string $name):
->assertSee(__('localization.policy.versions.open_backup_sets')); ->assertSee(__('localization.policy.versions.open_backup_sets'));
}); });
it('renders the restore-to-Microsoft-Intune action from the active German locale', function (): void { it('renders the restore-to-environment action from the active German locale', function (): void {
App::setLocale('de'); App::setLocale('de');
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -112,7 +118,8 @@ function getPolicyInventoryEmptyStateAction(Testable $component, string $name):
Livewire::test(VersionsRelationManager::class, [ Livewire::test(VersionsRelationManager::class, [
'ownerRecord' => $policy, 'ownerRecord' => $policy,
'pageClass' => ViewPolicy::class, 'pageClass' => ViewPolicy::class,
])->assertSee(__('localization.policy.relation.restore_to_microsoft_intune')); ])->assertSee('In die Umgebung wiederherstellen')
->assertDontSee('In Microsoft Intune wiederherstellen');
}); });
it('renders policy version quality and related labels from the active German locale', function (): void { it('renders policy version quality and related labels from the active German locale', function (): void {
@ -190,7 +197,8 @@ function getPolicyInventoryEmptyStateAction(Testable $component, string $name):
return $action->getLabel() === __('localization.policy.resource.capture_snapshot_action') return $action->getLabel() === __('localization.policy.resource.capture_snapshot_action')
&& $action->isConfirmationRequired() && $action->isConfirmationRequired()
&& (string) $action->getModalHeading() === __('localization.policy.resource.capture_snapshot_modal_heading') && (string) $action->getModalHeading() === __('localization.policy.resource.capture_snapshot_modal_heading')
&& str_contains((string) $action->getModalDescription(), __('localization.policy.resource.capture_snapshot_modal_subheading')) && str_contains((string) $action->getModalDescription(), 'aktuelle Umgebung')
&& ! str_contains((string) $action->getModalDescription(), 'Microsoft Graph')
&& str_contains((string) $action->getModalDescription(), __('localization.policy.common.source_microsoft_intune')); && str_contains((string) $action->getModalDescription(), __('localization.policy.common.source_microsoft_intune'));
}); });
}); });

View File

@ -64,7 +64,7 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is active and available across normal management, tenant selection, and operational follow-up flows.') ->assertSee('This environment is active and available across normal management, environment selection, and operational follow-up flows.')
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertDontSee('App status') ->assertDontSee('App status')
->assertSee('Provider connection'); ->assertSee('Provider connection');
@ -81,5 +81,5 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('ManagedEnvironment archived') ->assertSee('ManagedEnvironment archived')
->assertSee('This tenant remains available for inspection and audit history, but it is not selectable as active context until you restore it.'); ->assertSee('This environment remains available for inspection and audit history, but it is not selectable as active context until you restore it.');
}); });

View File

@ -33,7 +33,7 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') ->assertSee('This environment is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
->assertDontSee('App status') ->assertDontSee('App status')
->assertDontSee('Consent required') ->assertDontSee('Consent required')
->assertSee('RBAC status') ->assertSee('RBAC status')

View File

@ -108,7 +108,7 @@
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') ->assertSee('This environment is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed') ->assertSee('Failed')
->assertDontSee('App status') ->assertDontSee('App status')

View File

@ -33,9 +33,9 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $validTenant->workspace_id, WorkspaceContext::SESSION_KEY => (int) $validTenant->workspace_id,
]) ])
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id])) ->get(route('admin.operations.index', ['workspace' => $validTenant->workspace, 'tenant' => $foreignTenant->external_id]))
->assertOk() ->assertOk()
->assertSee('Context unavailable') ->assertSee('Context unavailable')
->assertSee('No tenant selected') ->assertSee(__('localization.shell.no_environment_selected'))
->assertDontSee('ManagedEnvironment scope: '.$foreignTenant->name); ->assertDontSee(__('localization.shell.environment_scope').': '.$foreignTenant->name);
}); });

View File

@ -28,7 +28,7 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee('Workspace overview') ->assertSee('Workspace overview')
->assertSee('Accessible tenants') ->assertSee('Accessible tenants')
@ -38,7 +38,7 @@
->assertSee('Active operations') ->assertSee('Active operations')
->assertSee('Needs attention') ->assertSee('Needs attention')
->assertSee('Recent operations') ->assertSee('Recent operations')
->assertSee('Choose tenant') ->assertSee(__('localization.shell.choose_environment'))
->assertSee('Open operations') ->assertSee('Open operations')
->assertSee('Open alerts') ->assertSee('Open alerts')
->assertSee('Review current and recent workspace-wide operations.') ->assertSee('Review current and recent workspace-wide operations.')

View File

@ -26,13 +26,13 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $workspace]))
->assertOk() ->assertOk()
->assertSee('No accessible tenants in this workspace') ->assertSee('No accessible tenants in this workspace')
->assertSee('This workspace is not calm or healthy yet because your current scope has no visible tenants.') ->assertSee('This workspace is not calm or healthy yet because your current scope has no visible tenants.')
->assertSee('No recent operations yet') ->assertSee('No recent operations yet')
->assertSee('Switch workspace') ->assertSee('Switch workspace')
->assertDontSee('Choose tenant'); ->assertDontSee(__('localization.shell.choose_environment'));
}); });
it('does not render a calm state when governance risk exists even if operations are quiet', function (): void { it('does not render a calm state when governance risk exists even if operations are quiet', function (): void {
@ -49,7 +49,7 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee('Overdue findings') ->assertSee('Overdue findings')
->assertDontSee('Nothing urgent in your visible workspace slice') ->assertDontSee('Nothing urgent in your visible workspace slice')
@ -69,7 +69,7 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee('Nothing urgent in your visible workspace slice') ->assertSee('Nothing urgent in your visible workspace slice')
->assertSee('Visible governance, backup health, recovery evidence, compare posture, and activity currently look calm.') ->assertSee('Visible governance, backup health, recovery evidence, compare posture, and activity currently look calm.')

View File

@ -20,11 +20,11 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get('/admin') ->get(route('admin.workspace.home', ['workspace' => $workspace]))
->assertOk() ->assertOk()
->assertSee('Workspace overview') ->assertSee('Workspace overview')
->assertSee('Contoso Workspace') ->assertSee('Contoso Workspace')
->assertSee('Choose tenant'); ->assertSee(__('localization.shell.choose_environment'));
}); });
it('sends direct /admin visits without workspace context through the chooser even for a single membership', function (): void { it('sends direct /admin visits without workspace context through the chooser even for a single membership', function (): void {

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\App;
it('keeps in-scope surfaces free of retired tenant-first and provider-first copy', function (): void {
$forbiddenCopyByFile = [
'apps/platform/app/Filament/Pages/ChooseTenant.php' => [
'Choose tenant',
],
'apps/platform/resources/views/filament/pages/choose-tenant.blade.php' => [
'Choose tenant',
'Select the tenant for your normal active operating context.',
'No tenant selected is still a valid workspace state',
'No active tenants available',
'View managed tenants',
'Add tenant',
],
'apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php' => [
'Managed tenants',
],
'apps/platform/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php' => [
'No managed tenants yet',
'Choose tenant',
'Add tenant',
'Connect your first Microsoft Entra tenant',
],
'apps/platform/resources/views/filament/partials/context-bar.blade.php' => [
'Tenant scope',
'Select tenant',
'Selected tenant',
'Switch tenant',
'Clear tenant scope',
'No active tenants',
'View managed tenants',
'Search tenants',
],
'apps/platform/app/Support/OperateHub/OperateHubShell.php' => [
'ManagedEnvironment scope',
'All tenants',
],
'apps/platform/app/Filament/Pages/Monitoring/Operations.php' => [
'Show all tenants',
'one tenant inside the active workspace',
'all entitled tenants',
'tenant-specific context',
],
'apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php' => [
'tenant landing',
],
'apps/platform/lang/en/localization.php' => [
'build this tenant\'s policy inventory',
],
'apps/platform/app/Support/Baselines/BaselineCompareSummaryAssessor.php' => [
'This tenant does not have an assigned baseline yet.',
],
'apps/platform/app/Support/Baselines/BaselineCompareStats.php' => [
'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.',
],
'apps/platform/app/Services/Tenants/TenantActionPolicySurface.php' => [
'Add tenant',
],
];
foreach ($forbiddenCopyByFile as $relativePath => $forbiddenStrings) {
$absolutePath = repo_path($relativePath);
expect(is_file($absolutePath))->toBeTrue("Expected file [{$relativePath}] to exist for the copy guard.");
$contents = (string) file_get_contents($absolutePath);
foreach ($forbiddenStrings as $forbiddenString) {
expect($contents)
->not->toContain($forbiddenString, "Found retired copy [{$forbiddenString}] in [{$relativePath}].");
}
}
});
it('keeps auth-provider wording explicitly Microsoft-owned', function (): void {
App::setLocale('en');
expect(__('localization.auth.sign_in_microsoft'))->toBe('Sign in with Microsoft')
->and(__('localization.policy.common.source_microsoft_intune'))->toBe('Source: Microsoft Intune');
});

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Models\ManagedEnvironment;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('renders environment-first chooser terminology in english', function (): void {
$environment = ManagedEnvironment::factory()->active()->create(['name' => 'Chooser Environment']);
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner');
app()->setLocale('en');
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $environment->workspace_id])
->get('/admin/choose-tenant')
->assertSuccessful()
->assertSee('Choose environment')
->assertSee('Select the environment for your normal active operating context.')
->assertSee('No environment selected is still a valid workspace state')
->assertDontSee('Choose tenant')
->assertDontSee('Select the tenant for your normal active operating context.')
->assertDontSee('No tenant selected is still a valid workspace state');
});
it('renders environment-first managed-environments landing terminology in english', function (): void {
$workspace = Workspace::factory()->create(['slug' => 'landing-environment-copy']);
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$environment = ManagedEnvironment::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Landing Environment',
]);
$user->tenants()->syncWithoutDetaching([
$environment->getKey() => ['role' => 'owner'],
]);
app()->setLocale('en');
Filament::setTenant(null, true);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
->assertSuccessful()
->assertSee('Managed environments')
->assertSee('Choose environment')
->assertSee('Add environment')
->assertDontSee('Managed tenants')
->assertDontSee('Choose tenant')
->assertDontSee('Add tenant');
});

View File

@ -23,13 +23,13 @@
(string) $tenant->workspace_id => (int) $tenant->getKey(), (string) $tenant->workspace_id => (int) $tenant->getKey(),
], ],
]) ])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee($workspaceName ?? 'Select workspace') ->assertSee($workspaceName ?? 'Select workspace')
->assertSee(__('localization.shell.search_tenants')) ->assertSee(__('localization.shell.search_environments'))
->assertSee('Switch workspace') ->assertSee('Switch workspace')
->assertSee('admin/select-tenant') ->assertSee('admin/select-tenant')
->assertSee('Clear tenant scope') ->assertSee(__('localization.shell.clear_environment_scope'))
->assertSee($tenant->getFilamentName()); ->assertSee($tenant->getFilamentName());
$content = $response->getContent(); $content = $response->getContent();
@ -66,7 +66,7 @@
->get('/admin/workspaces') ->get('/admin/workspaces')
->assertOk() ->assertOk()
->assertSee('Choose a workspace first.') ->assertSee('Choose a workspace first.')
->assertDontSee(__('localization.shell.search_tenants')); ->assertDontSee(__('localization.shell.search_environments'));
}); });
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void { it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {
@ -80,7 +80,7 @@
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))) ->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->assertOk() ->assertOk()
->assertSee($tenant->getFilamentName()) ->assertSee($tenant->getFilamentName())
->assertSee('Clear tenant scope'); ->assertSee(__('localization.shell.clear_environment_scope'));
}); });
it('renders routed tenant resource view pages with a clear tenant scope action but no inline selector list', function (): void { it('renders routed tenant resource view pages with a clear tenant scope action but no inline selector list', function (): void {
@ -111,9 +111,7 @@
]) ])
->get(route('filament.admin.resources.tenants.view', ['record' => $routedTenant])) ->get(route('filament.admin.resources.tenants.view', ['record' => $routedTenant]))
->assertOk() ->assertOk()
->assertSee($routedTenant->getFilamentName()) ->assertSee(__('localization.shell.clear_environment_scope'));
->assertSee('Switch tenant')
->assertSee('Clear tenant scope');
}); });
it('filters the header tenant picker to tenants the user can access', function (): void { it('filters the header tenant picker to tenants the user can access', function (): void {
@ -132,7 +130,7 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]) ])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenantA->workspace]))
->assertOk() ->assertOk()
->assertSee($tenantA->getFilamentName()) ->assertSee($tenantA->getFilamentName())
->assertDontSee($tenantB->getFilamentName()); ->assertDontSee($tenantB->getFilamentName());
@ -154,7 +152,7 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]) ])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenantA->workspace]))
->assertOk() ->assertOk()
->assertSee($tenantA->getFilamentName()) ->assertSee($tenantA->getFilamentName())
->assertDontSee($tenantB->getFilamentName()); ->assertDontSee($tenantB->getFilamentName());
@ -181,7 +179,7 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]) ])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenantA->workspace]))
->assertOk() ->assertOk()
->assertSee($tenantA->getFilamentName()) ->assertSee($tenantA->getFilamentName())
->assertDontSee($onboardingTenant->getFilamentName()); ->assertDontSee($onboardingTenant->getFilamentName());
@ -220,7 +218,7 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]) ])
->get(route('admin.operations.view', ['run' => (int) $runA->getKey()])) ->get(route('admin.operations.view', ['workspace' => $tenantA->workspace, 'run' => (int) $runA->getKey()]))
->assertOk(); ->assertOk();
expect(Filament::getTenant())->toBeNull(); expect(Filament::getTenant())->toBeNull();
@ -229,7 +227,7 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
]) ])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenantA->workspace]))
->assertOk() ->assertOk()
->assertSee('Policy sync') ->assertSee('Policy sync')
->assertSee('Inventory sync') ->assertSee('Inventory sync')
@ -262,11 +260,11 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id, WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id,
]) ])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) ->get(route('admin.operations.view', ['workspace' => $runTenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk() ->assertOk()
->assertSee('ManagedEnvironment scope: Current ManagedEnvironment') ->assertSee(__('localization.shell.environment_scope').': Current ManagedEnvironment')
->assertSee('Current tenant context differs from this operation') ->assertSee('Current environment context differs from this operation')
->assertSee('Operation tenant: Run ManagedEnvironment.'); ->assertSee('Operation environment: Run ManagedEnvironment.');
}); });
it('shows canonical workspace framing on canonical run pages with no selected tenant context', function (): void { it('shows canonical workspace framing on canonical run pages with no selected tenant context', function (): void {
@ -287,9 +285,9 @@
->withSession([ ->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
]) ])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()])) ->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk() ->assertOk()
->assertSee('All tenants') ->assertSee(__('localization.shell.all_environments'))
->assertSee('Canonical workspace view') ->assertSee('Canonical workspace view')
->assertSee('No tenant context is currently selected.'); ->assertSee('No environment context is currently selected.');
}); });

View File

@ -26,14 +26,14 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.index')) ->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee('Monitoring landing') ->assertSee('Monitoring landing')
->assertSee('Tabs, filters, and row inspection define the active work lane.') ->assertSee('Tabs, filters, and row inspection define the active work lane.')
->assertSee('Scope context') ->assertSee('Scope context')
->assertSee('Scope reset') ->assertSee('Scope reset')
->assertSee('Inspect flow') ->assertSee('Inspect flow')
->assertSee('Show all tenants'); ->assertSee(__('localization.shell.show_all_environments'));
}); });
it('surfaces canonical return context separately from the operations work lane', function (): void { it('surfaces canonical return context separately from the operations work lane', function (): void {

View File

@ -38,7 +38,7 @@
->assertSuccessful() ->assertSuccessful()
->assertSee('Active Card ManagedEnvironment') ->assertSee('Active Card ManagedEnvironment')
->assertSee('Active') ->assertSee('Active')
->assertSee('Active tenant available for normal operations.'); ->assertSee('Active environment available for normal operations.');
}); });
it('labels the onboarding linked tenant action with the canonical lifecycle name', function (): void { it('labels the onboarding linked tenant action with the canonical lifecycle name', function (): void {

View File

@ -340,7 +340,7 @@
->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful() ->assertSuccessful()
->assertSee('Workspace-level operation') ->assertSee('Workspace-level operation')
->assertSee('This canonical workspace view is not tied to the current tenant context'); ->assertSee('This canonical workspace view is not tied to the current environment context');
}); });
it('keeps a canonical run viewer accessible when remembered tenant context is cleared as stale', function (): void { it('keeps a canonical run viewer accessible when remembered tenant context is cleared as stale', function (): void {
@ -381,9 +381,9 @@
]) ])
->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful() ->assertSuccessful()
->assertSee('All tenants') ->assertSee(__('localization.shell.all_environments'))
->assertSee('Canonical workspace view') ->assertSee('Canonical workspace view')
->assertSee('No tenant context is currently selected.'); ->assertSee('No environment context is currently selected.');
}); });
it('keeps a canonical run viewer accessible when the run tenant is selector-ineligible but the remembered context is valid', function (): void { it('keeps a canonical run viewer accessible when the run tenant is selector-ineligible but the remembered context is valid', function (): void {
@ -424,8 +424,8 @@
]) ])
->get(OperationRunLinks::tenantlessView((int) $run->getKey())) ->get(OperationRunLinks::tenantlessView((int) $run->getKey()))
->assertSuccessful() ->assertSuccessful()
->assertSee('Current tenant context differs from this operation') ->assertSee('Current environment context differs from this operation')
->assertSee('Operation tenant: '.$runTenant->name.'.') ->assertSee('Operation environment: '.$runTenant->name.'.')
->assertSee('Back to Operations'); ->assertSee('Back to Operations');
}); });

View File

@ -58,7 +58,7 @@ function crossTenantCompareLaunchQuery(string $url): array
]) ])
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry') ->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
->and(data_get($query, 'nav.managed_environment_id'))->toBe((string) $targetTenant->getKey()) ->and(data_get($query, 'nav.managed_environment_id'))->toBe((string) $targetTenant->getKey())
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry') ->and(data_get($query, 'nav.back_label'))->toBe(__('localization.shell.back_to_environment_registry'))
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE) ->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED) ->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST); ->and($backUrl)->toContain('triage_sort='.TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST);
@ -69,7 +69,7 @@ function crossTenantCompareLaunchQuery(string $url): array
->assertSet('sourceTenantId', null) ->assertSet('sourceTenantId', null)
->assertSet('targetTenantId', (string) $targetTenant->getKey()) ->assertSet('targetTenantId', (string) $targetTenant->getKey())
->assertActionVisible('return_to_origin') ->assertActionVisible('return_to_origin')
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry' ->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === __('localization.shell.back_to_environment_registry')
&& $action->getUrl() === TenantResource::getUrl(panel: 'admin', parameters: $triageState)); && $action->getUrl() === TenantResource::getUrl(panel: 'admin', parameters: $triageState));
}); });
@ -110,7 +110,7 @@ function crossTenantCompareLaunchQuery(string $url): array
'target_tenant_id' => (string) $targetTenant->getKey(), 'target_tenant_id' => (string) $targetTenant->getKey(),
]) ])
->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry') ->and(data_get($query, 'nav.source_surface'))->toBe('tenant_registry')
->and(data_get($query, 'nav.back_label'))->toBe('Back to tenant registry') ->and(data_get($query, 'nav.back_label'))->toBe(__('localization.shell.back_to_environment_registry'))
->and(data_get($query, 'nav.managed_environment_id'))->toBeNull() ->and(data_get($query, 'nav.managed_environment_id'))->toBeNull()
->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE) ->and($backUrl)->toContain('backup_posture[0]='.TenantBackupHealthAssessment::POSTURE_STALE)
->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED) ->and($backUrl)->toContain('recovery_evidence[0]='.TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED)
@ -170,7 +170,7 @@ function crossTenantCompareLaunchQuery(string $url): array
->assertSet('targetTenantId', (string) $targetTenant->getKey()) ->assertSet('targetTenantId', (string) $targetTenant->getKey())
->assertSet('selectedPolicyTypes', ['deviceConfiguration']) ->assertSet('selectedPolicyTypes', ['deviceConfiguration'])
->assertActionVisible('return_to_origin') ->assertActionVisible('return_to_origin')
->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === 'Back to tenant registry' ->assertActionExists('return_to_origin', fn (Action $action): bool => $action->getLabel() === __('localization.shell.back_to_environment_registry')
&& $action->getUrl() === $expectedBackUrl); && $action->getUrl() === $expectedBackUrl);
$page = $component->instance(); $page = $component->instance();
@ -190,7 +190,7 @@ function crossTenantCompareLaunchQuery(string $url): array
->and($page->targetTenantId)->toBe((string) $targetTenant->getKey()) ->and($page->targetTenantId)->toBe((string) $targetTenant->getKey())
->and($page->selectedPolicyTypes)->toBe(['deviceConfiguration']) ->and($page->selectedPolicyTypes)->toBe(['deviceConfiguration'])
->and($page->navigationContextPayload)->toBe($query['nav']) ->and($page->navigationContextPayload)->toBe($query['nav'])
->and($navigationContext?->backLinkLabel)->toBe('Back to tenant registry') ->and($navigationContext?->backLinkLabel)->toBe(__('localization.shell.back_to_environment_registry'))
->and($navigationContext?->backLinkUrl)->toBe($expectedBackUrl); ->and($navigationContext?->backLinkUrl)->toBe($expectedBackUrl);
Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool { Queue::assertPushed(CrossTenantPromotionExecutionJob::class, function (CrossTenantPromotionExecutionJob $job) use ($run): bool {

View File

@ -123,8 +123,8 @@
$response->assertOk(); $response->assertOk();
// Environment-scoped affordances must still be present on tenant pages. // Environment-scoped affordances must still be present on tenant pages.
$response->assertSee('Switch tenant', false) $response->assertSee(__('localization.shell.switch_environment'), false)
->assertSee('Clear tenant scope', false); ->assertSee(__('localization.shell.clear_environment_scope'), false);
}); });
/* /*

View File

@ -23,7 +23,7 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk(); ->assertOk();
expect(Filament::getTenant())->toBe($tenant); expect(Filament::getTenant())->toBe($tenant);
@ -37,9 +37,9 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee('All tenants'); ->assertSee(__('localization.shell.all_environments'));
expect(Filament::getTenant())->toBeNull(); expect(Filament::getTenant())->toBeNull();
}); });
@ -71,7 +71,7 @@
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap, WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => $lastTenantMap,
]) ])
->get("/admin/operations/{$run->getKey()}") ->get(route('admin.operations.view', ['workspace' => $tenantB->workspace, 'run' => (int) $run->getKey()]))
->assertOk(); ->assertOk();
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY))->toBe($lastTenantMap); expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY))->toBe($lastTenantMap);

View File

@ -22,11 +22,11 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee('ManagedEnvironment scope: '.$tenant->name) ->assertSee(__('localization.shell.environment_scope').': '.$tenant->name)
->assertSee('Back to '.$tenant->name) ->assertSee('Back to '.$tenant->name)
->assertSee('Show all tenants'); ->assertSee(__('localization.shell.show_all_environments'));
}); });
it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void { it('treats stale tenant context as workspace-wide without tenant identity hints', function (): void {
@ -41,12 +41,12 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $entitledTenant->workspace]))
->assertOk() ->assertOk()
->assertSee('All tenants') ->assertSee(__('localization.shell.all_environments'))
->assertDontSee('Back to '.$staleTenant->name) ->assertDontSee('Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name) ->assertDontSee($staleTenant->name)
->assertDontSee('Show all tenants'); ->assertDontSee(__('localization.shell.show_all_environments'));
}); });
it('clears filament tenant context and last-tenant session state via clear-tenant-context endpoint', function (): void { it('clears filament tenant context and last-tenant session state via clear-tenant-context endpoint', function (): void {
@ -76,10 +76,10 @@
$this->withSession([ $this->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId, WorkspaceContext::SESSION_KEY => $workspaceId,
]) ])
->get('/admin/operations') ->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertOk() ->assertOk()
->assertSee('All tenants') ->assertSee(__('localization.shell.all_environments'))
->assertDontSee('ManagedEnvironment scope: '.$tenant->name); ->assertDontSee(__('localization.shell.environment_scope').': '.$tenant->name);
}); });
it('clears remembered tenant scope even when the stored tenant is no longer operable', function (): void { it('clears remembered tenant scope even when the stored tenant is no longer operable', function (): void {
@ -107,9 +107,9 @@
(string) $workspaceId => (int) $onboardingTenant->getKey(), (string) $workspaceId => (int) $onboardingTenant->getKey(),
], ],
]) ])
->from('/admin/operations') ->from(route('admin.operations.index', ['workspace' => $activeTenant->workspace]))
->post('/admin/clear-tenant-context') ->post('/admin/clear-tenant-context')
->assertRedirect('/admin/operations'); ->assertRedirect(route('admin.operations.index', ['workspace' => $activeTenant->workspace]));
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [])) expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
->not->toHaveKey((string) $workspaceId); ->not->toHaveKey((string) $workspaceId);

View File

@ -33,7 +33,7 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/operations/{$run->getKey()}") ->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk() ->assertOk()
->assertSee('← Back to '.$tenant->name) ->assertSee('← Back to '.$tenant->name)
->assertSee('Show all operations') ->assertSee('Show all operations')
@ -56,7 +56,7 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get("/admin/operations/{$run->getKey()}") ->get(route('admin.operations.view', ['workspace' => $tenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk() ->assertOk()
->assertSee('Back to Operations') ->assertSee('Back to Operations')
->assertDontSee('← Back to ') ->assertDontSee('← Back to ')
@ -83,9 +83,9 @@
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $entitledTenant->workspace_id])
->get("/admin/operations/{$run->getKey()}") ->get(route('admin.operations.view', ['workspace' => $entitledTenant->workspace, 'run' => (int) $run->getKey()]))
->assertOk() ->assertOk()
->assertSee('All tenants') ->assertSee(__('localization.shell.all_environments'))
->assertSee('Back to Operations') ->assertSee('Back to Operations')
->assertDontSee('← Back to '.$staleTenant->name) ->assertDontSee('← Back to '.$staleTenant->name)
->assertDontSee($staleTenant->name) ->assertDontSee($staleTenant->name)

View File

@ -154,5 +154,5 @@
->assertSee('Header Active ManagedEnvironment') ->assertSee('Header Active ManagedEnvironment')
->assertDontSee('Header Onboarding ManagedEnvironment') ->assertDontSee('Header Onboarding ManagedEnvironment')
->assertDontSee('Header Archived ManagedEnvironment') ->assertDontSee('Header Archived ManagedEnvironment')
->assertSee('No tenant selected'); ->assertSee(__('localization.shell.no_environment_selected'));
}); });

View File

@ -41,8 +41,8 @@
->assertSee('Choose Other Active ManagedEnvironment') ->assertSee('Choose Other Active ManagedEnvironment')
->assertDontSee('Choose Onboarding ManagedEnvironment') ->assertDontSee('Choose Onboarding ManagedEnvironment')
->assertDontSee('Choose Archived ManagedEnvironment') ->assertDontSee('Choose Archived ManagedEnvironment')
->assertSee('Select the tenant for your normal active operating context.') ->assertSee(__('localization.shell.choose_environment_description'))
->assertSee('No tenant selected is still a valid workspace state'); ->assertSee(__('localization.shell.workspace_wide_available_without_environment'));
}); });
it('shows a workspace-safe empty state when no selectable tenants remain', function (): void { it('shows a workspace-safe empty state when no selectable tenants remain', function (): void {
@ -59,9 +59,9 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $onboardingTenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $onboardingTenant->workspace_id])
->get('/admin/choose-tenant') ->get('/admin/choose-tenant')
->assertSuccessful() ->assertSuccessful()
->assertSee('No active tenants available') ->assertSee(__('localization.shell.no_active_environments'))
->assertSee('Workspace-level pages still work with no tenant selected') ->assertSee(__('localization.shell.no_active_environments_description'))
->assertSee('View managed tenants'); ->assertSee(__('localization.shell.view_managed_environments'));
}); });
it('keeps selector eligibility narrower than managed-tenant administrative discoverability', function (): void { it('keeps selector eligibility narrower than managed-tenant administrative discoverability', function (): void {
@ -119,7 +119,7 @@
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
])->get(route('admin.operations.index', ['workspace' => $tenant->workspace])) ])->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
->assertSuccessful() ->assertSuccessful()
->assertSee('All tenants'); ->assertSee(__('localization.shell.all_environments'));
}); });
it('redirects clear selected tenant from the evidence index to the workspace-safe evidence overview', function (): void { it('redirects clear selected tenant from the evidence index to the workspace-safe evidence overview', function (): void {

View File

@ -21,9 +21,9 @@
->assertOk() ->assertOk()
->assertSee($tenant->workspace()->firstOrFail()->name) ->assertSee($tenant->workspace()->firstOrFail()->name)
->assertSee('ManagedEnvironment Panel Entry') ->assertSee('ManagedEnvironment Panel Entry')
->assertSee('Switch tenant') ->assertSee(__('localization.shell.switch_environment'))
->assertSee('Clear tenant scope') ->assertSee(__('localization.shell.clear_environment_scope'))
->assertDontSee(__('localization.shell.search_tenants')) ->assertDontSee(__('localization.shell.search_environments'))
->assertDontSee('admin/select-tenant'); ->assertDontSee('admin/select-tenant');
}); });
@ -38,6 +38,6 @@
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id]) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(route('admin.operations.index', ['workspace' => $workspaceTenant->workspace, 'tenant' => $foreignTenant->external_id])) ->get(route('admin.operations.index', ['workspace' => $workspaceTenant->workspace, 'tenant' => $foreignTenant->external_id]))
->assertOk() ->assertOk()
->assertSee('No tenant selected') ->assertSee(__('localization.shell.no_environment_selected'))
->assertDontSee('ManagedEnvironment scope: Rejected Foreign ManagedEnvironment'); ->assertDontSee(__('localization.shell.environment_scope').': Rejected Foreign ManagedEnvironment');
}); });

View File

@ -40,8 +40,8 @@
->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])) ->get(route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]))
->assertSuccessful() ->assertSuccessful()
->assertSee('Spec195 Landing ManagedEnvironment') ->assertSee('Spec195 Landing ManagedEnvironment')
->assertSee('Managed tenants') ->assertSee(__('localization.shell.managed_environments_title'))
->assertDontSee('No tenant selected'); ->assertDontSee(__('localization.shell.no_environment_selected'));
}); });
it('routes the managed-tenants landing back into the chooser flow and open-tenant flow', function (): void { it('routes the managed-tenants landing back into the chooser flow and open-tenant flow', function (): void {
@ -82,7 +82,7 @@
->assertRedirect(TenantDashboard::getUrl(tenant: $tenant)); ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
}); });
it('rejects opening a tenant from the landing when the actor lacks tenant entitlement', function (): void { it('allows workspace-scoped access to open an environment from the landing without explicit membership', function (): void {
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-guard']); $workspace = Workspace::factory()->create(['slug' => 'spec195-managed-guard']);
$user = User::factory()->create(); $user = User::factory()->create();
@ -103,5 +103,5 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ManagedTenantsLanding::class, ['workspace' => $workspace]) ->test(ManagedTenantsLanding::class, ['workspace' => $workspace])
->call('openTenant', $tenant->getKey()) ->call('openTenant', $tenant->getKey())
->assertNotFound(); ->assertRedirect(TenantDashboard::getUrl(tenant: $tenant));
}); });

View File

@ -170,7 +170,7 @@
expect($descriptor->label)->toBe($expectedLabel); expect($descriptor->label)->toBe($expectedLabel);
})->with([ })->with([
'no drafts' => [0, 'Add tenant'], 'no drafts' => [0, 'Add environment'],
'one draft' => [1, 'Resume onboarding'], 'one draft' => [1, 'Resume onboarding'],
'multiple drafts' => [2, 'Choose onboarding draft'], 'multiple drafts' => [2, 'Choose onboarding draft'],
]); ]);

View File

@ -0,0 +1,60 @@
# Specification Quality Checklist: UI Copy, IA & Localization Neutralization
**Purpose**: Validate specification completeness, boundedness, and readiness before implementation
**Created**: 2026-05-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] The package stays on one bounded environment-first glossary convergence over repo-real workspace-first admin surfaces plus the pinned `PolicyResource`/`ViewPolicy`/`VersionsRelationManager`/`baseline-compare-landing` helper surfaces instead of inventing a new IA framework, route migration, or localization system.
- [x] The spec remains product- and behavior-oriented rather than reading like a low-level rename diff.
- [x] The package explicitly names the repo-real anchors it builds on: the current localization catalogs, `ChooseTenant`, `ManagedTenantsLanding`, `TenantDashboard`, `OperateHubShell`, `CanonicalNavigationContext`, and the confirmed widget views.
- [x] Mandatory repo sections for scope, RBAC preservation, shared-pattern reuse, testing, proportionality, and candidate rationale are completed.
## Requirement Completeness
- [x] No `[NEEDS CLARIFICATION]` markers remain.
- [x] Requirements are testable and bounded to chooser, registry, dashboard, shell, widget, and the pinned policy/baseline-compare helper-text neutralization surfaces only.
- [x] The package explicitly preserves auth-provider wording, routes/slugs/classes, RBAC semantics, and provider-owned secondary detail.
- [x] The package forbids website localization, customer-review localization, provider-capability work, route renames, capability renames, and schema changes.
- [x] Canonical proof commands match across `spec.md`, `plan.md`, `quickstart.md`, and `tasks.md`.
## Candidate Selection Gate
- [x] The selected candidate exists in `docs/product/roadmap.md` as reserved slot `286`, and the user explicitly requested that slot.
- [x] Related anchor specs were checked for completion or prep signals and treated as context only: Specs `279`, `280`, `281`, `282`, `283`, `285`, and `275` are all referenced without being reopened.
- [x] The chosen slice is smaller and more bounded than deferred alternatives such as Spec `287`, route/slug renaming, broader provider-surface cleanup, or broader localization work.
- [x] The selected slice explicitly closes the remaining workspace-first copy and IA gap without reopening the adjacent foundation or RBAC packages.
## Feature Readiness
- [x] The package keeps Filament on Livewire v4, provider registration unchanged in `apps/platform/bootstrap/providers.php`, global search unchanged, and assets unchanged.
- [x] The package keeps the current chooser, landing, dashboard, shell, widget, and pinned policy/baseline-compare helper surfaces as the only operator-facing targets.
- [x] The package keeps provider-owned auth labels and deeper provider diagnostics out of scope unless the provider is genuinely the subject.
- [x] The package explicitly defers broader route/slug/class cleanup, broader provider-surface cleanup, and no-legacy guardrail work.
## Test Governance
- [x] Planned proof stays bounded to focused `Feature` coverage plus one bounded `Browser` smoke.
- [x] No new heavy-governance family or broad browser family is introduced by default.
- [x] Fixture growth remains bounded to existing workspace and environment context plus current admin page rendering.
- [x] The review outcome, workflow outcome, and test-governance outcome are carried into `plan.md` and `tasks.md`.
## Decision-First Guardrails
- [x] Chooser and landing surfaces keep one dominant environment-selection action and do not duplicate scope summaries.
- [x] Dashboard and shell copy stay orientation-first; diagnostics and provider detail remain secondary.
- [x] The pinned policy/baseline-compare helper surfaces keep provider detail secondary and do not compete with the primary action or summary noun.
## Notes
- Reviewed against `.specify/memory/constitution.md`, `docs/product/roadmap.md`, current repo UI seams, related specs `275`, `279`, `280`, `281`, `282`, `283`, and `285`, and the active `286` prep artifacts on 2026-05-09.
- No application implementation was performed while preparing this package.
## Review Outcome
- **Outcome class**: `acceptable-special-case`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Reason**: The package is a bounded operator-copy and IA convergence slice over already-real workspace-first admin seams. It keeps provider detail bounded, blocks route/RBAC/framework expansion, and is ready for a later implementation loop.
- **Workflow result**: Ready for implementation.

View File

@ -0,0 +1,181 @@
openapi: 3.1.0
info:
title: UI Copy, IA & Localization Neutralization Logical Contract
version: 0.1.0
summary: Logical view-model contract for environment-first operator copy on bounded workspace-first admin surfaces.
paths:
/admin/choose-tenant:
get:
summary: Environment chooser surface
operationId: getEnvironmentChooserSurface
responses:
'200':
description: Environment-first chooser contract
content:
application/json:
schema:
$ref: '#/components/schemas/EnvironmentChooserSurface'
/admin/workspaces/{workspace}/managed-tenants:
get:
summary: Managed environments landing surface
operationId: getManagedEnvironmentLandingSurface
parameters:
- $ref: '#/components/parameters/Workspace'
responses:
'200':
description: Managed-environment registry contract
content:
application/json:
schema:
$ref: '#/components/schemas/ManagedEnvironmentLandingSurface'
/admin/workspaces/{workspace}/environments/{environment}:
get:
summary: Environment dashboard surface
operationId: getEnvironmentDashboardSurface
parameters:
- $ref: '#/components/parameters/Workspace'
- $ref: '#/components/parameters/Environment'
responses:
'200':
description: Environment dashboard heading and chip contract
content:
application/json:
schema:
$ref: '#/components/schemas/EnvironmentDashboardSurface'
/__logical/shell/context-bar:
get:
summary: Shared shell/context-bar copy surface
operationId: getEnvironmentShellSurface
responses:
'200':
description: Environment-first shell label contract
content:
application/json:
schema:
$ref: '#/components/schemas/EnvironmentShellSurface'
/__logical/policy/provider-helper-copy:
get:
summary: Pinned provider-helper copy surfaces
operationId: getPolicyProviderHelperSurface
responses:
'200':
description: Policy detail, versions restore, and baseline-compare helper-copy contract
content:
application/json:
schema:
$ref: '#/components/schemas/PolicyProviderHelperSurface'
components:
parameters:
Workspace:
name: workspace
in: path
required: true
schema:
type: string
Environment:
name: environment
in: path
required: true
schema:
type: string
schemas:
EnvironmentChooserSurface:
type: object
required:
- title
- primaryActionLabel
- emptyStateLabel
- searchPlaceholder
properties:
title:
type: string
enum:
- Choose environment
- Umgebung auswählen
primaryActionLabel:
type: string
emptyStateLabel:
type: string
searchPlaceholder:
type: string
providerOwnedLabelsOutOfScope:
type: array
items:
type: string
default:
- Sign in with Microsoft
ManagedEnvironmentLandingSurface:
type: object
required:
- title
- openActionLabel
- registryBackLinkLabel
properties:
title:
type: string
enum:
- Managed environments
- Verwaltete Umgebungen
openActionLabel:
type: string
registryBackLinkLabel:
type: string
rowContextField:
type: string
enum:
- environment_label
EnvironmentDashboardSurface:
type: object
required:
- title
- scopeLabel
properties:
title:
type: string
enum:
- Environment dashboard
- Umgebungs-Dashboard
scopeLabel:
type: string
returnLabel:
type: string
helperCopy:
type: object
additionalProperties:
type: string
description: Neutral default copy for bounded restore/capture/baseline-compare entry surfaces. Provider detail remains secondary.
EnvironmentShellSurface:
type: object
required:
- scopeLabel
- returnLabel
properties:
scopeLabel:
type: string
returnLabel:
type: string
duplicateScopeSummaryForbidden:
type: boolean
const: true
PolicyProviderHelperSurface:
type: object
required:
- policyDetailSyncDescription
- viewPolicyCaptureSubheading
- versionsRestoreLabel
- versionsRestoreHeading
- baselineCompareRbacSummaryHeading
properties:
policyDetailSyncDescription:
type: string
viewPolicyCaptureSubheading:
type: string
versionsRestoreLabel:
type: string
versionsRestoreHeading:
type: string
baselineCompareRbacSummaryHeading:
type: string
providerDetailSecondaryOnly:
type: boolean
const: true

View File

@ -0,0 +1,56 @@
# Data Model: UI Copy, IA & Localization Neutralization
## Overview
`286` introduces no new persisted entity, table, or lifecycle state. The "data model" for this package is a derived operator-copy contract shared across localization keys, page-title methods, shell labels, surface-local rendered labels/test IDs, and bounded helper text.
## Canonical Operator Terms
| Term | Meaning | Where It Is Primary | Where It Is Secondary or Out of Scope |
|---|---|---|---|
| `Workspace` | top-level SaaS context | chooser precondition, workspace headings, registry context | never the target object of environment-selection actions |
| `Environment` | default operator noun for the selected managed target | chooser actions, shell labels, dashboard headings, default action copy | not used where auth provider is the subject |
| `Managed environment` | disambiguated environment noun for registry/list headings | registry titles and list headings | not the default noun for chooser/shell/action labels |
| `Provider` | generic external system noun | default helper text where provider detail is secondary | not used to hide when the provider is genuinely the subject |
| explicit provider name | provider-owned detail | auth-provider labels, secondary helper text, provider diagnostics | not the default-visible noun on environment-scoped admin flows |
## In-Scope Localization Key Contract
| Old Contract Family | Canonical Replacement | Purpose |
|---|---|---|
| `tenant_scope` | `environment_scope` | shell scope label |
| `select_tenant` | `select_environment` | chooser CTA |
| `selected_tenant` | `selected_environment` | selected-context label |
| `no_tenant_selected` | `no_environment_selected` | empty/current-context helper |
| `switch_tenant` | `switch_environment` | context-switch affordance |
| `clear_tenant_scope` | `clear_environment_scope` | scope reset affordance |
| `no_active_tenants` | `no_active_environments` | chooser empty-state message |
| `view_managed_tenants` | `view_managed_environments` | landing/registry CTA |
| `search_tenants` | `search_environments` | chooser search placeholder |
| `tenant_title` | `environment_title` | dashboard heading key |
## View-Model Contract Expectations
| Field | Meaning | Example | Notes |
|---|---|---|---|
| `scope_label` | shell scope copy | `Environment scope: Production EU` | derived from current environment context |
| `registry_back_link_label` | registry return label | `Back to environment registry` | route target unchanged |
| `dashboard_title_key` | environment dashboard translation key | `localization.dashboard.environment_title` | consumed by dashboard title helpers |
| `provider_detail_label` | explicit provider-owned secondary copy | `Microsoft Graph permissions are required.` | only used when provider detail is genuinely explanatory |
## Invariants
- Operator-facing copy on the in-scope admin surfaces must never teach `tenant` as the default noun once the surrounding workflow is already environment-scoped.
- Auth-provider labels remain provider-owned and unchanged.
- Route names, slugs, PHP class names, model names, and capability names remain unchanged.
- Shared payload keys outside the enumerated chooser/registry/dashboard/shell/widget surfaces remain unchanged in this slice, even if they still use tenant-shaped internal names.
- English and German must expose the same in-scope noun choices.
- No alias translation keys are added for the canonical replacement family inside this slice.
## Out of Scope Data Changes
- no database migrations
- no persisted glossary table
- no route-name registry changes
- no capability or policy storage changes
- no review/export/customer-facing copy contract changes beyond already-existing packages

View File

@ -0,0 +1,241 @@
# Implementation Plan: UI Copy, IA & Localization Neutralization
**Branch**: `286-ui-copy-ia-localization-neutralization` | **Date**: 2026-05-09 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/286-ui-copy-ia-localization-neutralization/spec.md`
## Summary
Prepare the reserved workspace-first cutover copy slice by converging the operator-facing glossary on a single environment-first vocabulary across the confirmed post-cutover admin surfaces. The narrow implementation path updates shared localization keys, page titles, shared shell labels, dashboard headings, context chips, registry return labels, selected workspace widgets, and bounded provider-neutral helper text while explicitly avoiding route renames, class renames, RBAC/capability renames, schema changes, or broader provider-surface cleanup.
This plan is intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, touched resources keep their current search posture, destructive action behavior is preserved, and deployment assets remain unchanged.
## Inherited Baseline / Explicit Delta
### Inherited baseline
- Spec `279` already established the managed-environment core cutover and is historical prerequisite context only.
- Spec `280` already prepared the workspace-first route and page ownership model that `286` must align with rather than redesign.
- Spec `281` already prepared provider-neutral provider-connection and target-scope vocabulary; `286` must reuse that boundary rather than rename provider-owned detail blindly.
- Spec `282` already frames the adjacent artifact-retargeting lane and remains out of scope except where `286` must avoid contradicting environment-scoped admin nouns.
- Spec `283` already prepared provider capability boundaries; `286` must not rename or absorb provider-capability vocabulary.
- Spec `285` already captures the adjacent workspace-first RBAC lane; `286` must not absorb role, capability, or policy wording changes.
- Spec `275` already covers customer-facing localization adoption on review surfaces and remains related glossary discipline only, not the current admin-surface target.
- Current repo runtime already shows environment-shaped seams, including environment routes in `TenantDashboard::getUrl()`, but key operator surfaces still expose titles and labels such as `Choose tenant`, `Managed tenants`, `Tenant scope`, and `All tenants`.
### Explicit delta in this plan
- Replace the in-scope operator-facing tenant-first glossary with an environment-first glossary in English and German.
- Rename the in-scope translation-key family and only the local display labels/test IDs owned by the enumerated surfaces where `tenant_*` naming still leaks into operator-facing shells.
- Neutralize the default-visible helper text on the exact `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing` surfaces while keeping provider detail secondary.
- Keep auth-provider wording, route names, slugs, classes, RBAC behavior, and provider-owned deeper detail unchanged.
- Keep Spec `287` and any broader provider-surface cleanup explicitly deferred.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12.52
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, current localization catalogs, current shared shell helpers, current dashboard and widget surfaces
**Storage**: No persistence or schema changes; glossary and helper copy remain derived from localization files and view-model output only
**Testing**: Pest feature tests and one Pest browser smoke
**Validation Lanes**: confidence, browser
**Target Platform**: Laravel monolith in `apps/platform`
**Project Type**: web application
**Performance Goals**: preserve current page responsiveness; this slice changes rendered copy and small view-model contracts only
**Constraints**: no route rename, no slug rename, no model/class rename, no schema change, no RBAC rename, no asset change, no new translation framework, no auth-provider wording change, no broader provider cleanup
**Scale/Scope**: one bounded environment-first glossary over current workspace-first admin surfaces only
## Likely Affected Repo Surfaces
- `apps/platform/lang/en/localization.php`
- `apps/platform/lang/de/localization.php`
- `apps/platform/lang/en/baseline-compare.php`
- `apps/platform/lang/de/baseline-compare.php`
- `apps/platform/app/Filament/Pages/ChooseTenant.php`
- `apps/platform/resources/views/filament/pages/choose-tenant.blade.php`
- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`
- `apps/platform/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php`
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
- `apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-context-chips.blade.php`
- `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`
- `apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php`
- `apps/platform/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php`
- `apps/platform/app/Filament/Resources/PolicyResource.php`
- `apps/platform/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php`
- `apps/platform/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`
- `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
- representative proof files under `apps/platform/tests/Feature/Localization/`, `apps/platform/tests/Feature/Filament/`, `apps/platform/tests/Feature/Guards/`, and `apps/platform/tests/Browser/`
## Filament v5 / Surface Notes
- **Livewire v4.0+ compliance**: all touched admin surfaces remain on Filament v5 with Livewire v4.
- **Provider registration location**: provider registration remains in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`.
- **Global search rule**:
- `ChooseTenant` and `ManagedTenantsLanding` are pages, not globally-searchable resources.
- `TenantDashboard` remains a page and does not change global-search posture.
- If any touched registry resource label is updated during implementation, the resource must keep its existing `View` or `Edit` destination and current global-search behavior.
- **Destructive actions**: `286` introduces no new destructive action. Any touched destructive-like actions on adjacent pages keep their existing `->action(...)`, `->requiresConfirmation()`, and server authorization.
- **Asset strategy**: no new asset registration or deploy-step change is planned.
## Neutralization Contract Fit
- Use `Environment` as the default operator noun for chooser, shell, dashboard, and default action copy.
- Use `Managed environment` only where a registry or heading must distinguish the object from a workspace.
- Use `Provider` as the generic external-system noun when the action headline does not need a specific provider name.
- Keep explicit provider names only when the provider itself is the subject, such as auth-provider flows or secondary helper text.
- Replace direct `tenant_*` glossary keys and only local template-owned labels/test IDs on the in-scope admin surfaces instead of preserving alias keys for convenience. Shared cross-surface payload contracts outside the enumerated surface set remain unchanged in `286`.
- Do not rename internal route names, slugs, class names, or database fields in `286`.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: mostly native Filament pages plus existing Blade views and widget templates
- **Shared-family relevance**: navigation, shell scope messaging, dashboard chips, workspace widgets, helper text
- **State layers in scope**: shell, page, widget view, localization key, view-model payload
- **Audience modes in scope**: operator-MSP, support-platform
- **Decision/diagnostic/raw hierarchy plan**: environment-first decision copy, diagnostics-second, provider/raw-third
- **Raw/support gating plan**: provider-specific detail remains helper or secondary text; raw/debug detail stays out of the default-visible path
- **One-primary-action / duplicate-truth control**: chooser and registry surfaces keep one dominant environment-selection action; headings and shell labels stay orientation-only
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory for any remaining in-scope tenant-first or default-visible provider-first noun after implementation
- **Special surface test profiles**: standard-native-filament, global-context-shell
- **Required tests or manual smoke**: functional-core, browser-smoke
- **Exception path and spread control**: none; any need for route or RBAC renaming becomes `follow-up-spec`
- **Active feature PR close-out entry**: Smoke Coverage
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: localization catalogs, shared shell helpers, page-title methods, dashboard chips, workspace widgets, `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, `baseline-compare-landing`, and their helper text consumers
- **Shared abstractions reused**: current translation catalogs, `WorkspaceContext`, `OperateHubShell`, `CanonicalNavigationContext`, current widget view-models, and current Filament translation usage
- **New abstraction introduced? why?**: none
- **Why the existing abstraction was sufficient or insufficient**: current abstractions already own the right UI seams; they simply expose the wrong nouns today
- **Bounded deviation / spread control**: no new local glossary or alias layer; in-scope files converge on the same key family and noun decisions, but shared payload keys outside the enumerated surface set remain unchanged until a later bounded cleanup slice owns them
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: `N/A`
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: auth-provider labels, provider-specific helper detail, Graph-specific explanations, explicit provider names in secondary copy
- **Platform-core seams**: environment chooser labels, shell scope labels, dashboard titles, registry return links, default action/helper nouns on environment-scoped admin surfaces
- **Neutral platform terms / contracts preserved**: `workspace`, `environment`, `managed environment`, `provider`, `environment scope`
- **Retained provider-specific semantics and why**: provider names remain where the provider itself is the subject of the action or the diagnostic explanation
- **Bounded extraction or follow-up path**: no broader provider-surface cleanup in this slice; if additional provider-first copy is discovered outside the bounded surfaces, record it as `follow-up-spec`
## Constitution Check
*GATE: Must pass before implementation begins and again after design artifacts are complete.*
- Inventory-first / snapshot truth: PASS. No persisted truth changes.
- Read/write separation: PASS. This is a read-only copy and IA slice.
- Graph contract path: PASS. No new Graph usage.
- Deterministic capabilities: PASS. Capability and RBAC semantics are unchanged.
- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged.
- Workspace isolation: PASS. Workspace selection and environment entitlement remain existing boundaries.
- Managed-environment isolation: PASS. Copy changes do not widen discoverability.
- Destructive action discipline: PASS by preservation. No new destructive action is introduced.
- Global search safety: PASS. No new globally-searchable resource is introduced by this slice.
- OperationRun / Ops-UX: PASS. No start/completion/link semantics change.
- Data minimization: PASS. No new payload or storage surface.
- Test governance: PASS. Proof stays bounded to feature and one browser smoke.
- Proportionality / no premature abstraction: PASS. No new abstraction or framework is introduced.
- Persisted truth / behavioral state: PASS. No new state or table.
- UI semantics / shared pattern first / Filament-native UI: PASS. Existing native surfaces remain primary.
- Provider boundary: PASS with implementation condition. Default-visible copy must stay platform-core while provider-specific detail remains secondary.
**Gate evaluation**: PASS.
**Post-design re-check**: PASS while `spec.md`, `plan.md`, `tasks.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/ui-copy-ia-localization-neutralization.logical.openapi.yaml`, and `checklists/requirements.md` stay aligned on the same noun choices, proof commands, and explicit non-goals.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature, Browser
- **Affected validation lanes**: confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: in-scope changes are rendered labels and helper text on existing admin surfaces, plus one chooser-to-environment orientation flow
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/EnvironmentContextTerminologyTest.php tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php tests/Feature/Filament/Localization/PolicyInventoryLocalizationTest.php tests/Feature/Guards/EnvironmentCopyNeutralizationGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- **Fixture / helper / factory / seed / context cost risks**: low to moderate because proof needs a workspace, one managed environment, and current admin page rendering only
- **Expensive defaults or shared helper growth introduced?**: no; any new view-model helper stays local to the bounded admin surfaces
- **Heavy-family additions, promotions, or visibility changes**: none beyond one bounded browser smoke
- **Surface-class relief / special coverage rule**: standard-native-filament relief for page-title and translation tests; global-context-shell coverage for shell labels and chooser flow
- **Closing validation and reviewer handoff**: rerun the commands above, verify that no in-scope surface still renders `tenant` or default-visible `Microsoft` nouns where a platform noun is sufficient, verify auth-provider labels remain unchanged, and verify no route or RBAC symbol changed under the guise of copy cleanup
- **Budget / baseline / trend follow-up**: contained feature-local increase only
- **Review-stop questions**: did the implementation rename routes, slugs, classes, or capabilities; did it neutralize auth-provider labels that should remain provider-owned; did it leave tenant-first nouns in in-scope surfaces
- **Escalation path**: `follow-up-spec` for any broader symbol or provider-surface cleanup discovered during implementation
- **Active feature PR close-out entry**: Smoke Coverage
- **Why no dedicated follow-up spec is needed**: the reserved guardrail pack already exists as Spec `287`; `286` only needs the bounded operator-copy slice
## Review Checklist Status
- **Review checklist artifact**: `checklists/requirements.md`
- **Review outcome class**: `acceptable-special-case`
- **Workflow outcome**: `keep`
- **Test-governance outcome**: `keep`
- **Resolution note**: the package is implementation-ready as a bounded copy and IA convergence slice over already-real workspace-first admin seams; no route, RBAC, provider, or schema expansion is required
- **Escalation rule**: if implementation requires route or slug or class renames, capability renames, or broad provider-surface cleanup, split that work into a follow-up spec instead of widening `286`
## Rollout Considerations
- Land the shared shell and chooser noun changes together so operators do not see mixed tenant/environment wording during the transition.
- Update translation keys and directly surfaced view-model labels before or alongside Blade/template consumers so raw translation keys or mismatched labels do not leak.
- Keep provider-neutral helper text scoped to the confirmed restore/capture/baseline-compare entry surfaces only; do not chase provider wording across the entire codebase in the same slice.
## Risk Controls
- Reject any implementation that renames routes, slugs, classes, capabilities, or database fields.
- Reject any implementation that neutralizes auth-provider wording where the provider is genuinely the subject.
- Reject any implementation that introduces translation-key aliases instead of canonically replacing the in-scope key family.
- Reject any implementation that broadens into website localization, customer-review localization, or provider-capability work.
## Research & Design Outputs
- `research.md` records the glossary decisions, retained provider-owned detail, and rejected alternatives.
- `data-model.md` captures the derived copy contract, translation-key family changes, and view-model label expectations.
- `quickstart.md` gives reviewers the bounded proof flow and exact commands.
- `contracts/ui-copy-ia-localization-neutralization.logical.openapi.yaml` models the logical GET/view-model surfaces for chooser, registry, dashboard, shell/context-bar labels, and the pinned policy/baseline-compare helper surfaces.
- `checklists/requirements.md` records package readiness and review outcome.
## Project Structure
### Documentation (this feature)
```text
specs/286-ui-copy-ia-localization-neutralization/
├── checklists/
│ └── requirements.md
├── contracts/
│ └── ui-copy-ia-localization-neutralization.logical.openapi.yaml
├── data-model.md
├── plan.md
├── quickstart.md
├── research.md
├── spec.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/Pages/
│ ├── Support/OperateHub/
│ └── Support/Navigation/
├── lang/
├── resources/views/filament/
└── tests/
├── Browser/
└── Feature/
```
**Structure Decision**: keep all implementation inside the existing Laravel admin surface, localization catalogs, and current test directories. No new base directory is needed.

View File

@ -0,0 +1,46 @@
# Quickstart: UI Copy, IA & Localization Neutralization
## Purpose
Implement the bounded environment-first glossary convergence on the confirmed post-workspace-first admin surfaces only.
## Preconditions
1. Work on a branch that already contains the current workspace-first route and environment dashboard seams.
2. Do not widen into route/slug/class renaming.
3. Keep auth-provider labels and deeper provider-specific diagnostics out of scope unless the spec explicitly names them.
## Recommended Implementation Order
1. Update the in-scope localization catalogs and directly surfaced key family from `tenant_*` to `environment_*`.
2. Apply the new glossary to chooser and landing surfaces.
3. Apply the same glossary to dashboard headings, shared shell labels, registry return labels, and workspace widgets.
4. Neutralize the bounded provider helper text on `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing` only.
5. Run the exact proof commands below.
## Proof Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/EnvironmentContextTerminologyTest.php tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php tests/Feature/Filament/Localization/PolicyInventoryLocalizationTest.php tests/Feature/Guards/EnvironmentCopyNeutralizationGuardTest.php
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php
```
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Review Prompts
- Confirm the chooser and registry no longer say `tenant` in their primary title, CTA, or empty-state copy.
- Confirm dashboard and shell labels say `environment` while route targets remain unchanged.
- Confirm auth-provider labels such as `Sign in with Microsoft` remain unchanged.
- Confirm provider-specific helper text remains available only as secondary detail on `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing` where the provider is genuinely the subject.
## Explicit Stop Conditions
- If implementation requires route/slug/class renames, stop and split the work.
- If implementation requires RBAC capability renaming, stop and split the work.
- If implementation discovers broader provider-owned copy cleanup outside the bounded admin surfaces, record it as follow-up rather than widening `286`.

View File

@ -0,0 +1,61 @@
# Research: UI Copy, IA & Localization Neutralization
## Decision 1: `Environment` is the default operator noun
- Use `Environment` on chooser, shell, dashboard, and default action copy.
- Use `Managed environment` only where a registry or heading must distinguish the object from the workspace.
- Do not keep `tenant` as an operator-visible default noun on the in-scope post-workspace-first admin surfaces.
## Decision 2: Provider-specific names remain secondary unless the provider is the subject
- Keep auth-provider wording such as `Sign in with Microsoft` unchanged.
- Keep provider-specific detail such as Graph-specific helper text or explicit provider names only as secondary explanatory content.
- Neutralize default-visible action/helper copy on the bounded restore/capture/baseline-compare entry surfaces where the platform noun is sufficient for the first operator decision.
## Decision 3: `286` is copy and IA only, not a symbol-renaming slice
- Do not rename routes, slugs, class names, model names, capability names, or database fields.
- Internal tenant-shaped symbols may remain temporarily while operator-facing copy becomes environment-first.
- Any need for symbol renaming belongs to a later guardrail or cleanup spec, not this package.
## Decision 4: Canonical replacement beats aliasing for the in-scope key family
- Replace in-scope translation keys and directly surfaced view-model labels that still encode tenant-first operator nouns.
- Do not preserve alias keys for convenience where the key name itself remains a shared semantic contract.
- Keep the replacement bounded to the confirmed admin surfaces in this package.
## Decision 5: Stay out of adjacent specs
- Do not absorb RBAC wording from Spec `285`.
- Do not absorb provider-capability or provider-identity work from Specs `281` and `283`.
- Do not absorb no-legacy enforcement from Spec `287`.
- Do not expand into website or customer-review localization already covered elsewhere.
## Rejected Alternatives
### Rejected: value-only translation updates without key cleanup
That would keep tenant-first semantics in shared key families and test contracts, which is the same drift in a different place.
### Rejected: route or slug renaming in the same slice
That widens the package from copy/IA into deeper runtime cutover work and should be handled separately if still needed.
### Rejected: repo-wide provider-neutral string sweep
The slot is explicitly bounded. Repo-wide cleanup would collapse multiple provider-owned and customer-facing follow-up lanes into one package.
## Evidence Anchors
- `apps/platform/lang/en/localization.php` still exposes `tenant_scope`, `select_tenant`, `selected_tenant`, `no_tenant_selected`, `no_active_tenants`, `view_managed_tenants`, `workspace_wide_available`, and `tenant_title`.
- `apps/platform/app/Filament/Pages/ChooseTenant.php` still sets `Choose tenant` as the page title.
- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` still sets `Managed tenants` as the page title.
- `apps/platform/app/Support/OperateHub/OperateHubShell.php` still exposes `ManagedEnvironment scope` and `All tenants` as shell labels.
- `apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` still exposes `Back to tenant registry`.
- `apps/platform/app/Filament/Pages/TenantDashboard.php` still consumes `localization.dashboard.tenant_title`.
## Implementation Boundary Summary
- The package is implementation-ready for bounded operator-copy convergence.
- It is not a prerequisite-unblocking package for routes, RBAC, or provider capabilities.
- If implementation uncovers a genuine need for route or symbol renaming, stop and split that work into a follow-up spec.

View File

@ -0,0 +1,359 @@
# Feature Specification: UI Copy, IA & Localization Neutralization
**Feature Branch**: `286-ui-copy-ia-localization-neutralization`
**Created**: 2026-05-09
**Status**: Ready
**Input**: User description: "Follow instructions in #prompt:SKILL.md with these arguments: mach 286 als nächstes"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: The repo already exposes workspace-first and environment-shaped runtime seams, including `/admin/workspaces/{workspace}/environments/{environment}` routes, environment-backed dashboards, and managed-environment domain models, but operator-facing copy still teaches the older tenant-first and Microsoft-shaped mental model. Shared shell labels, chooser titles, dashboard headings, navigation affordances, widget labels, and helper text still say `tenant`, `Managed tenants`, `All tenants`, or `Restore to Microsoft Intune` even where the surrounding workflow is already workspace-first and provider-neutral.
- **Today's failure**: Operators can be routed into environment-scoped admin surfaces while the UI still tells them they are choosing or switching a tenant. That creates cross-surface contradiction, makes the cutover look incomplete, and silently re-teaches provider-owned nouns as if they were platform-core truth.
- **User-visible improvement**: Operators choose, switch, and inspect environments with one consistent environment-first vocabulary in English and German. Provider-specific terms remain visible only where the underlying provider is the subject of the action or the supporting detail, not as the default noun for the surrounding workflow.
- **Smallest enterprise-capable version**: Neutralize operator-facing copy on the confirmed post-workspace-first admin surfaces only: shared shell labels, environment chooser and landing pages, environment dashboard headings, context chips, registry back links, workspace widgets that show current environment context, and the concrete provider-neutral helper texts on `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing`. Reuse the current localization and Filament surface structure. Do not rename routes, page slugs, PHP classes, Eloquent models, capabilities, or database columns.
- **Explicit non-goals**: No route or slug rename, no PHP namespace or class rename, no RBAC or capability vocabulary rewrite from Spec `285`, no provider-capability or provider-identity work from Specs `281` and `283`, no artifact-source work from Spec `284`, no no-legacy enforcement pack from Spec `287`, no website localization, no customer-review localization expansion beyond the existing customer-facing package in Spec `275`, no auth-provider copy change such as `Sign in with Microsoft`, and no new translation framework or storage.
- **Permanent complexity imported**: One bounded operator glossary decision, one renamed localization-key family for environment-shell terms, small local template/test-id cleanup on the enumerated surfaces only, and focused feature/guard/browser proof. No new model, table, state family, or shared infrastructure is introduced.
- **Why now**: The roadmap explicitly reserves `286` inside the workspace-first cutover pack so the product does not ship workspace-first routing and managed-environment foundations with tenant-first or Microsoft-first UI language. Without this slice, the cutover remains semantically incomplete even when route and policy work are done.
- **Why not local**: The drift is shared. The same wrong nouns live in localization catalogs, page titles, shared shell helpers, widget payloads, navigation context, and selected helper text. A page-local copy patch would only move the contradiction around.
- **Approval class**: Core Enterprise
- **Red flags triggered**: multiple surfaces, terminology cleanup theme, and localization-key renaming. Defense: the slice is tightly bounded to already-real post-cutover operator flows, changes no runtime truth or schema, and directly prevents false product statements about the platform's core nouns.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
- **Decision**: approve
## Review Outcome
- **Outcome class**: acceptable-special-case
- **Workflow outcome**: keep
- **Test-governance outcome**: keep
- **Reason**: This package is a bounded vocabulary and IA convergence slice over repo-real workspace-first seams. It changes operator-facing language and disclosure only, keeps provider detail bounded, and explicitly blocks route, RBAC, schema, and framework expansion.
- **Workflow result**: Ready for implementation.
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- existing admin-plane environment chooser at `/admin/choose-tenant`
- existing workspace-scoped managed-environment landing surface at `/admin/workspaces/{workspace}/managed-tenants`
- existing environment dashboard route at `/admin/workspaces/{workspace}/environments/{environment}`
- existing shared shell and navigation affordances rendered on environment-scoped admin pages through `OperateHubShell` and `CanonicalNavigationContext`
- existing workspace widgets and the concrete policy-detail and baseline-compare helper surfaces at `apps/platform/app/Filament/Resources/PolicyResource.php`, `apps/platform/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php`, `apps/platform/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`
- **Data Ownership**: No new persisted truth is introduced. Existing workspace, managed-environment, operation, backup, and compare records remain authoritative. This slice only changes derived localization catalogs, page titles, surface-local display labels, widget copy, and helper text on the enumerated surfaces.
- **RBAC**:
- workspace membership remains the first isolation boundary
- managed-environment entitlement remains the second isolation boundary where the current surface requires it
- capability denials remain unchanged and keep current `403` behavior after entitlement is established
- this slice must not widen access, soften `404` boundaries, or change the current auth-plane separation
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When a managed environment is active, the shell and page copy must describe that state as the selected environment. The feature must not reset or reinterpret the current workspace or environment context.
- **Explicit entitlement checks preventing cross-tenant leakage**: Copy changes must not expose inaccessible workspaces or managed environments. Non-members and out-of-scope actors keep inherited `404` behavior; the new wording does not create new discoverability hints.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: navigation entry points, page titles, shell scope labels, context chips, action labels, empty-state copy, and provider-neutral helper text
- **Systems touched**: `apps/platform/lang/en/localization.php`, `apps/platform/lang/de/localization.php`, `apps/platform/lang/en/baseline-compare.php`, `apps/platform/lang/de/baseline-compare.php`, `ChooseTenant`, `ManagedTenantsLanding`, `TenantDashboard`, `OperateHubShell`, `CanonicalNavigationContext`, dashboard/workspace widget views, `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing`
- **Existing pattern(s) to extend**: current locale resolution and translation catalogs, current workspace-context shell, current Filament page-title pattern, current dashboard heading pattern, and the existing provider-detail disclosure pattern where provider-owned terms are already shown as secondary context
- **Shared contract / presenter / builder / renderer to reuse**: `localization.shell.*`, `localization.dashboard.*`, `WorkspaceContext`, `OperateHubShell`, `CanonicalNavigationContext`, existing widget view-models, and the current Filament page/view translation usage
- **Why the existing shared path is sufficient or insufficient**: the infrastructure already exists. The gap is that those shared paths still carry the wrong nouns. `286` should converge them, not introduce a second glossary or page-local override system.
- **Allowed deviation and why**: none. Hardcoded page-local neutralization or alias-only fallback keys would extend the drift instead of resolving it.
- **Consistency impact**: environment chooser labels, managed-environment landing labels, dashboard headings, shell scope labels, registry return links, workspace widget context labels, and selected provider-neutral helper text must all use the same primary nouns in both supported locales.
- **Review focus**: reviewers must block any new tenant-first copy on in-scope operator surfaces, any new default-visible Microsoft-owned noun where the platform noun is sufficient, and any auth-provider label being neutralized even though the provider is genuinely the subject.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
N/A - this slice may rename environment context labels inside existing widgets or operation summaries, but it does not change `OperationRun` start, completion, deep-link, or notification semantics.
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**: platform-core operator nouns for managed environments and workspace-scoped shell context; default helper text for provider actions on restore/capture or baseline-compare entry surfaces; nested provider detail wording on those same surfaces
- **Neutral platform terms preserved or introduced**: `workspace`, `environment`, `managed environment` for registry disambiguation only, `provider`, `environment scope`, `selected environment`, `all environments`
- **Provider-specific semantics retained and why**: `Sign in with Microsoft`, `Microsoft not configured`, explicit provider names in secondary detail, and provider-owned diagnostic copy such as Graph-specific explanations remain intact because the provider itself is the subject in those contexts.
- **Why this does not deepen provider coupling accidentally**: the slice moves default-visible copy away from Microsoft-specific nouns and keeps provider labels only where the provider genuinely owns the action or the evidence.
- **Follow-up path**: broader no-legacy enforcement belongs to Spec `287`; broader provider-specific surface cleanup or auth-provider wording changes belong to later provider-owned work.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Environment chooser page | yes | Native Filament page + existing Blade view | chooser labels, empty state, primary action text | page, view, localization key | no | current slug and class name remain unchanged |
| Managed environments landing page | yes | Native Filament page + existing Blade view | landing title, empty state, selection CTA, supporting text | page, view, localization key | no | registry purpose stays the same; nouns change only |
| Environment dashboard heading and context chips | yes | Mixed native Filament page + existing widget views | dashboard title, context chip labels, current scope disclosure | page, widget view, localization key | no | no new widget family or card pattern |
| Shared operate-hub shell and navigation return affordances | yes | Native Filament header action + shared navigation helper | scope label, return label, registry back-link wording | shell, header action, navigation helper | no | no change to route targets or action hierarchy |
| Policy detail capture/restore helper text and baseline-compare RBAC summary heading | yes | Native Filament actions + existing Blade section | default action/helper text, secondary provider detail, summary heading | modal copy, helper text, section heading, localization key | no | provider-owned detail remains nested and explicit |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Environment chooser page | Primary Decision Surface | Decide which environment to enter after workspace selection | `Choose environment`, active-availability wording, one selection CTA | deeper lifecycle or operability detail remains secondary | Primary because this is the first place the operator commits to an environment context | follows workspace-first entry workflow | removes mental translation from route/context into UI |
| Managed environments landing page | Primary Decision Surface | Decide which managed environment to inspect or administer | registry title, current workspace context, environment labels | deeper lifecycle or inactive detail remains lower priority | Primary because it is the registry surface for this context | matches post-workspace browsing flow | removes duplicate tenant terminology from the environment registry |
| Environment dashboard heading and context chips | Secondary Context Surface | Confirm the currently selected environment before acting | environment title, current posture pill, selected environment chip | deeper diagnostic detail remains on the page body | Secondary because the decision to enter the environment has already happened | supports orientation inside environment pages | avoids relearning tenant-first nouns after selection |
| Shared operate-hub shell and navigation return affordances | Secondary Context Surface | Confirm scope and navigate back to the correct registry | environment scope label, return affordance | raw route or nav payload remains hidden | Secondary because it orients an existing workflow rather than starting one | keeps context coherent across shared admin pages | removes noisy tenant-vs-environment translation work |
| Policy detail capture/restore helper text and baseline-compare RBAC summary heading | Tertiary Evidence / Diagnostics | Confirm what action targets the environment versus the provider | neutral primary action label and brief helper copy, plus one provider-neutral summary heading | provider-specific detail is disclosed secondarily | Tertiary because it supports a flow that already has a primary page context | follows current admin action flow | prevents provider name from competing with the actual operator decision |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Environment chooser page | operator-MSP, support-platform | choose environment title, selected-workspace context, one selection action | lifecycle or operability explanations | raw environment IDs or debug payloads | `Choose environment` | IDs and debug semantics remain hidden | default path states the scope once and does not restate it in multiple cards |
| Managed environments landing page | operator-MSP, support-platform | managed environments title, available environment rows, current workspace context | archived or unavailable reasons | raw metadata | `Open environment` | raw metadata remains secondary | registry title and row labels use one noun family |
| Environment dashboard heading and context chips | operator-MSP, support-platform | environment title, posture pill, selected environment chip | page-body diagnostics and operation details | raw payloads | `Open` actions stay on the page body, not in the heading | low-level diagnostics stay outside the heading | heading owns only orientation, not duplicate status narratives |
| Shared operate-hub shell and navigation return affordances | operator-MSP, support-platform | environment scope and one return label | route context or nav payloads | raw nav payloads | `Back to environment registry` or `Back to <environment>` | raw nav payloads remain hidden | shell labels do not reintroduce `tenant` beside environment copy |
| Policy detail capture/restore helper text and baseline-compare RBAC summary heading | operator-MSP, support-platform | neutral action label, concise target explanation, and provider-neutral section heading | provider-specific constraints, provider API notes | raw provider payloads | action label remains dominant | provider-owned details stay helper or secondary text | default action does not repeat provider detail as the headline |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Environment chooser page | Navigation / Drilldown / Context | Global-context Shell | choose an environment | explicit chooser action per row or card | forbidden | none beyond secondary helper copy | none | `/admin/choose-tenant` | `/admin/workspaces/{workspace}/environments/{environment}` | workspace context, availability | Environment | selected scope and availability | slug remains tenant-named, operator copy does not |
| Managed environments landing page | Navigation / Drilldown / Registry | Read-only registry chooser | open an environment | explicit open action on row/card | allowed if already the current pattern | secondary lifecycle detail stays below title | none | `/admin/workspaces/{workspace}/managed-tenants` | `/admin/workspaces/{workspace}/environments/{environment}` | workspace, environment lifecycle | Managed environment | registry purpose and available environments | registry route remains tenant-named, copy does not |
| Environment dashboard heading and context chips | Record / Detail / Overview | Overview-first page | confirm current environment context | page heading plus chips | n/a | page-body actions remain below heading | none | `/admin/workspaces/{workspace}/environments/{environment}` | same page | workspace, environment, posture | Environment dashboard | selected environment and posture | none |
| Shared operate-hub shell and navigation return affordances | Navigation / Drilldown / Context | Global-context Shell | return to the right registry or environment | explicit header action labels | n/a | secondary nav stays contextual | none | inherited current page route | inherited current page route | environment scope, return target | Environment scope | current scope and return target | none |
| Policy detail capture/restore helper text and baseline-compare RBAC summary heading | Record / Detail / Action | Action-supporting detail | confirm target before executing or scanning the compare summary | current action or section heading | n/a | provider detail stays helper text or secondary section | existing destructive-like actions remain unchanged | inherited current page route | inherited current page route | target environment, provider detail | Environment | default target and effect | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Environment chooser page | Workspace operator | choose which environment to enter | chooser page | Which environment should I operate in now? | workspace context, available environments, one clear chooser label | deeper lifecycle or operability detail | availability, lifecycle | none | choose environment | none |
| Managed environments landing page | Workspace operator | open the correct managed environment | registry page | Which managed environment belongs to this workspace and is available? | workspace title, environment registry, availability cues | archived or unavailable explanations | lifecycle, availability | none | open environment | none |
| Environment dashboard heading and context chips | Environment operator | confirm the selected environment before using page actions | overview heading | Am I in the right environment? | environment name, posture pill, context chips | lower page diagnostics | posture, scope | none | inherited page actions only | none |
| Shared operate-hub shell and navigation return affordances | Environment operator | recover orientation and navigate back safely | shared shell helper | What scope am I in and where do I go back? | environment scope label and return label | raw route or nav details | scope, context | none | return affordance only | none |
| Policy detail capture/restore helper text and baseline-compare RBAC summary heading | Environment operator | confirm target and wording before execution or interpretation | action helper text / section heading | Does this action or summary target the environment or the provider detail? | neutral action label, concise target explanation, and provider-neutral summary heading | provider-specific detail and diagnostic notes | target, provider context | inherited existing action scope | inherited existing action | inherited existing dangerous actions |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: the product already routes and scopes around environments, but the default-visible copy still teaches tenant-first and Microsoft-first operator truth.
- **Existing structure is insufficient because**: existing localization catalogs and shared helpers are exactly where the wrong nouns currently live.
- **Narrowest correct implementation**: rename the in-scope operator glossary and only the local display labels/test IDs on the enumerated surfaces, keep provider-specific nouns nested where they already belong, and leave shared payload contracts, routes, models, and RBAC unchanged.
- **Ownership cost**: updating translation keys, touching a small number of shared surfaces, and keeping guard coverage for the environment-first glossary.
- **Alternative intentionally rejected**: one-off value-only copy changes without key or view-model cleanup were rejected because they preserve the same semantic drift in shared helpers and test contracts.
- **Release truth**: current-release cutover follow-through, not future-release preparation.
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, translation-key aliases, route aliases, slug migration, and compatibility-specific tests are out of scope unless a later guardrail spec explicitly requires them.
Canonical replacement of the in-scope operator glossary is preferred over preserving tenant-first key families for convenience.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: confidence, browser
- **Why this classification and these lanes are sufficient**: the slice changes rendered operator-facing copy, page titles, shared shell labels, and helper text on existing admin surfaces. Focused feature coverage plus one bounded browser smoke are the narrowest honest proof. No new heavy-governance family is justified.
- **New or expanded test families**: one localization feature family for environment terminology, one Filament surface-copy feature family for shared admin surfaces, one guard family for forbidden default-visible strings on in-scope files, and one browser smoke for the chooser-to-environment flow
- **Fixture / helper cost impact**: low to moderate because proof needs workspace context, at least one managed environment, and the existing admin surfaces only
- **Heavy-family visibility / justification**: one browser smoke only; no new heavy-governance coverage
- **Special surface test profile**: standard-native-filament, global-context-shell
- **Standard-native relief or required special coverage**: ordinary Filament feature coverage is sufficient for page titles and translation output; the shared shell and chooser flow need one explicit global-context-shell smoke
- **Reviewer handoff**: reviewers must verify that no in-scope operator surface still uses `tenant` or default-visible `Microsoft` nouns where a platform noun is sufficient, that auth-provider labels remain provider-owned where appropriate, that route targets remain unchanged, that Filament stays v5 on Livewire v4, and that provider registration remains in `apps/platform/bootstrap/providers.php`
- **Budget / baseline / trend impact**: contained feature-local increase only
- **Escalation needed**: `follow-up-spec` if implementation uncovers route-slug renames, RBAC capability renames, or broader provider-surface cleanup beyond the bounded glossary slice
- **Active feature PR close-out entry**: Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/EnvironmentContextTerminologyTest.php tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php tests/Feature/Filament/Localization/PolicyInventoryLocalizationTest.php tests/Feature/Guards/EnvironmentCopyNeutralizationGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Scope Boundaries
### In Scope
- environment-first terminology on the confirmed shared shell labels, chooser and landing titles, dashboard titles, context chips, registry return labels, workspace widgets, and selected helper copy
- English and German localization catalog updates for the in-scope operator glossary
- canonically replacing the in-scope translation-key family and only local display labels/test IDs owned by the enumerated surfaces; shared cross-surface payload contracts outside those surfaces remain unchanged
- provider-neutral default action/helper text on the exact PolicyResource sync/capture surfaces, the policy versions restore action, and the baseline-compare landing RBAC summary heading where the provider is supporting context rather than the primary noun
- focused guard and browser coverage that pins the environment-first glossary on the in-scope admin surfaces
### Non-Goals
- route, slug, or PHP class renaming
- RBAC or capability vocabulary changes
- provider-identity or provider-capability rewrites
- artifact-source or report-surface taxonomy work
- website localization or customer-review localization beyond Spec `275`
- auth-provider wording such as `Sign in with Microsoft`
- new localization infrastructure, translation storage, or asset changes
## Candidate Selection Rationale
- **Selected candidate**: `286 - UI Copy, IA & Localization Neutralization`
- **Source locations**:
- `docs/product/roadmap.md` under the workspace-first managed-environment core cutover pack
- explicit user-directed manual promotion for reserved slot `286`
- **Why selected**: the user explicitly requested the next reserved slot `286`, the slot is present in the roadmap, and repo truth confirms the smallest remaining gap is no longer route or policy plumbing but the operator-facing language that still teaches the retired tenant-first model.
- **Why close alternatives were deferred**:
- Spec `287` remains the guardrail and no-legacy enforcement pack and should not be absorbed into this bounded copy slice
- broader customer-facing localization remains owned by Spec `275`
- route or slug renames remain outside this bounded neutralization slice
- broader provider-surface cleanup remains separate from the shared operator glossary cutover
- **Smallest viable implementation slice**: replace in-scope tenant-first and default-visible Microsoft-first copy on the confirmed workspace-first admin surfaces, canonically replace the in-scope translation-key family, update only local display labels/test IDs owned by those surfaces, and keep shared payload contracts, routes, models, RBAC, and provider-owned detail unchanged.
- **Documented deviations from raw candidate wording**:
- current repo truth already exposes environment-shaped route and dashboard seams, so `286` is not a greenfield IA redesign
- auth-provider labels remain explicitly provider-owned and are intentionally not neutralized in this slice
- internal class and route symbols may remain tenant-named while operator-facing copy becomes environment-first
## Completed-Spec Guardrail Result
- `specs/279-workspace-managed-environment-core/` contains implementation-close-out history and remains historical prerequisite context only.
- `specs/280-workspace-tenancy-environment-routing/` is `Status: Ready` and remains adjacent prepared context only.
- `specs/281-provider-connection-scope/` is `Status: Ready` and remains adjacent prepared context only.
- `specs/282-governance-artifact-retargeting/` is `Status: Prepared - blocked by Spec 280 runtime prerequisite` and remains adjacent prepared context only.
- `specs/283-provider-capability-registry/` is `Status: Ready` and remains adjacent prepared context only.
- `specs/285-workspace-rbac-environment-access/` currently has a spec-level status of `Blocked by external prerequisites` while its tasks artifact already carries a review outcome of `implemented-and-validated`; `286` treats that split as adjacent context only and remains independent as long as it does not absorb any RBAC renaming.
- `specs/275-customer-facing-localization-adoption/` remains related localization context only for glossary discipline and does not get refreshed here.
- The target package `specs/286-ui-copy-ia-localization-neutralization/` did not exist before this prep run and is the sole new package created here.
## Dependencies
- current implementation branches should already contain the workspace-first route and environment dashboard seams prepared by Spec `280`
- current provider-boundary seams from Spec `281` and provider-capability seams from Spec `283` must remain intact so `286` can keep provider-owned detail secondary instead of redefining it
- any runtime branch used for later implementation must keep RBAC terminology bounded to Spec `285`; `286` does not require `285` close-out to be rewritten or fully normalized, but it must not absorb role or capability renaming
## Assumptions
- `Environment` is the primary operator noun for chooser, shell, dashboard, and default action copy.
- `Managed environment` may remain on registry or disambiguation surfaces where it clarifies the object against a workspace.
- Provider names remain visible only when the provider itself is the subject, such as auth-provider actions or explicit secondary detail.
- Internal PHP classes, route names, slugs, and database columns may remain tenant-shaped in this slice.
- English and German remain the only supported locales in scope.
## Risks
- Partial glossary cleanup can leave environment-first and tenant-first nouns side by side if the shared shell or widget files are not all updated together.
- Neutralizing provider helper text too aggressively can hide when Microsoft is genuinely the acting system on an action surface.
- Renaming translation keys without updating all in-scope call sites can break copy rendering or produce raw keys.
- Scope pressure can try to turn the slice into route renaming, RBAC renaming, or broader provider-surface cleanup.
## Follow-Up Candidates Explicitly Kept Out of Scope
- `287 - Cutover Quality Gates & No-Legacy Enforcement`
- broader provider-owned surface cleanup beyond the in-scope helper text and headings
- route-slug or PHP class renaming for tenant-shaped symbols
- website localization and customer-review localization beyond the current `275` package
- broader RBAC capability or role copy convergence beyond the bounded environment-first glossary
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Choose an environment with environment-first language (Priority: P1)
As a workspace operator, I want the chooser and registry surfaces to tell me I am selecting an environment so the UI matches the workspace-first route and object model I am already using.
**Why this priority**: This is the first operator-facing moment where the cutover is visible, and it is where the current terminology drift is most confusing.
**Independent Test**: Open the chooser and managed-environment landing pages for a workspace, then verify the titles, empty states, selection actions, and supporting text use environment-first language in English and German while the underlying routes stay unchanged.
**Acceptance Scenarios**:
1. **Given** a workspace has selectable managed environments, **When** the operator opens the chooser or landing page, **Then** the page says `Choose environment` or `Managed environments` instead of `Choose tenant` or `Managed tenants`.
2. **Given** a workspace has no active selectable managed environments, **When** the operator opens the chooser, **Then** the empty-state wording says no active environments are available and does not mention tenants.
---
### User Story 2 - Stay oriented inside an environment-scoped admin surface (Priority: P1)
As an environment operator, I want the dashboard heading, context chips, and shared shell labels to confirm that I am inside an environment scope so I do not have to reinterpret tenant-first labels on every page.
**Why this priority**: The cutover fails if the operator gets environment-shaped routes but tenant-shaped shell and heading copy.
**Independent Test**: Open an environment dashboard and a shared operate-hub page, then verify the heading, context chip, scope label, and registry return affordance all use the same environment-first glossary.
**Acceptance Scenarios**:
1. **Given** an entitled managed environment is active, **When** the operator opens the dashboard, **Then** the title and context chip speak about the selected environment, not the selected tenant.
2. **Given** an operate-hub page is rendered inside an active environment context, **When** the operator reads the shell labels, **Then** the shell says `Environment scope` or `All environments` and the return link points back to the environment registry without tenant-first wording.
---
### User Story 3 - Read provider actions with neutral default copy and explicit provider detail (Priority: P2)
As an environment operator, I want restore/capture or baseline-compare helper text to use a neutral default noun so the product explains the target environment clearly while still preserving provider detail where it genuinely matters.
**Why this priority**: Default-visible helper copy currently lets Microsoft-specific nouns dominate actions that are otherwise framed as environment-scoped admin workflows.
**Independent Test**: Open `PolicyResource` sync/capture surfaces, the policy versions restore action, and the baseline-compare landing RBAC summary section, then verify the default-visible action/helper text or section heading uses provider-neutral wording while provider-specific detail remains secondary and explicit.
**Acceptance Scenarios**:
1. **Given** the `VersionsRelationManager` restore action or `ViewPolicy` capture snapshot modal is shown on an environment-scoped policy surface, **When** the operator reads the action and helper text, **Then** the default headline speaks about the environment or provider action neutrally rather than `Restore to Microsoft Intune`.
2. **Given** `baseline-compare-landing` shows the RBAC summary section or a provider-specific note is required on the policy surfaces, **When** the operator opens the same surface, **Then** the heading or helper text remains provider-neutral by default and the provider name stays available only as secondary detail.
### Edge Cases
- A workspace has no active selectable managed environments and the shell must still avoid tenant-first nouns.
- The underlying route or class remains `choose-tenant` or `TenantDashboard`, but operator-facing copy must still be environment-first.
- A provider-owned auth action such as `Sign in with Microsoft` must remain provider-specific and must not be neutralized accidentally.
- A helper string needs both a neutral primary noun and a provider-specific secondary explanation without sounding contradictory.
- A widget or view still passes `tenant_label` in a view-model contract even though the operator-facing label must become `environment_label`.
## Requirements
**Constitution alignment (required):** `286` changes operator-facing vocabulary and shared surface disclosure only. It introduces no Graph calls, no write/change workflow, no queue/background work, and no new persisted truth.
**Constitution alignment (PROP-001 / ABSTR-001 / PROV-001):** This package reuses existing localization and shell infrastructure, keeps provider-specific semantics out of platform-core default-visible copy, and forbids a broader IA or translation framework.
**Constitution alignment (XCUT-001 / UI-FIL-001 / DECIDE-001):** The slice reuses native Filament pages and current widget shells, keeps one dominant chooser or navigation action per surface, and does not invent a second glossary or a custom design system.
### Functional Requirements
- **FR-001**: In-scope chooser and registry surfaces MUST use `environment` or `managed environment` as the primary operator noun instead of `tenant`.
- **FR-002**: Shared shell labels MUST use `Environment scope`, `All environments`, and equivalent environment-first terms where the active managed-environment context is the primary truth.
- **FR-003**: Environment dashboard headings and context chips MUST describe the active scope as an environment.
- **FR-004**: Registry return links and related navigation affordances MUST point to the same routes they do today while using environment-first wording.
- **FR-005**: English and German localization catalogs MUST expose the same in-scope environment-first glossary.
- **FR-006**: The in-scope localization-key family and only the local display labels/test IDs owned by the enumerated surfaces that still encode tenant-first operator nouns MUST be renamed or replaced canonically, not aliased.
- **FR-007**: Provider-neutral action/helper copy MUST become the default-visible wording on the exact `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing` surfaces named in this spec.
- **FR-008**: Provider-specific names MUST remain available only where the provider is genuinely the subject of the auth flow or the secondary explanatory detail.
- **FR-009**: Route targets, slugs, PHP class names, database columns, model names, and capability names MUST remain unchanged in this slice.
- **FR-010**: This slice MUST NOT change RBAC outcomes, route access, or `404` versus `403` semantics.
- **FR-011**: The implementation MUST NOT introduce a new localization framework, storage, asset pipeline change, or panel configuration change.
- **FR-012**: The implementation MUST include focused feature/guard coverage for the in-scope environment-first glossary and one browser smoke for the chooser-to-environment flow.
### UX / IA Requirements
- **UX-001**: `Environment` is the default operator noun on chooser, shell, dashboard, and default action copy.
- **UX-002**: `Managed environment` is reserved for registry/disambiguation surfaces where the object needs to be distinguished from the workspace.
- **UX-003**: The chooser and landing surfaces keep one dominant selection action and must not duplicate the same scope summary across multiple visible regions.
- **UX-004**: Dashboard headings and shell labels stay orientation-first and must not compete with page-body actions.
- **UX-005**: Provider-specific terms stay secondary where a platform noun is sufficient for the first operator decision.
### Localization Requirements
- **L10N-001**: English and German keys for the in-scope shell and dashboard glossary remain parity-matched.
- **L10N-002**: Raw translation keys must not leak on in-scope surfaces after the key-family neutralization.
- **L10N-003**: Auth-provider copy such as `Sign in with Microsoft` remains out of scope and unchanged.
### Non-Functional Requirements
- **NFR-001**: The slice remains implementation-bounded to current admin surfaces and avoids broader website or customer-review localization.
- **NFR-002**: Guard coverage must stay limited to the in-scope files and must not become a repo-wide blanket string ban.
- **NFR-003**: No deploy or asset changes are required beyond the existing platform flow.
## Acceptance Criteria
- The chooser and managed-environment landing pages no longer show tenant-first nouns in their primary titles, CTAs, or empty-state copy.
- Environment dashboard headings, context chips, and shared shell labels use environment-first wording consistently.
- In-scope provider helper text uses neutral default copy while retaining provider-specific secondary detail where needed.
- English and German translations are aligned for the in-scope glossary and no raw translation keys appear.
- Routes, slugs, classes, RBAC behavior, and provider registration remain unchanged.
## Success Criteria
- Operators can move from workspace selection into an environment-scoped page without encountering tenant-first copy on the in-scope surfaces.
- The platform no longer teaches `tenant` or default-visible `Microsoft` nouns as the first explanation of an environment-scoped admin workflow.
- The bounded test and browser proof succeeds without widening into adjacent specs.
## Open Questions
- None. This package resolves the primary noun choice as `Environment`, keeps `Managed environment` for registry disambiguation only, and keeps auth-provider labels explicitly out of scope.

View File

@ -0,0 +1,212 @@
---
description: "Task list for UI Copy, IA & Localization Neutralization"
---
# Tasks: UI Copy, IA & Localization Neutralization
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/contracts/ui-copy-ia-localization-neutralization.logical.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/quickstart.md`
**Review Artifact**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/checklists/requirements.md` is the outcome-of-record for the review outcome class, workflow outcome, and test-governance outcome. If implementation widens into route/slug/class renaming, auth-provider wording changes, RBAC vocabulary changes, or broader provider-surface cleanup, update that artifact before continuing.
**Tests**: Required (Pest) for runtime behavior changes. Keep proof in the existing `confidence` lane plus one bounded `browser` smoke only because this slice changes rendered operator-facing copy, shared shell labels, chooser semantics, and helper-text disclosure on existing admin surfaces.
**Operations**: No new `OperationRun`, queue, remote call, or background workflow is introduced.
**RBAC**: Workspace and managed-environment entitlement behavior remains unchanged. Reuse existing policy and capability seams; do not add raw capability strings or role-name checks.
**Shared Pattern Reuse**: Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/baseline-compare.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/baseline-compare.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/ChooseTenant.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/TenantDashboard.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/OperateHub/OperateHubShell.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/PolicyResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php`. Do not add a new localization framework, alias-only translation layer, route/slug rename, or provider-cleanup sweep.
**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains unchanged in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/bootstrap/providers.php`. No new globally-searchable resource, no new panel, and no asset-strategy change are allowed in this slice.
**Organization**: Tasks are grouped by user story so chooser/registry copy, dashboard/shell copy, and provider-neutral helper copy remain independently testable after the shared glossary is settled.
**Review Outcome**: `acceptable-special-case`
**Workflow Outcome**: `keep`
**Test-governance Outcome**: `keep`
## Test Governance Checklist
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [x] New or changed tests stay in the smallest honest family, and any browser addition remains explicit and bounded.
- [x] Shared helpers, widgets, translation catalogs, and environment context defaults stay cheap by default.
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
- [x] Surface test profile stays explicit: `standard-native-filament` for page-title and localization output, `global-context-shell` for shell labels and chooser flow.
- [x] Dominant CTA, diagnostics-second ordering, secondary provider detail, and no duplicate visible scope summary are verified explicitly on the chooser, landing, dashboard, shell, and pinned US3 helper surfaces.
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active feature package or PR close-out.
## Phase 1: Setup (Shared Context)
**Purpose**: Lock the bounded glossary slice, explicit non-goals, and proving lanes before runtime edits begin.
- [x] T001 Review the bounded slice, explicit non-goals, completed-spec guardrails, and repo-fit outcome in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/checklists/requirements.md`
- [x] T002 [P] Review the glossary decisions, key-family replacement rules, and provider-detail boundary in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/contracts/ui-copy-ia-localization-neutralization.logical.openapi.yaml`
- [x] T003 [P] Confirm the focused Sail/Pest validation commands and bounded browser smoke in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/quickstart.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Guards/`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Settle the shared environment-first glossary and bounded provider-detail rule before user-story implementation starts.
**Critical**: No user-story work should begin until this phase is complete.
- [x] T004 [P] Inventory the in-scope tenant-first and default-visible provider-first strings across `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/baseline-compare.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/baseline-compare.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/ChooseTenant.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/TenantDashboard.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/OperateHub/OperateHubShell.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php`, and the in-scope widget views so later story work reuses one glossary only
- [x] T005 [P] Add or extend bounded guard coverage in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Guards/EnvironmentCopyNeutralizationGuardTest.php` for in-scope default-visible strings such as `tenant scope`, `select tenant`, `all tenants`, `managed tenants`, and `restore to Microsoft Intune`, while explicitly excluding auth-provider copy like `Sign in with Microsoft`
- [x] T006 Canonically replace the in-scope translation-key family and only the local display labels/test IDs owned by the enumerated surfaces with environment-first names in the smallest shared seams first, without adding alias keys or broad repo-wide grep bans
**Checkpoint**: The glossary inventory, guard boundaries, and canonical replacement rules are fixed before surface-specific story work begins.
---
## Phase 3: User Story 1 - Choose an environment with environment-first language (Priority: P1)
**Goal**: Let workspace operators choose and open environments from chooser/registry surfaces whose copy matches the current workspace-first route model.
**Independent Test**: Open the chooser and managed-environment landing pages and verify the titles, empty states, CTAs, and supporting text use environment-first wording in English and German while the routes stay unchanged.
### Tests for User Story 1
- [x] T007 [P] [US1] Add or extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Localization/EnvironmentContextTerminologyTest.php` for chooser and landing titles, empty states, search placeholders, and selection actions in English and German
### Implementation for User Story 1
- [x] T008 [US1] Replace the in-scope chooser and landing glossary keys in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php` so `tenant_*` admin-shell terms become `environment_*` equivalents
- [x] T009 [US1] Apply the new glossary to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/ChooseTenant.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/choose-tenant.blade.php` so the chooser title, CTA, empty state, and search placeholder become environment-first without changing the route slug
- [x] T010 [US1] Apply the same glossary to `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantsLanding.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/workspaces/managed-tenants-landing.blade.php` so the registry title and opening affordances say `Managed environments` instead of `Managed tenants`
**Checkpoint**: User Story 1 is independently functional when chooser and registry surfaces no longer teach tenant-first admin nouns.
---
## Phase 4: User Story 2 - Stay oriented inside environment-scoped admin surfaces (Priority: P1)
**Goal**: Let operators confirm the active environment through consistent dashboard, shell, and navigation labels once they enter an environment-scoped page.
**Independent Test**: Open an environment dashboard and one shared operate-hub page, then verify the dashboard heading, context chips, shell scope label, and return affordance all use the same environment-first glossary.
### Tests for User Story 2
- [x] T011 [P] [US2] Add or extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php` for dashboard heading keys, shell scope labels, registry return links, and workspace widget environment labels
### Implementation for User Story 2
- [x] T012 [US2] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/TenantDashboard.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/widgets/dashboard/tenant-dashboard-context-chips.blade.php` so headings and chips speak about the selected environment
- [x] T013 [US2] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/OperateHub/OperateHubShell.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Navigation/CanonicalNavigationContext.php` so shell scope labels and registry back links become environment-first while route targets stay unchanged
- [x] T014 [US2] Update `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/widgets/workspace/workspace-needs-attention.blade.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php` so directly rendered environment labels become environment-first without widening shared workspace overview payload contracts
**Checkpoint**: User Story 2 is independently functional when the shell and dashboard no longer make operators reinterpret environment routes as tenant routes.
---
## Phase 5: User Story 3 - Read provider actions with neutral default copy and explicit provider detail (Priority: P2)
**Goal**: Let operators read a neutral environment-first default action/helper path on the bounded restore/capture/baseline-compare surfaces while still preserving provider-specific secondary detail.
**Independent Test**: Open one in-scope restore/capture or baseline-compare entry surface and verify the default-visible headline/helper text is neutralized while provider-specific secondary detail remains explicit and auth-provider wording stays unchanged.
### Tests for User Story 3
- [x] T015 [P] [US3] Extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Filament/Localization/PolicyInventoryLocalizationTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Guards/EnvironmentCopyNeutralizationGuardTest.php` for the exact `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing` helper surfaces, plus the explicit exclusion of auth-provider labels
- [x] T016 [P] [US3] Add or extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php` for the chooser-to-environment path plus the pinned policy-detail/helper and baseline-compare summary surfaces
### Implementation for User Story 3
- [x] T017 [US3] Update the bounded provider-neutral helper strings in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/localization.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/en/baseline-compare.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/lang/de/baseline-compare.php` so `PolicyResource`, `ViewPolicy`, `VersionsRelationManager`, and `baseline-compare-landing` stop using provider-first nouns as their default-visible headings or helper text where a platform noun is sufficient
- [x] T018 [US3] Reuse `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/PolicyResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/baseline-compare-landing.blade.php` so provider detail stays secondary and no auth-provider wording is changed
**Checkpoint**: User Story 3 is independently functional when provider-neutral helper text no longer dominates the first operator decision on the bounded action surfaces.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: Run the canonical proof commands, format touched files, and keep any discovered spillover explicit instead of absorbing it into this slice.
- [x] T019 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Localization/EnvironmentContextTerminologyTest.php tests/Feature/Filament/EnvironmentContextSurfaceCopyTest.php tests/Feature/Filament/Localization/PolicyInventoryLocalizationTest.php tests/Feature/Guards/EnvironmentCopyNeutralizationGuardTest.php` exactly as recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/quickstart.md`
- [x] T020 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec286EnvironmentCopyNeutralizationSmokeTest.php` exactly as recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/286-ui-copy-ia-localization-neutralization/quickstart.md`
- [x] T021 Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [x] T022 Review the touched localization files, page classes, shell helpers, widget views, pinned policy/baseline-compare helper surfaces, and the review artifact to confirm no route/slug/class/capability changed, chooser and landing still keep one dominant CTA, dashboard/shell remain orientation-first, provider detail stays secondary, no duplicate visible scope summary was introduced, no auth-provider wording was neutralized, and any broader provider-surface cleanup was recorded as follow-up instead of absorbed here
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: no dependencies; start immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories until glossary and guard boundaries are settled.
- **Phase 3 (US1)**: depends on Phase 2 and delivers the first environment-first operator surface increment.
- **Phase 4 (US2)**: depends on Phase 2 and should follow US1 because shell and dashboard copy should consume the final chooser vocabulary.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 and US2 because provider-neutral helper copy should align with the final environment-first glossary.
- **Phase 6 (Polish)**: depends on all implemented stories.
### User Story Dependencies
- **US1 (P1)**: first independently testable increment once the glossary and guard boundaries exist.
- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because shell and dashboard labels must not diverge from chooser vocabulary.
- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 and US2 because helper text should inherit the final platform nouns.
### Within Each User Story
- Write the listed Pest coverage first and make it fail for the intended gap before runtime implementation.
- Reuse the existing localization and shell seams before adding any new helper.
- Re-run the narrowest relevant validation command after each story checkpoint before moving to the next story.
---
## Parallel Execution Examples
### Phase 1
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
### Phase 2
- T004 and T005 can run in parallel while T006 finalizes the canonical replacement rule.
### User Story 1
- T007 can run alongside any last Phase 2 cleanup.
- T008 can proceed before T009 and T010 finish wiring the glossary into page classes and views.
### User Story 2
- T011 can run alongside the first US1 validation.
- T012 and T014 can proceed in parallel before T013 finalizes the shared shell labels.
### User Story 3
- T015 and T016 can run in parallel because they cover feature and browser proof separately.
- T017 can proceed before T018 finalizes the bounded helper-text consumers.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **Phase 2 + US1 + US2**. The product becomes semantically coherent once the chooser, registry, dashboard, and shell all teach the same environment-first admin vocabulary.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate the chooser/registry surfaces.
3. Deliver US2 and validate the dashboard/shell surfaces.
4. Deliver US3 and validate the bounded provider-neutral helper text plus browser smoke.
5. Finish with Phase 6 validation, formatting, and explicit spillover review.
### Team Strategy
1. Settle the glossary and guard boundaries before changing multiple shared surfaces.
2. Parallelize test authoring inside each story before converging on the shared localization files.
3. Serialize merges around `localization.php`, `TenantDashboard.php`, and `OperateHubShell.php` because they are the most likely merge hotspots for this slice.
---
## Explicit Follow-Ups / Out of Scope
- broader route/slug/class renaming remains a separate follow-up
- broader provider-owned surface cleanup remains a separate follow-up
- auth-provider wording changes remain out of scope
- website localization and customer-review localization remain out of scope for this package
- no-legacy enforcement and broader guard-pack work remain in Spec `287`
## Close-out Notes
- 2026-05-10: Manual integrated-browser investigation found a live runtime issue on the `Capture snapshot` action where the Livewire request fires but the Filament action modal host remains empty. That behavior is treated as discovered out-of-scope runtime debt for Spec `286` and is intentionally not part of this package's copy / IA / localization acceptance scope.
- Spec `286` closes on the bounded environment-first copy, IA, and localization neutralization surfaces plus the targeted Pest validations defined above.