Broaden shared Button settings governance

This commit is contained in:
rcourtman 2026-06-12 17:31:46 +01:00
parent bbdd590a82
commit 60f153611b
11 changed files with 152 additions and 60 deletions

View file

@ -463,9 +463,11 @@ not a replacement status card, CTA band, or page-local nested card.
danger/outline/ghost button chrome, sizes, focus rings, disabled/loading
behavior, and safe new-tab link behavior must come from `Button`,
`ButtonLink`, and `buttonModel.ts`. Empty states, platform page notices,
Patrol controls, settings panel actions, and other repeated command
affordances must not copy local `inline-flex ... rounded-md ... bg-surface`
Tailwind shells just because the page needs a one-off action. If a new
Patrol controls, settings panel actions, infrastructure setup controls, and
other repeated command affordances must not copy local `inline-flex ...
rounded-md ... bg-surface` 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. 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.

View file

@ -191,7 +191,11 @@
"requiredConsumers": [
{ "path": "src/components/AI/Chat/ChatMessages.tsx" },
{ "path": "src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx" },
{ "path": "src/components/Settings/AgentProfilesPanel.tsx" },
{ "path": "src/components/Settings/AvailabilitySettingsPanel.tsx" },
{ "path": "src/components/Settings/DataHandlingPanel.tsx" },
{ "path": "src/components/Settings/InfrastructureInstallerSection.tsx" },
{ "path": "src/components/Settings/InfrastructureSourceManager.tsx" },
{ "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" },
{ "path": "src/features/standalone/StandalonePageSurface.tsx" }
],
@ -1056,6 +1060,28 @@
"src/components/shared/Button.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "button-secondary-settings-action-local-shell",
"category": "action-button",
"summary": "Settings and infrastructure surfaces must not copy the standard secondary px-3 py-2 action-button shell; use Button or ButtonLink with the mdCompact size.",
"canonical": {
"path": "src/components/shared/buttonModel.ts",
"export": "getButtonClass"
},
"scopes": ["src/components/Settings", "src/features", "src/pages"],
"extensions": [".tsx"],
"allPatterns": [
"rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content"
],
"legacyReason": "Retired migration debt. Standard settings-sized secondary command 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

