mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Converge shared primitive spinners on LoadingSpinner
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user