From 05f1a3298e8b4b6ef213b72e4487ebd8144df026 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Jun 2026 22:59:58 +0100 Subject: [PATCH] Converge Patrol approval actions on shared Button primitives --- .../subsystems/frontend-primitives.md | 4 + .../subsystems/patrol-intelligence.md | 5 + .../scripts/shared-template-registry.json | 111 +++++++++++++++++ .../src/components/patrol/ApprovalBanner.tsx | 25 ++-- .../src/components/patrol/ApprovalSection.tsx | 94 +++++--------- .../patrol/__tests__/ApprovalBanner.test.tsx | 11 ++ .../patrol/__tests__/ApprovalSection.test.tsx | 15 +++ .../src/components/shared/Button.test.tsx | 8 ++ .../SharedPrimitives.guardrails.test.ts | 115 ++++++++++++++++++ .../src/components/shared/buttonModel.ts | 3 + 10 files changed, 321 insertions(+), 70 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9158a8a9a..753d9d1cb 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -540,6 +540,10 @@ not a replacement status card, CTA band, or page-local nested card. and reporting exports use the shared `success`, `successOutline`, and `successGhost` Button variants instead of carrying page-local emerald action shells. + Patrol approval and remediation controls use that same primitive family: + Patrol owns approval/reapproval/denial/review/Assistant handoff behavior, + while `Button` owns success, warning-solid, primary, secondary, ghost, + disabled, focus, and compact action chrome. Shared error-boundary fallback actions are also command buttons: reset, reload, and retry controls must compose `Button` so emergency UI does not become a separate local button vocabulary. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 731f150a4..b2b281e24 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -73,6 +73,11 @@ Patrol-specific presentation helpers. tone, loading indicator variant, or empty-state shell variant, extend the shared primitive and registry guard instead of adding page-local rounded pill spans, spinner spans, or empty-state wrappers. + Patrol approval and remediation actions own approval, denial, reapproval, + review, and Assistant handoff semantics, but their visible action chrome must + compose the shared `Button` primitive for success, warning-solid, primary, + secondary, ghost, disabled, focus, and compact action behavior instead of + page-local button shells. 3. Keep remediation execution badge copy and severity styling aligned through `frontend-modern/src/components/patrol/RemediationStatus.tsx` and `frontend-modern/src/utils/remediationPresentation.ts` 4. Add or change Patrol header, summary, status runtime-state presentation, or runtime provider action presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx`, `frontend-modern/src/components/patrol/PatrolStatusBar.tsx`, `frontend-modern/src/utils/patrolRuntimePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimeActions.ts` Patrol summary presentation may show recent activity mix and trigger mode as diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 1b5d55161..7a47f399a 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -938,6 +938,8 @@ }, "requiredConsumers": [ { "path": "src/components/AI/Chat/ChatMessages.tsx" }, + { "path": "src/components/patrol/ApprovalBanner.tsx" }, + { "path": "src/components/patrol/ApprovalSection.tsx" }, { "path": "src/components/ErrorBoundary.tsx" }, { "path": "src/components/Infrastructure/ResourceDetailDrawer.tsx" }, { "path": "src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx" }, @@ -988,6 +990,23 @@ { "path": "src/features/standalone/StandalonePageSurface.tsx" } ], "forbiddenPatterns": [ + { + "path": "src/components/patrol/ApprovalBanner.tsx", + "patterns": [ + "px-3 py-1.5 bg-green-600 hover:bg-green-700", + "px-3 py-1.5 bg-surface-alt hover:bg-surface-hover", + "px-3 py-1.5 bg-amber-600 hover:bg-amber-700" + ] + }, + { + "path": "src/components/patrol/ApprovalSection.tsx", + "patterns": [ + "px-3 py-1.5 bg-green-600 hover:bg-green-700", + "px-3 py-1.5 bg-amber-600 hover:bg-amber-700", + "px-3 py-1.5 bg-blue-600 hover:bg-blue-700", + "px-3 py-1.5 hover:bg-surface-hover disabled:opacity-50 text-muted" + ] + }, { "path": "src/components/Settings/GeneralSettingsPanel.tsx", "patterns": [ @@ -5528,6 +5547,98 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "button-success-patrol-approval-action-local-shell", + "category": "action-button", + "summary": "Patrol approval surfaces must not copy green execute-action button shells; use Button with the success variant and sm size.", + "canonical": { + "path": "src/components/shared/buttonModel.ts", + "export": "getButtonClass" + }, + "scopes": ["src/components/patrol", "src/features/patrol"], + "extensions": [".tsx"], + "allPatterns": [ + "px-3 py-1.5 bg-green-600 hover:bg-green-700", + "text-white text-xs font-medium rounded" + ], + "legacyReason": "Retired migration debt. Patrol approval execute actions belong to the shared Button primitive family.", + "allowedPaths": [], + "ignoredPaths": ["src/components/shared/Button.test.tsx"], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "button-warning-solid-patrol-approval-action-local-shell", + "category": "action-button", + "summary": "Patrol approval surfaces must not copy amber solid review or reapprove button shells; use Button with the warningSolid variant and sm size.", + "canonical": { + "path": "src/components/shared/buttonModel.ts", + "export": "getButtonClass" + }, + "scopes": ["src/components/patrol", "src/features/patrol"], + "extensions": [".tsx"], + "allPatterns": [ + "px-3 py-1.5 bg-amber-600 hover:bg-amber-700", + "text-white text-xs font-medium rounded" + ], + "legacyReason": "Retired migration debt. Patrol approval warning actions belong to the shared Button primitive family.", + "allowedPaths": [], + "ignoredPaths": ["src/components/shared/Button.test.tsx"], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "button-primary-patrol-approval-action-local-shell", + "category": "action-button", + "summary": "Patrol approval surfaces must not copy blue assistant-action button shells; use Button with the primary variant and sm size.", + "canonical": { + "path": "src/components/shared/buttonModel.ts", + "export": "getButtonClass" + }, + "scopes": ["src/components/patrol", "src/features/patrol"], + "extensions": [".tsx"], + "allPatterns": [ + "px-3 py-1.5 bg-blue-600 hover:bg-blue-700", + "text-white text-xs font-medium rounded" + ], + "legacyReason": "Retired migration debt. Patrol Assistant handoff actions belong to the shared Button primitive family.", + "allowedPaths": [], + "ignoredPaths": ["src/components/shared/Button.test.tsx"], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "button-neutral-patrol-approval-action-local-shell", + "category": "action-button", + "summary": "Patrol approval surfaces must not copy neutral deny button shells; use Button with the secondary or ghost variant and sm size.", + "canonical": { + "path": "src/components/shared/buttonModel.ts", + "export": "getButtonClass" + }, + "scopes": ["src/components/patrol", "src/features/patrol"], + "extensions": [".tsx"], + "allPatterns": [ + "px-3 py-1.5 bg-surface-alt hover:bg-surface-hover", + "text-base-content text-xs font-medium rounded-md" + ], + "legacyReason": "Retired migration debt. Patrol approval deny actions belong to the shared Button primitive family.", + "allowedPaths": [], + "ignoredPaths": ["src/components/shared/Button.test.tsx"], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "button-success-outline-settings-action-local-shell", "category": "action-button", diff --git a/frontend-modern/src/components/patrol/ApprovalBanner.tsx b/frontend-modern/src/components/patrol/ApprovalBanner.tsx index a0d8b9cf7..e5e0424b2 100644 --- a/frontend-modern/src/components/patrol/ApprovalBanner.tsx +++ b/frontend-modern/src/components/patrol/ApprovalBanner.tsx @@ -16,6 +16,7 @@ import { getApprovalExpiryStatusLabel, getApprovalRiskPresentation, } from '@/utils/approvalRiskPresentation'; +import { Button } from '@/components/shared/Button'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { MetadataBadge } from '@/components/shared/MetadataBadge'; import ShieldAlertIcon from 'lucide-solid/icons/shield-alert'; @@ -128,11 +129,13 @@ export const ApprovalBanner: Component = (props) => {
- - + 1}> - +
diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index 4a4661b9f..c1304ae72 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -5,12 +5,15 @@ * States: Pending, Expired, Executed, Denied, Failed, Verified, VerificationFailed. */ +import CheckIcon from 'lucide-solid/icons/check'; +import MessageSquareIcon from 'lucide-solid/icons/message-square'; import { Component, Show, createSignal, createResource, createMemo } from 'solid-js'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { notificationStore } from '@/stores/notifications'; import { aiChatStore } from '@/stores/aiChat'; import { hasFeature } from '@/stores/license'; import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai'; +import { Button } from '@/components/shared/Button'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { MetadataBadge } from '@/components/shared/MetadataBadge'; import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; @@ -234,44 +237,34 @@ export const ApprovalSection: Component = (props) => { const renderRecoveryActions = (assistantLabel: string, onAssistantClick: (e: Event) => void) => (
- + - +
); @@ -301,10 +294,7 @@ export const ApprovalSection: Component = (props) => { /> Fix Available - + {approvalRisk.label} risk @@ -316,62 +306,44 @@ export const ApprovalSection: Component = (props) => {
- - + - +
diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalBanner.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalBanner.test.tsx index 28a29d982..eae8661ec 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalBanner.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalBanner.test.tsx @@ -55,6 +55,17 @@ describe('ApprovalBanner', () => { expect(approvalBannerSource).not.toMatch(/px-1\.5 py-0\.5 text-\[10px\] font-medium rounded/); }); + it('keeps approval action controls on the shared Button primitive', () => { + expect(approvalBannerSource).toContain('@/components/shared/Button'); + expect(approvalBannerSource).toContain(' { state.pendingApprovals = [ { diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index e5acd8827..7d2e6b3b9 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -92,6 +92,21 @@ describe('ApprovalSection', () => { expect(approvalSectionSource).toContain('LoadingSpinner'); }); + it('keeps approval action controls on the shared Button primitive', () => { + expect(approvalSectionSource).toContain('@/components/shared/Button'); + expect(approvalSectionSource).toContain(' { cleanup(); }); diff --git a/frontend-modern/src/components/shared/Button.test.tsx b/frontend-modern/src/components/shared/Button.test.tsx index 30b55c4c9..4a1ddde75 100644 --- a/frontend-modern/src/components/shared/Button.test.tsx +++ b/frontend-modern/src/components/shared/Button.test.tsx @@ -37,6 +37,7 @@ describe('Button', () => { ); expect(buttonModelSource).toContain('primaryFlat:'); expect(buttonModelSource).toContain('success:'); + expect(buttonModelSource).toContain('warningSolid:'); expect(buttonModelSource).toContain('successOutline:'); expect(buttonModelSource).toContain('successGhost:'); expect(buttonModelSource).toContain('dangerGhost:'); @@ -105,6 +106,9 @@ describe('Button', () => { + @@ -137,6 +141,10 @@ describe('Button', () => { expect(openInfrastructureButton).toHaveClass('bg-emerald-600'); expect(openInfrastructureButton).toHaveClass('text-white'); + const reviewApprovalsButton = screen.getByRole('button', { name: 'Review approvals' }); + expect(reviewApprovalsButton).toHaveClass('bg-amber-600'); + expect(reviewApprovalsButton).toHaveClass('text-white'); + const openInventoryButton = screen.getByRole('button', { name: 'Open inventory' }); expect(openInventoryButton).toHaveClass('border-emerald-300'); expect(openInventoryButton).toHaveClass('text-emerald-900'); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index f4723ff04..da65c2556 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -3003,6 +3003,18 @@ describe('shared primitive guardrails', () => { const settingsSuccessActionGuard = registry.patternGuards?.find( (guard) => guard.id === 'button-success-settings-action-local-shell', ); + const patrolSuccessApprovalActionGuard = registry.patternGuards?.find( + (guard) => guard.id === 'button-success-patrol-approval-action-local-shell', + ); + const patrolWarningApprovalActionGuard = registry.patternGuards?.find( + (guard) => guard.id === 'button-warning-solid-patrol-approval-action-local-shell', + ); + const patrolPrimaryApprovalActionGuard = registry.patternGuards?.find( + (guard) => guard.id === 'button-primary-patrol-approval-action-local-shell', + ); + const patrolNeutralApprovalActionGuard = registry.patternGuards?.find( + (guard) => guard.id === 'button-neutral-patrol-approval-action-local-shell', + ); const settingsSuccessOutlineActionGuard = registry.patternGuards?.find( (guard) => guard.id === 'button-success-outline-settings-action-local-shell', ); @@ -3081,6 +3093,8 @@ describe('shared primitive guardrails', () => { expect(registeredRule?.canonical?.export).toBe('Button'); expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ 'src/components/AI/Chat/ChatMessages.tsx', + 'src/components/patrol/ApprovalBanner.tsx', + 'src/components/patrol/ApprovalSection.tsx', 'src/components/ErrorBoundary.tsx', 'src/components/Infrastructure/ResourceDetailDrawer.tsx', 'src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx', @@ -3126,6 +3140,23 @@ describe('shared primitive guardrails', () => { ]); expect(registeredRule?.forbiddenPatterns).toEqual( expect.arrayContaining([ + expect.objectContaining({ + path: 'src/components/patrol/ApprovalBanner.tsx', + patterns: expect.arrayContaining([ + 'px-3 py-1.5 bg-green-600 hover:bg-green-700', + 'px-3 py-1.5 bg-surface-alt hover:bg-surface-hover', + 'px-3 py-1.5 bg-amber-600 hover:bg-amber-700', + ]), + }), + expect.objectContaining({ + path: 'src/components/patrol/ApprovalSection.tsx', + patterns: expect.arrayContaining([ + 'px-3 py-1.5 bg-green-600 hover:bg-green-700', + 'px-3 py-1.5 bg-amber-600 hover:bg-amber-700', + 'px-3 py-1.5 bg-blue-600 hover:bg-blue-700', + 'px-3 py-1.5 hover:bg-surface-hover disabled:opacity-50 text-muted', + ]), + }), expect.objectContaining({ path: 'src/components/Settings/GeneralSettingsPanel.tsx', patterns: expect.arrayContaining([ @@ -3331,6 +3362,26 @@ describe('shared primitive guardrails', () => { expect(billingAdminOrganizationsTableSource).not.toContain( 'px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover', ); + expect(approvalBannerSource).toContain('@/components/shared/Button'); + expect(approvalBannerSource).toContain(' { expect(settingsSuccessActionGuard?.ignoredPaths).toEqual([ 'src/components/shared/Button.test.tsx', ]); + expect(patrolSuccessApprovalActionGuard?.canonical?.path).toBe( + 'src/components/shared/buttonModel.ts', + ); + expect(patrolSuccessApprovalActionGuard?.canonical?.export).toBe('getButtonClass'); + expect(patrolSuccessApprovalActionGuard?.allPatterns).toEqual([ + 'px-3 py-1.5 bg-green-600 hover:bg-green-700', + 'text-white text-xs font-medium rounded', + ]); + expect(patrolSuccessApprovalActionGuard?.scopes).toEqual([ + 'src/components/patrol', + 'src/features/patrol', + ]); + expect(patrolSuccessApprovalActionGuard?.allowedPaths ?? []).toHaveLength(0); + expect(patrolSuccessApprovalActionGuard?.ignoredPaths).toEqual([ + 'src/components/shared/Button.test.tsx', + ]); + expect(patrolWarningApprovalActionGuard?.canonical?.path).toBe( + 'src/components/shared/buttonModel.ts', + ); + expect(patrolWarningApprovalActionGuard?.canonical?.export).toBe('getButtonClass'); + expect(patrolWarningApprovalActionGuard?.allPatterns).toEqual([ + 'px-3 py-1.5 bg-amber-600 hover:bg-amber-700', + 'text-white text-xs font-medium rounded', + ]); + expect(patrolWarningApprovalActionGuard?.scopes).toEqual([ + 'src/components/patrol', + 'src/features/patrol', + ]); + expect(patrolWarningApprovalActionGuard?.allowedPaths ?? []).toHaveLength(0); + expect(patrolWarningApprovalActionGuard?.ignoredPaths).toEqual([ + 'src/components/shared/Button.test.tsx', + ]); + expect(patrolPrimaryApprovalActionGuard?.canonical?.path).toBe( + 'src/components/shared/buttonModel.ts', + ); + expect(patrolPrimaryApprovalActionGuard?.canonical?.export).toBe('getButtonClass'); + expect(patrolPrimaryApprovalActionGuard?.allPatterns).toEqual([ + 'px-3 py-1.5 bg-blue-600 hover:bg-blue-700', + 'text-white text-xs font-medium rounded', + ]); + expect(patrolPrimaryApprovalActionGuard?.scopes).toEqual([ + 'src/components/patrol', + 'src/features/patrol', + ]); + expect(patrolPrimaryApprovalActionGuard?.allowedPaths ?? []).toHaveLength(0); + expect(patrolPrimaryApprovalActionGuard?.ignoredPaths).toEqual([ + 'src/components/shared/Button.test.tsx', + ]); + expect(patrolNeutralApprovalActionGuard?.canonical?.path).toBe( + 'src/components/shared/buttonModel.ts', + ); + expect(patrolNeutralApprovalActionGuard?.canonical?.export).toBe('getButtonClass'); + expect(patrolNeutralApprovalActionGuard?.allPatterns).toEqual([ + 'px-3 py-1.5 bg-surface-alt hover:bg-surface-hover', + 'text-base-content text-xs font-medium rounded-md', + ]); + expect(patrolNeutralApprovalActionGuard?.scopes).toEqual([ + 'src/components/patrol', + 'src/features/patrol', + ]); + expect(patrolNeutralApprovalActionGuard?.allowedPaths ?? []).toHaveLength(0); + expect(patrolNeutralApprovalActionGuard?.ignoredPaths).toEqual([ + 'src/components/shared/Button.test.tsx', + ]); expect(settingsSuccessOutlineActionGuard?.canonical?.path).toBe( 'src/components/shared/buttonModel.ts', ); diff --git a/frontend-modern/src/components/shared/buttonModel.ts b/frontend-modern/src/components/shared/buttonModel.ts index 9b505047d..cd438ea30 100644 --- a/frontend-modern/src/components/shared/buttonModel.ts +++ b/frontend-modern/src/components/shared/buttonModel.ts @@ -2,6 +2,7 @@ export type ButtonVariant = | 'primary' | 'primaryFlat' | 'warning' + | 'warningSolid' | 'info' | 'success' | 'successOutline' @@ -31,6 +32,8 @@ export const BUTTON_VARIANT_CLASSES: Record = { primaryFlat: 'border border-transparent bg-blue-600 text-white hover:bg-blue-700', warning: 'border border-amber-300 bg-amber-100 text-amber-800 hover:bg-amber-200 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-100 dark:hover:bg-amber-800', + warningSolid: + 'border border-transparent bg-amber-600 text-white shadow-sm hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600', info: 'border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-200', success: 'border border-transparent bg-emerald-600 text-white shadow-sm hover:bg-emerald-700', successOutline: