Canonicalize settings dialog close buttons

This commit is contained in:
rcourtman 2026-06-12 17:48:28 +01:00
parent ce2cc8948b
commit c57993adb9
9 changed files with 92 additions and 32 deletions

View file

@ -467,11 +467,12 @@ not a replacement status card, CTA band, or page-local nested card.
compact row actions, and other repeated command affordances must not copy
local `inline-flex ... rounded-md ...` Tailwind shells just because the page
needs a one-off action. The shared `mdCompact` size owns the common
settings/action `px-3 py-2 text-sm` shape, and the shared `xs` size owns the
compact settings row-action `px-2.5 py-1 text-xs` shape. If a new
surface needs a variant that the shared primitive does not expose, extend
the primitive and registry guard rather than adding a page-local class
string.
settings/action `px-3 py-2 text-sm` shape, the shared `xs` size owns the
compact settings row-action `px-2.5 py-1 text-xs` shape, and the shared
`iconMd` size owns the settings dialog close-button `h-9 w-9` icon shape.
If a new surface needs a variant that the shared primitive does not expose,
extend the primitive and registry guard rather than adding a page-local
class string.
Framed product table surfaces must consume the shared `TableCard` frame and
`TableCardHeader` title/action band instead of composing page-local `Card`
border, background, overflow, or table-title chrome. Feature owners may own

View file