@ -1,4 +1,5 @@
import { Component, For, Show } from 'solid-js';
import { Button } from '@/components/shared/Button';
import { Card } from '@/components/shared/Card';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { Dialog } from '@/components/shared/Dialog';
@ -89,9 +90,7 @@ export const AgentProfilesPanel: Component = () => {
<Crown class="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="text-base font-semibold text-base-content">
{featureGateCopy.title}
</h3>
<h3 class="text-base font-semibold text-base-content">{featureGateCopy.title}</h3>
<p class="text-sm text-muted">{featureGateCopy.subtitle}</p>
</div>
</div>
@ -116,25 +115,29 @@ export const AgentProfilesPanel: Component = () => {
description="Reusable agent configurations"
action={
<div class="flex items-center gap-2">
<button
<Button
type="button"
variant="primary"
size="mdCompact"
onClick={handleCreate}
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 sm:px-4 sm:py-2 sm:text-sm"
class="min-h-10 gap-2 sm:min-h-9 sm:px-4"
>
<Plus class="w-4 h-4" />
<span class="hidden sm:inline">New Profile</span>
<span class="sm:hidden">New</span>
</button>
</Button>
<Show when={aiAvailable()}>
<button
<Button
type="button"
variant="secondary"
size="mdCompact"
onClick={handleSuggest}
title="Suggest profiles for your estate"
class="inline-flex min-h-10 sm:min-h-9 min-w-10 items-center gap-1.5 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover sm:px-3 sm:py-2 sm:text-sm"
class="min-h-10 min-w-10 gap-1.5 sm:min-h-9"
>
<Lightbulb class="w-3.5 h-3.5" />
<span class="hidden sm:inline">Ideas</span>
</button>
</Button>
</Show>
</div>
}

View file

@ -12,6 +12,7 @@ import Plus from 'lucide-solid/icons/plus';
import RotateCw from 'lucide-solid/icons/rotate-cw';
import Activity from 'lucide-solid/icons/activity';
import X from 'lucide-solid/icons/x';
import { Button } from '@/components/shared/Button';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { Dialog } from '@/components/shared/Dialog';
import {
@ -33,10 +34,6 @@ import {
shouldOpenAvailabilityTargetAddDialog,
} from './availabilitySettingsModel';
const primaryButtonClass =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
const secondaryButtonClass =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const rowActionButtonClass =
'inline-flex min-h-8 items-center justify-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const closeButtonClass =
@ -200,23 +197,27 @@ export const AvailabilitySettingsPanel: Component = () => {
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
<Button
type="button"
variant="secondary"
size="mdCompact"
class="min-h-9 gap-2"
onClick={() => void loadTargets()}
disabled={loading()}
class={secondaryButtonClass}
>
<RotateCw class={`h-4 w-4 ${loading() ? 'animate-spin' : ''}`} />
Refresh
</button>
<button
</Button>
<Button
type="button"
variant="primary"
size="mdCompact"
class="min-h-9 gap-2"
onClick={() => openAddDialog('service')}
class={primaryButtonClass}
>
<Plus class="h-4 w-4" />
Add service/device check
</button>
</Button>
</div>
</div>
</div>
@ -253,14 +254,16 @@ export const AvailabilitySettingsPanel: Component = () => {
endpoint that only needs reachability monitoring.
</p>
</div>
<button
<Button
type="button"
variant="primary"
size="mdCompact"
class="min-h-9 gap-2"
onClick={() => openAddDialog('service')}
class={primaryButtonClass}
>
<Plus class="h-4 w-4" />
Add service/device check
</button>
</Button>
</div>
}
>

View file

@ -7,7 +7,7 @@ import Lock from 'lucide-solid/icons/lock';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import ShieldCheck from 'lucide-solid/icons/shield-check';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import Button from '@/components/shared/Button';
import { Button, ButtonLink } from '@/components/shared/Button';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { settingsTabPath } from './settingsNavigationModel';
import {
@ -100,12 +100,14 @@ const EmptyPolicyPostureState: Component = () => (
organization does not have visible monitored resources.
</p>
</div>
<a
<ButtonLink
href={settingsTabPath('infrastructure-systems')}
class="inline-flex w-fit items-center justify-center rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
variant="secondary"
size="mdCompact"
class="w-fit"
>
Open Infrastructure
</a>
</ButtonLink>
</div>
</section>
);

View file

@ -1,5 +1,6 @@
import type { Component } from 'solid-js';
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { Button } from '@/components/shared/Button';
import { FormSelect } from '@/components/shared/FormSelect';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { copyToClipboard } from '@/utils/clipboard';
@ -472,15 +473,16 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
or a specific install profile.
</p>
</div>
<button
<Button
type="button"
variant="secondary"
size="mdCompact"
onClick={() => setShowAdvancedOptions((current) => !current)}
class="inline-flex items-center justify-center rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface"
>
{showAdvancedOptions()
? 'Hide advanced connection and install options'
: 'Show advanced connection and install options'}
</button>
</Button>
</div>
</div>

View file

@ -9,6 +9,7 @@ import {
type Component,
} from 'solid-js';
import { AlertTriangle, Cpu, Plus, RotateCw, Search, SlidersHorizontal } from 'lucide-solid';
import { Button } from '@/components/shared/Button';
import SettingsPanel from '@/components/shared/SettingsPanel';
import {
Table,
@ -66,18 +67,9 @@ const inlineButtonClass =
// to an existing platform without competing with the primary CTA.
const addSectionButtonClass =
'inline-flex items-center justify-center gap-1 rounded px-2 py-0.5 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-50 dark:text-blue-300 dark:hover:bg-blue-950/30';
const workspacePrimaryButtonClass =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
const workspaceSecondaryButtonClass =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const discoveryPrimaryButtonClass =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
const discoverySecondaryButtonClass =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const discoveryRowClass =
'border-b border-border-subtle bg-blue-50/30 hover:bg-blue-50/40 dark:bg-blue-950/10 dark:hover:bg-blue-950/20';
const discoveryScanTargetLabel =
'Proxmox VE, Proxmox Backup Server, and Proxmox Mail Gateway APIs';
const discoveryScanTargetLabel = 'Proxmox VE, Proxmox Backup Server, and Proxmox Mail Gateway APIs';
// The system column shows just the name on a single line. The OS / version
// (standalone) or cluster identity (cluster) descriptor that used to sit on a
// second line moves into the name's hover title, so every row stays one line
@ -598,14 +590,16 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<div class="border-b border-border bg-surface px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<Show when={props.onAddInfrastructure}>
<button
<Button
type="button"
variant="primary"
size="mdCompact"
class="min-h-9 gap-2"
onClick={props.onAddInfrastructure}
class={workspacePrimaryButtonClass}
>
<Plus class="h-4 w-4" />
Add infrastructure
</button>
</Button>
</Show>
</div>
</div>
@ -671,11 +665,13 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<Show when={!props.readOnly}>
<div class="flex flex-wrap gap-2 lg:justify-end">
<Show when={props.onRunDiscovery}>
<button
<Button
type="button"
variant="primary"
size="mdCompact"
class="min-h-9 gap-2"
onClick={props.onRunDiscovery}
disabled={props.discoveryScanStatus().scanning || !props.discoveryEnabled}
class={discoveryPrimaryButtonClass}
title={`Scan configured networks for reachable ${discoveryScanTargetLabel}. Discovered candidates appear here for review before they are added.`}
>
<RotateCw
@ -683,28 +679,32 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
aria-hidden="true"
/>
{props.discoveryScanStatus().scanning ? 'Scanning...' : 'Run discovery'}
</button>
</Button>
</Show>
<Show when={props.onOpenDiscoverySettings}>
<button
<Button
type="button"
variant="secondary"
size="mdCompact"
class="min-h-9 gap-2"
onClick={props.onOpenDiscoverySettings}
class={discoverySecondaryButtonClass}
title={`Configure which networks Pulse scans for ${discoveryScanTargetLabel}.`}
>
<SlidersHorizontal class="h-4 w-4" aria-hidden="true" />
Discovery settings
</button>
</Button>
</Show>
<Show when={discoveredCandidateCount() > 0 && props.onReviewDiscoveredSource}>
<button
<Button
type="button"
variant="secondary"
size="mdCompact"
class="min-h-9 gap-2"
onClick={() => props.onReviewDiscoveredSource?.(props.discoveredNodes()[0])}
class={discoverySecondaryButtonClass}
>
<Search class="h-4 w-4" aria-hidden="true" />
{discoveredCandidateCount() === 1 ? 'Review candidate' : 'Review first candidate'}
</button>
</Button>
</Show>
</div>
</Show>
@ -752,15 +752,17 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
setupConfidenceAction().kind !== 'agent'
}
>
<button
<Button
type="button"
variant="secondary"
size="mdCompact"
onClick={() => setupConfidenceAction().onClick?.()}
disabled={setupConfidenceAction().disabled}
class={`${workspaceSecondaryButtonClass} self-start xl:self-center`}
class="min-h-9 gap-2 self-start xl:self-center"
>
{setupConfidenceActionIcon(setupConfidenceAction().kind)}
{setupConfidenceAction().label}
</button>
</Button>
</Show>
</div>

View file

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { DataHandlingPanel } from '../DataHandlingPanel';
const unifiedResourcesState = vi.hoisted(() => ({
@ -18,6 +19,13 @@ vi.mock('@/hooks/useUnifiedResources', () => ({
}),
}));
const renderPanel = () =>
render(() => (
<Router>
<Route path="/" component={() => <DataHandlingPanel />} />
</Router>
));
describe('DataHandlingPanel', () => {
beforeEach(() => {
unifiedResourcesState.error = null;
@ -33,10 +41,11 @@ describe('DataHandlingPanel', () => {
afterEach(() => {
cleanup();
window.history.replaceState({}, '', '/');
});
it('explains the empty resource posture instead of leading with zero-value counters', () => {
render(() => <DataHandlingPanel />);
renderPanel();
expect(screen.getByText('Resource Data Policy')).toBeInTheDocument();
expect(screen.getByText('Read-only resource privacy posture')).toBeInTheDocument();
@ -68,7 +77,7 @@ describe('DataHandlingPanel', () => {
},
};
render(() => <DataHandlingPanel />);
renderPanel();
expect(screen.getByText('Governed Resources')).toBeInTheDocument();
expect(screen.getByText('Local-Only')).toBeInTheDocument();

View file

@ -18,6 +18,7 @@ describe('Button', () => {
"secondary: 'border border-border bg-surface text-base-content shadow-sm hover:bg-surface-hover'",
);
expect(buttonModelSource).toContain('export const BUTTON_SIZE_CLASSES');
expect(buttonModelSource).toContain("mdCompact: 'px-3 py-2 text-sm'");
});
it('renders command buttons with the shared secondary shell', () => {

View file

@ -106,6 +106,7 @@ import incidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePan
import thresholdsTableDockerIgnoredPrefixesSectionSource from '@/components/Alerts/ThresholdsTableDockerIgnoredPrefixesSection.tsx?raw';
import webhookConfigFormSource from '@/components/Alerts/WebhookConfigForm.tsx?raw';
import reportMergeModalSource from '@/components/Infrastructure/ReportMergeModal.tsx?raw';
import availabilitySettingsPanelSource from '@/components/Settings/AvailabilitySettingsPanel.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';
@ -170,6 +171,7 @@ import diagnosticsResultsPanelSource from '@/components/Settings/DiagnosticsResu
import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw';
import dataHandlingPanelSource from '@/components/Settings/DataHandlingPanel.tsx?raw';
import generalSettingsPanelSource from '@/components/Settings/GeneralSettingsPanel.tsx?raw';
import infrastructureInstallerSectionSource from '@/components/Settings/InfrastructureInstallerSection.tsx?raw';
import organizationAccessMembersSectionSource from '@/components/Settings/OrganizationAccessMembersSection.tsx?raw';
import organizationIncomingSharesSectionSource from '@/components/Settings/OrganizationIncomingSharesSection.tsx?raw';
import organizationOutgoingSharesSectionSource from '@/components/Settings/OrganizationOutgoingSharesSection.tsx?raw';
@ -1505,13 +1507,20 @@ describe('shared primitive guardrails', () => {
const registeredGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-secondary-command-local-shell',
);
const settingsActionGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-secondary-settings-action-local-shell',
);
expect(registeredRule?.canonical?.path).toBe('src/components/shared/Button.tsx');
expect(registeredRule?.canonical?.export).toBe('Button');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/AI/Chat/ChatMessages.tsx',
'src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx',
'src/components/Settings/AgentProfilesPanel.tsx',
'src/components/Settings/AvailabilitySettingsPanel.tsx',
'src/components/Settings/DataHandlingPanel.tsx',
'src/components/Settings/InfrastructureInstallerSection.tsx',
'src/components/Settings/InfrastructureSourceManager.tsx',
'src/features/patrol/PatrolIntelligenceWorkspace.tsx',
'src/features/standalone/StandalonePageSurface.tsx',
]);
@ -1523,6 +1532,18 @@ describe('shared primitive guardrails', () => {
expect(registeredGuard?.scopes).toEqual(['src/components', 'src/features', 'src/pages']);
expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0);
expect(registeredGuard?.ignoredPaths).toEqual(['src/components/shared/Button.test.tsx']);
expect(settingsActionGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts');
expect(settingsActionGuard?.canonical?.export).toBe('getButtonClass');
expect(settingsActionGuard?.allPatterns).toEqual([
'rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content',
]);
expect(settingsActionGuard?.scopes).toEqual([
'src/components/Settings',
'src/features',
'src/pages',
]);
expect(settingsActionGuard?.allowedPaths ?? []).toHaveLength(0);
expect(settingsActionGuard?.ignoredPaths).toEqual(['src/components/shared/Button.test.tsx']);
expect(buttonSource).toContain('export function Button');
expect(buttonSource).toContain('export function ButtonLink');
@ -1537,7 +1558,27 @@ describe('shared primitive guardrails', () => {
expect(resourceDetailDrawerDebugTabSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content',
);
expect(agentProfilesPanelSource).toContain('@/components/shared/Button');
expect(agentProfilesPanelSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content',
);
expect(availabilitySettingsPanelSource).toContain('@/components/shared/Button');
expect(availabilitySettingsPanelSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content',
);
expect(dataHandlingPanelSource).toContain('@/components/shared/Button');
expect(dataHandlingPanelSource).toContain('ButtonLink');
expect(dataHandlingPanelSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content',
);
expect(infrastructureInstallerSectionSource).toContain('@/components/shared/Button');
expect(infrastructureInstallerSectionSource).not.toContain(
'rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content',
);
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',
);
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 = 'sm' | 'md' | 'lg' | 'icon';
export type ButtonSize = 'sm' | 'mdCompact' | 'md' | 'lg' | 'icon';
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';
@ -14,6 +14,7 @@ export const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
export const BUTTON_SIZE_CLASSES: Record<ButtonSize, string> = {
sm: 'px-2.5 py-1.5 text-xs',
mdCompact: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
icon: 'p-2',