From bcec5ac1fa0ecbee8a49c9edb1860a4ca2117d3b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Jun 2026 18:43:53 +0100 Subject: [PATCH] Converge shared primitive spinners on LoadingSpinner --- .../subsystems/frontend-primitives.md | 16 ++++-- .../scripts/shared-template-registry.json | 53 ++++++++++++++++++- .../src/components/shared/Button.test.tsx | 3 ++ .../src/components/shared/Button.tsx | 22 +------- .../components/shared/HistoryChartOverlay.tsx | 3 +- .../src/components/shared/PulseDataGrid.tsx | 3 +- .../SharedPrimitives.guardrails.test.ts | 24 ++++++++- .../shared/__tests__/HistoryChart.test.tsx | 7 +++ .../shared/__tests__/PulseDataGrid.test.tsx | 5 ++ 9 files changed, 108 insertions(+), 28 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 1c59cb89c..3e4c8a186 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1777,6 +1777,13 @@ not a replacement status card, CTA band, or page-local nested card. ## Current State +Shared loading indicators are part of the active frontend primitive contract. +`LoadingSpinner` owns pure loading and action-pending spinner shells for shared +primitive internals such as `Button`, `PulseDataGrid`, and +`HistoryChartOverlay`, as well as Login, Settings, Patrol, and AI finding +surfaces; local `animate-spin` spinner shells in those consumers are governed +by the shared-template registry rather than page-local discretion. + AI settings provider fields are a governed frontend primitive, not a provider-local form fork. The shared provider configuration section must render provider-specific controls from `aiSettingsModel.ts` `extraFields`, including @@ -2779,10 +2786,11 @@ green/yellow span classes in storage components or storage-backup presentation helpers. Loading indicators are registry-backed too. `LoadingSpinner` owns the shared border-based spinner shell, size catalog, tone catalog, decorative status, and -accessible status label behavior. Settings, Patrol, and AI finding surfaces -must compose that primitive for pure loading and action-pending spinners; -icon-specific refresh rotation remains local icon state, not a loading-spinner -shell. +accessible status label behavior. Shared primitive internals such as `Button`, +`PulseDataGrid`, and `HistoryChartOverlay`, plus Login, Settings, Patrol, and +AI finding surfaces, must compose that primitive for pure loading and +action-pending spinners; icon-specific refresh rotation remains local icon +state, not a loading-spinner shell. Native select controls are registry-backed too. `FormSelect` owns label/id wiring, helper-text description merging, value synchronization, default select chrome, dynamic-option value synchronization, and compact styling hooks for diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 9c91a240f..e35525920 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -435,7 +435,7 @@ { "id": "loading-spinner-shell", "category": "loading-indicator", - "summary": "Login, Settings, Patrol, AI, and shared loading indicators must compose LoadingSpinner for shared size, tone, and accessibility behavior instead of recreating border-based animate-spin spans.", + "summary": "Shared primitives, Login, Settings, Patrol, AI, and shared loading indicators must compose LoadingSpinner for shared size, tone, and accessibility behavior instead of recreating border-based animate-spin spans.", "canonical": { "path": "src/components/shared/LoadingSpinner.tsx", "export": "LoadingSpinner" @@ -453,6 +453,9 @@ { "path": "src/components/Settings/UpdatesSettingsPanel.tsx" }, { "path": "src/components/Settings/UserAssignmentsDialog.tsx" }, { "path": "src/components/Settings/UserAssignmentsPanel.tsx" }, + { "path": "src/components/shared/Button.tsx" }, + { "path": "src/components/shared/HistoryChartOverlay.tsx" }, + { "path": "src/components/shared/PulseDataGrid.tsx" }, { "path": "src/components/patrol/ApprovalBanner.tsx" }, { "path": "src/components/patrol/ApprovalSection.tsx" }, { "path": "src/components/patrol/InvestigationMessages.tsx" }, @@ -533,6 +536,24 @@ "animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" ] }, + { + "path": "src/components/shared/Button.tsx", + "patterns": [ + "class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-current\"" + ] + }, + { + "path": "src/components/shared/HistoryChartOverlay.tsx", + "patterns": [ + "w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" + ] + }, + { + "path": "src/components/shared/PulseDataGrid.tsx", + "patterns": [ + "w-4 h-4 rounded-full border-2 border-slate-300 border-t-blue-600 animate-spin" + ] + }, { "path": "src/components/patrol/ApprovalBanner.tsx", "patterns": [ @@ -573,6 +594,9 @@ "proof": [ "src/components/shared/LoadingSpinner.test.tsx", "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "src/components/shared/__tests__/HistoryChart.test.tsx", + "src/components/shared/__tests__/PulseDataGrid.test.tsx", "src/components/AI/__tests__/FindingsPanel.test.ts", "src/components/__tests__/Login.test.tsx", "src/components/patrol/__tests__/ApprovalBanner.test.tsx", @@ -2762,6 +2786,33 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "shared-component-local-loading-spinner-shell", + "category": "loading-indicator", + "summary": "Shared primitive internals must not recreate local animate-spin loading shells; compose LoadingSpinner so pages inherit the canonical size, tone, and accessibility behavior by construction.", + "canonical": { + "path": "src/components/shared/LoadingSpinner.tsx", + "export": "LoadingSpinner" + }, + "scopes": [ + "src/components/shared/Button.tsx", + "src/components/shared/HistoryChartOverlay.tsx", + "src/components/shared/PulseDataGrid.tsx" + ], + "extensions": [".tsx"], + "allPatterns": ["animate-spin"], + "legacyReason": "Retired migration debt. Button loading state, PulseDataGrid loading rows, and HistoryChart overlays compose LoadingSpinner instead of local SVG or border spinner shells.", + "allowedPaths": [], + "ignoredPaths": ["src/components/shared/SharedPrimitives.guardrails.test.ts"], + "proof": [ + "src/components/shared/LoadingSpinner.test.tsx", + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "src/components/shared/__tests__/HistoryChart.test.tsx", + "src/components/shared/__tests__/PulseDataGrid.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "login-local-border-loading-spinner-shell", "category": "loading-indicator", diff --git a/frontend-modern/src/components/shared/Button.test.tsx b/frontend-modern/src/components/shared/Button.test.tsx index 611fe9b3d..f0eb213e8 100644 --- a/frontend-modern/src/components/shared/Button.test.tsx +++ b/frontend-modern/src/components/shared/Button.test.tsx @@ -87,6 +87,9 @@ describe('Button', () => { )); expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled(); + expect(buttonSource).toContain("import { LoadingSpinner } from './LoadingSpinner'"); + expect(buttonSource).toContain(' { diff --git a/frontend-modern/src/components/shared/Button.tsx b/frontend-modern/src/components/shared/Button.tsx index ec926c36f..5ba54cca2 100644 --- a/frontend-modern/src/components/shared/Button.tsx +++ b/frontend-modern/src/components/shared/Button.tsx @@ -16,6 +16,7 @@ import { type CopyValueButtonSize, type CopyValueButtonVariant, } from './buttonModel'; +import { LoadingSpinner } from './LoadingSpinner'; export interface ButtonProps extends JSX.ButtonHTMLAttributes { variant?: ButtonVariant; @@ -100,26 +101,7 @@ export function Button(props: ButtonProps) { {...rest} > {local.isLoading ? ( - - - - + ) : null} {local.children} diff --git a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx index f044ac4f0..9c3463d89 100644 --- a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx +++ b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx @@ -1,4 +1,5 @@ import { Component, Show } from 'solid-js'; +import { LoadingSpinner } from './LoadingSpinner'; import type { HistoryChartState } from './useHistoryChartState'; interface HistoryChartOverlayProps { @@ -41,7 +42,7 @@ export const HistoryChartOverlay: Component = (props)
-
+
diff --git a/frontend-modern/src/components/shared/PulseDataGrid.tsx b/frontend-modern/src/components/shared/PulseDataGrid.tsx index 5ddcc2373..a40ba6b87 100644 --- a/frontend-modern/src/components/shared/PulseDataGrid.tsx +++ b/frontend-modern/src/components/shared/PulseDataGrid.tsx @@ -7,6 +7,7 @@ import { TableHead, TableCell, } from '@/components/shared/Table'; +import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { getPulseDataGridAlignClass, getPulseDataGridFrameClass, @@ -139,7 +140,7 @@ export function PulseDataGrid(props: PulseDataGridProps) { class="px-4 py-8 text-center text-sm text-slate-500" >
-
+ Loading...
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 7b9df0ce3..d094f7717 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -2460,7 +2460,7 @@ describe('shared primitive guardrails', () => { } }); - it('keeps Login, Settings, Patrol, and AI loading spinners on the shared LoadingSpinner primitive', () => { + it('keeps shared, Login, Settings, Patrol, and AI loading spinners on the shared LoadingSpinner primitive', () => { const registry = JSON.parse(sharedTemplateRegistrySource) as { rules?: Array<{ id: string; @@ -2490,6 +2490,9 @@ describe('shared primitive guardrails', () => { const loginBorderGuard = registry.patternGuards?.find( (guard) => guard.id === 'login-local-border-loading-spinner-shell', ); + const sharedComponentSpinnerGuard = registry.patternGuards?.find( + (guard) => guard.id === 'shared-component-local-loading-spinner-shell', + ); expect(registeredRule?.canonical?.path).toBe('src/components/shared/LoadingSpinner.tsx'); expect(registeredRule?.canonical?.export).toBe('LoadingSpinner'); @@ -2506,6 +2509,9 @@ describe('shared primitive guardrails', () => { 'src/components/Settings/UpdatesSettingsPanel.tsx', 'src/components/Settings/UserAssignmentsDialog.tsx', 'src/components/Settings/UserAssignmentsPanel.tsx', + 'src/components/shared/Button.tsx', + 'src/components/shared/HistoryChartOverlay.tsx', + 'src/components/shared/PulseDataGrid.tsx', 'src/components/patrol/ApprovalBanner.tsx', 'src/components/patrol/ApprovalSection.tsx', 'src/components/patrol/InvestigationMessages.tsx', @@ -2541,6 +2547,17 @@ describe('shared primitive guardrails', () => { expect(loginBorderGuard?.allPatterns).toEqual(['border-t-transparent', 'animate-spin']); expect(loginBorderGuard?.scopes).toEqual(['src/components/Login.tsx']); expect(loginBorderGuard?.allowedPaths ?? []).toHaveLength(0); + expect(sharedComponentSpinnerGuard?.canonical?.path).toBe( + 'src/components/shared/LoadingSpinner.tsx', + ); + expect(sharedComponentSpinnerGuard?.canonical?.export).toBe('LoadingSpinner'); + expect(sharedComponentSpinnerGuard?.allPatterns).toEqual(['animate-spin']); + expect(sharedComponentSpinnerGuard?.scopes).toEqual([ + 'src/components/shared/Button.tsx', + 'src/components/shared/HistoryChartOverlay.tsx', + 'src/components/shared/PulseDataGrid.tsx', + ]); + expect(sharedComponentSpinnerGuard?.allowedPaths ?? []).toHaveLength(0); expect(loadingSpinnerSource).toContain('getLoadingSpinnerClass'); expect(loadingSpinnerSource).toContain('aria-hidden={ariaHidden()}'); @@ -2552,6 +2569,11 @@ describe('shared primitive guardrails', () => { ); expect(loginSource).not.toContain('class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"'); + for (const source of [buttonSource, historyChartOverlaySource, pulseDataGridSource]) { + expect(source).toContain('LoadingSpinner'); + expect(source).not.toContain('animate-spin'); + } + for (const source of [ findingsPanelSource, approvalBannerSource, diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index ee4a76560..927b65171 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -57,6 +57,13 @@ describe('HistoryChart', () => { expect(historyChartSource).toContain('HistoryChartHeader'); expect(historyChartSource).toContain('HistoryChartOverlay'); expect(historyChartSource).toContain('HistoryChartTooltip'); + expect(historyChartOverlaySource).toContain("import { LoadingSpinner } from './LoadingSpinner'"); + expect(historyChartOverlaySource).toContain( + '', + ); + expect(historyChartOverlaySource).not.toContain( + 'w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin', + ); expect(historyChartSource).not.toContain('ChartsAPI.getMetricsHistory'); expect(historyChartSource).not.toContain('calculateOptimalPoints'); expect(historyChartSource).not.toContain('setupCanvasDPR'); diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx index 22e2bbe82..fefe8ae40 100644 --- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx @@ -25,7 +25,12 @@ describe('PulseDataGrid', () => { expect(pulseDataGridSource).toContain('getPulseDataGridWidthAttr'); expect(pulseDataGridSource).toContain('isPulseDataGridInteractiveTarget'); expect(pulseDataGridSource).toContain("from '@/components/shared/Table'"); + expect(pulseDataGridSource).toContain("from '@/components/shared/LoadingSpinner'"); expect(pulseDataGridSource).toContain('