From 60f153611b6777d716945e8dd9c02c76fd0629d2 Mon Sep 17 00:00:00 2001
From: rcourtman
Date: Fri, 12 Jun 2026 17:31:46 +0100
Subject: [PATCH] Broaden shared Button settings governance
---
.../subsystems/frontend-primitives.md | 8 +--
.../scripts/shared-template-registry.json | 26 ++++++++++
.../Settings/AgentProfilesPanel.tsx | 21 ++++----
.../Settings/AvailabilitySettingsPanel.tsx | 29 ++++++-----
.../components/Settings/DataHandlingPanel.tsx | 10 ++--
.../InfrastructureInstallerSection.tsx | 8 +--
.../Settings/InfrastructureSourceManager.tsx | 52 ++++++++++---------
.../__tests__/DataHandlingPanel.test.tsx | 13 ++++-
.../src/components/shared/Button.test.tsx | 1 +
.../SharedPrimitives.guardrails.test.ts | 41 +++++++++++++++
.../src/components/shared/buttonModel.ts | 3 +-
11 files changed, 152 insertions(+), 60 deletions(-)
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index d8cb970c69..c57c7106ea 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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.
diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json
index 684aedc522..63a3fb299f 100644
--- a/frontend-modern/scripts/shared-template-registry.json
+++ b/frontend-modern/scripts/shared-template-registry.json
@@ -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": [
diff --git a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx
index 2c536e5ad1..d786c113a3 100644
--- a/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx
+++ b/frontend-modern/src/components/Settings/AgentProfilesPanel.tsx
@@ -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 = () => {
-
- {featureGateCopy.title}
-
+
{featureGateCopy.title}
{featureGateCopy.subtitle}
@@ -116,25 +115,29 @@ export const AgentProfilesPanel: Component = () => {
description="Reusable agent configurations"
action={
-
+
-
+
}
diff --git a/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx b/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx
index d9ba4f3ef1..d9e6b72c96 100644
--- a/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx
+++ b/frontend-modern/src/components/Settings/AvailabilitySettingsPanel.tsx
@@ -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 = () => {
-
-
@@ -253,14 +254,16 @@ export const AvailabilitySettingsPanel: Component = () => {
endpoint that only needs reachability monitoring.
- openAddDialog('service')}
- class={primaryButtonClass}
>
Add service/device check
-
+
}
>
diff --git a/frontend-modern/src/components/Settings/DataHandlingPanel.tsx b/frontend-modern/src/components/Settings/DataHandlingPanel.tsx
index ff54786a88..45ca91b031 100644
--- a/frontend-modern/src/components/Settings/DataHandlingPanel.tsx
+++ b/frontend-modern/src/components/Settings/DataHandlingPanel.tsx
@@ -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.
-
Open Infrastructure
-
+
);
diff --git a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx
index 661eebdf35..9328ce7cec 100644
--- a/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx
+++ b/frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx
@@ -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
- 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'}
-
+
diff --git a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx
index 6f36fabdd3..3b2d43646d 100644
--- a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx
+++ b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx
@@ -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
@@ -671,11 +665,13 @@ export const InfrastructureSourceManager: Component
-
{props.discoveryScanStatus().scanning ? 'Scanning...' : 'Run discovery'}
-
+
-
Discovery settings
-
+
0 && props.onReviewDiscoveredSource}>
- props.onReviewDiscoveredSource?.(props.discoveredNodes()[0])}
- class={discoverySecondaryButtonClass}
>
{discoveredCandidateCount() === 1 ? 'Review candidate' : 'Review first candidate'}
-
+
@@ -752,15 +752,17 @@ export const InfrastructureSourceManager: Component
- 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}
-
+
diff --git a/frontend-modern/src/components/Settings/__tests__/DataHandlingPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/DataHandlingPanel.test.tsx
index 82cd3b1e24..37b59ff49c 100644
--- a/frontend-modern/src/components/Settings/__tests__/DataHandlingPanel.test.tsx
+++ b/frontend-modern/src/components/Settings/__tests__/DataHandlingPanel.test.tsx
@@ -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(() => (
+
+ } />
+
+ ));
+
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(() => );
+ renderPanel();
expect(screen.getByText('Resource Data Policy')).toBeInTheDocument();
expect(screen.getByText('Read-only resource privacy posture')).toBeInTheDocument();
@@ -68,7 +77,7 @@ describe('DataHandlingPanel', () => {
},
};
- render(() => );
+ renderPanel();
expect(screen.getByText('Governed Resources')).toBeInTheDocument();
expect(screen.getByText('Local-Only')).toBeInTheDocument();
diff --git a/frontend-modern/src/components/shared/Button.test.tsx b/frontend-modern/src/components/shared/Button.test.tsx
index f139c8f0d6..185134ba2c 100644
--- a/frontend-modern/src/components/shared/Button.test.tsx
+++ b/frontend-modern/src/components/shared/Button.test.tsx
@@ -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', () => {
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index 5d732d4cdd..4a121a550e 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -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',
diff --git a/frontend-modern/src/components/shared/buttonModel.ts b/frontend-modern/src/components/shared/buttonModel.ts
index ddd3a4c435..df3a16b231 100644
--- a/frontend-modern/src/components/shared/buttonModel.ts
+++ b/frontend-modern/src/components/shared/buttonModel.ts
@@ -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 = {
export const BUTTON_SIZE_CLASSES: Record = {
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',