@ -195,6 +195,8 @@
{ "path": "src/components/Settings/AvailabilitySettingsPanel.tsx" },
{ "path": "src/components/Settings/ConnectionEditor/ConnectionEditor.tsx" },
{ "path": "src/components/Settings/DataHandlingPanel.tsx" },
{ "path": "src/components/Settings/InfrastructureAgentUpdatesDialog.tsx" },
{ "path": "src/components/Settings/InfrastructureDiscoverySettingsDialog.tsx" },
{ "path": "src/components/Settings/InfrastructureInstallerSection.tsx" },
{ "path": "src/components/Settings/InfrastructureSourceManager.tsx" },
{ "path": "src/components/Settings/InfrastructureWorkspace.tsx" },
@ -1106,6 +1108,28 @@
"src/components/shared/Button.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "button-outline-settings-dialog-close-local-shell",
"category": "action-button",
"summary": "Settings dialog close buttons must not copy the h-9 w-9 bordered icon shell; use Button with the outline variant and iconMd size.",
"canonical": {
"path": "src/components/shared/buttonModel.ts",
"export": "getButtonClass"
},
"scopes": ["src/components/Settings", "src/features", "src/pages"],
"extensions": [".tsx"],
"allPatterns": [
"h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover"
],
"legacyReason": "Retired migration debt. Settings dialog close buttons 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"
]
}
],
"requiredPatternGuards": [

View file

@ -34,9 +34,6 @@ import {
shouldOpenAvailabilityTargetAddDialog,
} from './availabilitySettingsModel';
const closeButtonClass =
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover';
type AvailabilityDialogState =
| { mode: 'add'; initialTargetKind?: AvailabilityTargetKind }
| { mode: 'edit'; target: AvailabilityTarget }
@ -348,14 +345,15 @@ export const AvailabilitySettingsPanel: Component = () => {
<h2 class="text-base font-semibold text-base-content">{dialogTitle()}</h2>
<p class="text-sm text-muted">{dialogDescription()}</p>
</div>
<button
<Button
type="button"
variant="outline"
size="iconMd"
onClick={() => closeDialog(true)}
class={closeButtonClass}
aria-label="Close availability check dialog"
>
<X class="h-4 w-4" />
</button>
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<AvailabilityTargetSlot

View file

@ -1,6 +1,7 @@
import { For, Show, createMemo, type Component } from 'solid-js';
import { Copy, X } from 'lucide-solid';
import { Dialog } from '@/components/shared/Dialog';
import { Button } from '@/components/shared/Button';
import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import {
@ -16,9 +17,6 @@ interface InfrastructureAgentUpdatesDialogProps {
onClose: () => void;
}
const closeButtonClass =
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover';
const copyButtonClass =
'absolute right-2 top-2 inline-flex min-h-10 min-w-10 items-center justify-center rounded-md bg-surface-hover p-2 text-muted transition-colors hover:text-base-content disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:min-w-9';
@ -60,14 +58,15 @@ export const InfrastructureAgentUpdatesDialog: Component<InfrastructureAgentUpda
Copy the update command for each host that is behind the current Pulse Agent target.
</p>
</div>
<button
<Button
type="button"
variant="outline"
size="iconMd"
onClick={props.onClose}
class={closeButtonClass}
aria-label="Close agent update commands"
>
<X class="h-4 w-4" />
</button>
</Button>
</div>
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 sm:p-6">

View file

@ -1,6 +1,7 @@
import { Component, Show } from 'solid-js';
import X from 'lucide-solid/icons/x';
import { Dialog } from '@/components/shared/Dialog';
import { Button } from '@/components/shared/Button';
import { DiscoverySettingsForm } from './DiscoverySettingsForm';
import type { DiscoverySettingsFormProps } from './discoverySettingsModel';
@ -9,9 +10,6 @@ interface InfrastructureDiscoverySettingsDialogProps extends DiscoverySettingsFo
onClose: () => void;
}
const closeButtonClass =
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover';
export const InfrastructureDiscoverySettingsDialog: Component<
InfrastructureDiscoverySettingsDialogProps
> = (props) => {
@ -32,14 +30,15 @@ export const InfrastructureDiscoverySettingsDialog: Component<
source discovery.
</p>
</div>
<button
<Button
type="button"
variant="outline"
size="iconMd"
onClick={props.onClose}
class={closeButtonClass}
aria-label="Close discovery settings dialog"
>
<X class="h-4 w-4" />
</button>
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6">

View file

@ -91,8 +91,6 @@ const ADD_STEP_TO_INSTALLER_FOCUS: Partial<
kubernetes: 'kubernetes',
};
const closeButtonClass =
'inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover';
const buttonClass =
'inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
@ -934,14 +932,15 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<h2 class="text-base font-semibold text-base-content">{addDialogTitle()}</h2>
<p class="text-sm text-muted">{addDialogDescription()}</p>
</div>
<button
<Button
type="button"
variant="outline"
size="iconMd"
onClick={closeAddFlow}
class={closeButtonClass}
aria-label="Close add infrastructure dialog"
>
<X class="h-4 w-4" />
</button>
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
@ -1003,14 +1002,15 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
<h2 class="text-base font-semibold text-base-content">{editDialogTitle()}</h2>
<p class="text-sm text-muted">{editDialogDescription()}</p>
</div>
<button
<Button
type="button"
variant="outline"
size="iconMd"
onClick={closeEditFlow}
class={closeButtonClass}
aria-label="Close edit infrastructure dialog"
>
<X class="h-4 w-4" />
</button>
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">

View file

@ -20,6 +20,7 @@ describe('Button', () => {
expect(buttonModelSource).toContain('export const BUTTON_SIZE_CLASSES');
expect(buttonModelSource).toContain("xs: 'px-2.5 py-1 text-xs'");
expect(buttonModelSource).toContain("mdCompact: 'px-3 py-2 text-sm'");
expect(buttonModelSource).toContain("iconMd: 'h-9 w-9 p-0'");
});
it('renders command buttons with the shared secondary shell', () => {

View file

@ -108,6 +108,8 @@ import webhookConfigFormSource from '@/components/Alerts/WebhookConfigForm.tsx?r
import reportMergeModalSource from '@/components/Infrastructure/ReportMergeModal.tsx?raw';
import availabilitySettingsPanelSource from '@/components/Settings/AvailabilitySettingsPanel.tsx?raw';
import connectionEditorSource from '@/components/Settings/ConnectionEditor/ConnectionEditor.tsx?raw';
import infrastructureAgentUpdatesDialogSource from '@/components/Settings/InfrastructureAgentUpdatesDialog.tsx?raw';
import infrastructureDiscoverySettingsDialogSource from '@/components/Settings/InfrastructureDiscoverySettingsDialog.tsx?raw';
import selfHostedCommercialRecoverySectionSource from '@/components/Settings/SelfHostedCommercialRecoverySection.tsx?raw';
import suggestProfileModalSource from '@/components/Settings/SuggestProfileModal.tsx?raw';
import alertAppriseDestinationsSectionSource from '@/features/alerts/AlertAppriseDestinationsSection.tsx?raw';
@ -1515,6 +1517,9 @@ describe('shared primitive guardrails', () => {
const settingsRowActionGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-outline-settings-row-action-local-shell',
);
const settingsDialogCloseGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-outline-settings-dialog-close-local-shell',
);
expect(registeredRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
expect(registeredRule?.canonical?.export).toBe('Button');
@ -1525,6 +1530,8 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/AvailabilitySettingsPanel.tsx',
'src/components/Settings/ConnectionEditor/ConnectionEditor.tsx',
'src/components/Settings/DataHandlingPanel.tsx',
'src/components/Settings/InfrastructureAgentUpdatesDialog.tsx',
'src/components/Settings/InfrastructureDiscoverySettingsDialog.tsx',
'src/components/Settings/InfrastructureInstallerSection.tsx',
'src/components/Settings/InfrastructureSourceManager.tsx',
'src/components/Settings/InfrastructureWorkspace.tsx',
@ -1563,6 +1570,22 @@ describe('shared primitive guardrails', () => {
]);
expect(settingsRowActionGuard?.allowedPaths ?? []).toHaveLength(0);
expect(settingsRowActionGuard?.ignoredPaths).toEqual(['src/components/shared/Button.test.tsx']);
expect(settingsDialogCloseGuard?.canonical?.path).toBe(
'src/components/shared/buttonModel.ts',
);
expect(settingsDialogCloseGuard?.canonical?.export).toBe('getButtonClass');
expect(settingsDialogCloseGuard?.allPatterns).toEqual([
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
]);
expect(settingsDialogCloseGuard?.scopes).toEqual([
'src/components/Settings',
'src/features',
'src/pages',
]);
expect(settingsDialogCloseGuard?.allowedPaths ?? []).toHaveLength(0);
expect(settingsDialogCloseGuard?.ignoredPaths).toEqual([
'src/components/shared/Button.test.tsx',
]);
expect(buttonSource).toContain('export function Button');
expect(buttonSource).toContain('export function ButtonLink');
@ -1588,6 +1611,9 @@ describe('shared primitive guardrails', () => {
expect(availabilitySettingsPanelSource).not.toContain(
'rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content',
);
expect(availabilitySettingsPanelSource).not.toContain(
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
);
expect(connectionEditorSource).toContain('@/components/shared/Button');
expect(connectionEditorSource).not.toContain(
'rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content',
@ -1601,6 +1627,14 @@ describe('shared primitive guardrails', () => {
expect(infrastructureInstallerSectionSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content',
);
expect(infrastructureAgentUpdatesDialogSource).toContain('@/components/shared/Button');
expect(infrastructureAgentUpdatesDialogSource).not.toContain(
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
);
expect(infrastructureDiscoverySettingsDialogSource).toContain('@/components/shared/Button');
expect(infrastructureDiscoverySettingsDialogSource).not.toContain(
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
);
expect(infrastructureSourceManagerSource).toContain('@/components/shared/Button');
expect(infrastructureSourceManagerSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content',
@ -1612,6 +1646,9 @@ describe('shared primitive guardrails', () => {
expect(infrastructureWorkspaceSource).not.toContain(
'rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content',
);
expect(infrastructureWorkspaceSource).not.toContain(
'h-9 w-9 items-center justify-center rounded-md border border-border text-base-content transition-colors hover:bg-surface-hover',
);
expect(patrolIntelligenceWorkspaceSource).toContain('@/components/shared/Button');
expect(patrolIntelligenceWorkspaceSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content',

View file

@ -1,5 +1,5 @@
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline';
export type ButtonSize = 'xs' | 'sm' | 'mdCompact' | 'md' | 'lg' | 'icon';
export type ButtonSize = 'xs' | 'sm' | 'mdCompact' | 'md' | 'lg' | 'icon' | 'iconMd';
export const BUTTON_BASE_CLASS =
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
@ -19,6 +19,7 @@ export const BUTTON_SIZE_CLASSES: Record<ButtonSize, string> = {
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
icon: 'p-2',
iconMd: 'h-9 w-9 p-0',
};
export type ButtonClassOptions = {