Converge shared primitive spinners on LoadingSpinner

This commit is contained in:
rcourtman
2026-06-13 18:43:53 +01:00
parent a39e209918
commit bcec5ac1fa
9 changed files with 108 additions and 28 deletions
@@ -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
@@ -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",
@@ -87,6 +87,9 @@ describe('Button', () => {
));
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
expect(buttonSource).toContain("import { LoadingSpinner } from './LoadingSpinner'");
expect(buttonSource).toContain('<LoadingSpinner size="md" tone="current"');
expect(buttonSource).not.toContain('class="animate-spin -ml-1 mr-2 h-4 w-4 text-current"');
});
it('renders settings action buttons through named size and variants', () => {
@@ -16,6 +16,7 @@ import {
type CopyValueButtonSize,
type CopyValueButtonVariant,
} from './buttonModel';
import { LoadingSpinner } from './LoadingSpinner';
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
@@ -100,26 +101,7 @@ export function Button(props: ButtonProps) {
{...rest}
>
{local.isLoading ? (
<svg
class="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<LoadingSpinner size="md" tone="current" class="-ml-1 mr-2" />
) : null}
{local.children}
</button>
@@ -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<HistoryChartOverlayProps> = (props)
<Show when={props.chart.loading()}>
<div class="absolute inset-0 flex items-center justify-center bg-surface -[1px]">
<div class="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<LoadingSpinner size="xl" tone="info" label="Loading history" />
</div>
</Show>
@@ -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<T>(props: PulseDataGridProps<T>) {
class="px-4 py-8 text-center text-sm text-slate-500"
>
<div class="flex items-center justify-center gap-2">
<div class="w-4 h-4 rounded-full border-2 border-slate-300 border-t-blue-600 animate-spin"></div>
<LoadingSpinner size="md" tone="info" />
Loading...
</div>
</TableCell>
@@ -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,
@@ -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(
'<LoadingSpinner size="xl" tone="info" label="Loading history" />',
);
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');
@@ -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('<Table');
expect(pulseDataGridSource).toContain('<LoadingSpinner size="md" tone="info"');
expect(pulseDataGridSource).not.toContain(
'w-4 h-4 rounded-full border-2 border-slate-300 border-t-blue-600 animate-spin',
);
expect(pulseDataGridSource).toContain('wrapperClass="scrollbar-hide"');
expect(pulseDataGridSource).toContain('scrollbar-hide');
expect(pulseDataGridSource).not.toContain('<div class="overflow-x-auto');