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.

- + } > 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 - + 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
- + - + 0 && props.onReviewDiscoveredSource}> - +
@@ -752,15 +752,17 @@ export const InfrastructureSourceManager: Component - + 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',