From e27b981701eb6de50c8dd604b5601c4560b93fa3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Jun 2026 18:37:07 +0100 Subject: [PATCH] Canonicalize copy value controls --- .../subsystems/frontend-primitives.md | 7 + .../scripts/shared-template-registry.json | 156 +++++++++++++++++- .../src/components/Discovery/DiscoveryTab.tsx | 116 ++++--------- .../src/components/shared/Button.test.tsx | 78 ++++++++- .../src/components/shared/Button.tsx | 80 ++++++++- .../src/components/shared/CopyableCodeRow.tsx | 37 +++++ .../SharedPrimitives.guardrails.test.ts | 86 +++++++++- .../shared/WebInterfaceUrlField.tsx | 72 ++++---- .../src/components/shared/buttonModel.ts | 40 +++++ 9 files changed, 527 insertions(+), 145 deletions(-) create mode 100644 frontend-modern/src/components/shared/CopyableCodeRow.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index c2e6192d2..93b197935 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -166,6 +166,7 @@ work extends shared components instead of creating new local variants. 137. `frontend-modern/src/components/shared/Button.tsx` 138. `frontend-modern/src/components/shared/buttonModel.ts` 139. `frontend-modern/src/components/shared/Button.test.tsx` +140. `frontend-modern/src/components/shared/CopyableCodeRow.tsx` ## Shared Boundaries @@ -473,6 +474,12 @@ not a replacement status card, CTA band, or page-local nested card. 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. + Copy-value affordances belong to the same shared button family. Feature + surfaces may own the copied value, success/error notification, and adjacent + product copy, but icon/chip copy controls must use `CopyValueButton`, and + copyable command/path/value rows must use `CopyableCodeRow` instead of + recreating local copy icons, copied-state checks, disabled empty-value + handling, or `font-mono` code-row shells. 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 diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index f2c5d1923..cd8dc9dc9 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -220,6 +220,66 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "copy-value-action-shell", + "category": "copy-action", + "summary": "Copy-value icon and chip controls must compose CopyValueButton for shared copied-state iconography, disabled handling, focus, sizing, and aria behavior instead of recreating page-local copy buttons.", + "canonical": { + "path": "src/components/shared/Button.tsx", + "export": "CopyValueButton" + }, + "requiredConsumers": [ + { "path": "src/components/Discovery/DiscoveryTab.tsx" }, + { "path": "src/components/shared/WebInterfaceUrlField.tsx" } + ], + "forbiddenPatterns": [ + { + "path": "src/components/Discovery/DiscoveryTab.tsx", + "patterns": [ + "interface CopyValueButtonProps", + "const CopyValueButton", + "CopyIcon class=\"h-3.5 w-3.5\"", + "CheckIcon class=\"h-3.5 w-3.5 text-emerald-600" + ] + }, + { + "path": "src/components/shared/WebInterfaceUrlField.tsx", + "patterns": [ + "CopyIcon class=\"h-3.5 w-3.5\"", + "CheckIcon class=\"h-3.5 w-3.5 text-emerald-600" + ] + } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "copyable-code-row-shell", + "category": "copy-action", + "summary": "Copyable code/value rows must compose CopyableCodeRow for the shared code surface and copy action instead of recreating local font-mono row shells.", + "canonical": { + "path": "src/components/shared/CopyableCodeRow.tsx", + "export": "CopyableCodeRow" + }, + "requiredConsumers": [{ "path": "src/components/Discovery/DiscoveryTab.tsx" }], + "forbiddenPatterns": [ + { + "path": "src/components/Discovery/DiscoveryTab.tsx", + "patterns": [ + "const CopyableCodeRow", + "flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5" + ] + } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "form-select-shell", "category": "form-control", @@ -1100,6 +1160,94 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "copy-value-neutral-local-button-shell", + "category": "copy-action", + "summary": "Copy-value controls must not copy the neutral bordered icon-button shell; use CopyValueButton with the neutral variant.", + "canonical": { + "path": "src/components/shared/Button.tsx", + "export": "CopyValueButton" + }, + "scopes": ["src/components", "src/features", "src/pages"], + "extensions": [".tsx"], + "allPatterns": [ + "inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded border border-border bg-surface px-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content", + "fallback={}" + ], + "legacyReason": "Retired migration debt. Neutral copy-value icon buttons belong to CopyValueButton.", + "allowedPaths": [], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "copy-value-muted-local-button-shell", + "category": "copy-action", + "summary": "Copy-value controls must not copy the muted icon-button shell; use CopyValueButton with the ghost variant.", + "canonical": { + "path": "src/components/shared/Button.tsx", + "export": "CopyValueButton" + }, + "scopes": ["src/components", "src/features", "src/pages"], + "extensions": [".tsx"], + "allPatterns": [ + "inline-flex min-h-8 min-w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content", + "fallback={}" + ], + "legacyReason": "Retired migration debt. Muted copy-value icon buttons belong to CopyValueButton.", + "allowedPaths": [], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "copy-value-accent-local-button-shell", + "category": "copy-action", + "summary": "Copy-value controls must not copy the blue suggested-value icon-button shell; use CopyValueButton with the accent variant.", + "canonical": { + "path": "src/components/shared/Button.tsx", + "export": "CopyValueButton" + }, + "scopes": ["src/components", "src/features", "src/pages"], + "extensions": [".tsx"], + "allPatterns": [ + "inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950", + "fallback={}" + ], + "legacyReason": "Retired migration debt. Accent copy-value icon buttons belong to CopyValueButton.", + "allowedPaths": [], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "copyable-code-row-local-shell", + "category": "copy-action", + "summary": "Copyable code/value rows must not copy the local surface-alt font-mono row shell; use CopyableCodeRow.", + "canonical": { + "path": "src/components/shared/CopyableCodeRow.tsx", + "export": "CopyableCodeRow" + }, + "scopes": ["src/components", "src/features", "src/pages"], + "extensions": [".tsx"], + "allPatterns": [ + "flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5", + "break-all font-mono text-xs text-base-content" + ], + "legacyReason": "Retired migration debt. Copyable code rows belong to CopyableCodeRow so code styling and copy behavior stay canonical.", + "allowedPaths": [], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/components/shared/Button.test.tsx", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "button-secondary-settings-action-local-shell", "category": "action-button", @@ -1154,9 +1302,7 @@ }, "scopes": ["src/components/Settings", "src/features", "src/pages"], "extensions": [".tsx"], - "allPatterns": [ - "rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white" - ], + "allPatterns": ["rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white"], "legacyReason": "Retired migration debt. Primary settings actions belong to the shared Button primitive family.", "allowedPaths": [], "ignoredPaths": ["src/components/shared/Button.test.tsx"], @@ -1176,9 +1322,7 @@ }, "scopes": ["src/components/Settings", "src/features", "src/pages"], "extensions": [".tsx"], - "allPatterns": [ - "rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white" - ], + "allPatterns": ["rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white"], "legacyReason": "Retired migration debt. Confirmed destructive settings actions belong to the shared Button primitive family.", "allowedPaths": [], "ignoredPaths": ["src/components/shared/Button.test.tsx"], diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index 0e12499d6..e7f9d7870 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -1,6 +1,4 @@ import { Component, For, Show, createMemo } from 'solid-js'; -import CheckIcon from 'lucide-solid/icons/check'; -import CopyIcon from 'lucide-solid/icons/copy'; import ExternalLinkIcon from 'lucide-solid/icons/external-link'; import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; import type { ResourceType } from '../../types/discovery'; @@ -31,6 +29,8 @@ import { DISCOVERY_ANALYSIS_EXPLANATION, DISCOVERY_ANALYSIS_REASONING_LABEL, } from '@/utils/resourceAnalysisPresentation'; +import { CopyValueButton } from '@/components/shared/Button'; +import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow'; import { useDiscoveryTabState } from './useDiscoveryTabState'; import { orderFactsByActionability } from './factOrdering'; import { deriveCliCommand } from './cliCommand'; @@ -51,54 +51,6 @@ interface DiscoveryTabProps { showManualRunAction?: boolean; } -interface CopyValueButtonProps { - value?: string | null; - copiedValue: () => string; - onCopy: (value?: string | null) => void | Promise; - label: string; - class?: string; -} - -const CopyValueButton: Component = (props) => { - const trimmedValue = () => (props.value || '').trim(); - const copied = () => Boolean(trimmedValue()) && props.copiedValue() === trimmedValue(); - - return ( - - ); -}; - -interface CopyableCodeRowProps extends CopyValueButtonProps { - value: string; -} - -const CopyableCodeRow: Component = (props) => ( -
- {props.value} - -
-); - export const DiscoveryTab: Component = (props) => { const { canTriggerDiscovery, @@ -787,10 +739,11 @@ export const DiscoveryTab: Component = (props) => { Version {d().service_version} @@ -867,10 +820,11 @@ export const DiscoveryTab: Component = (props) => {

@@ -898,8 +852,8 @@ export const DiscoveryTab: Component = (props) => { >

@@ -930,8 +884,8 @@ export const DiscoveryTab: Component = (props) => { {(path) => ( )} @@ -950,8 +904,8 @@ export const DiscoveryTab: Component = (props) => { {(path) => ( )} @@ -970,8 +924,8 @@ export const DiscoveryTab: Component = (props) => { {(path) => ( )} @@ -997,8 +951,8 @@ export const DiscoveryTab: Component = (props) => {

@@ -1024,14 +978,13 @@ export const DiscoveryTab: Component = (props) => {
{(port) => ( - + )}
@@ -1070,10 +1017,11 @@ export const DiscoveryTab: Component = (props) => {
diff --git a/frontend-modern/src/components/shared/Button.test.tsx b/frontend-modern/src/components/shared/Button.test.tsx index 1cffba6d3..324c3f6a5 100644 --- a/frontend-modern/src/components/shared/Button.test.tsx +++ b/frontend-modern/src/components/shared/Button.test.tsx @@ -1,9 +1,11 @@ import { Route, Router } from '@solidjs/router'; import { cleanup, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Button, ButtonLink, CommandCopyButton } from './Button'; +import { Button, ButtonLink, CommandCopyButton, CopyValueButton } from './Button'; import buttonSource from './Button.tsx?raw'; import buttonModelSource from './buttonModel.ts?raw'; +import { CopyableCodeRow } from './CopyableCodeRow'; +import copyableCodeRowSource from './CopyableCodeRow.tsx?raw'; describe('Button', () => { afterEach(() => { @@ -14,16 +16,25 @@ describe('Button', () => { it('keeps shell styling in the shared model', () => { expect(buttonSource).toContain('getButtonClass'); expect(buttonModelSource).toContain('export const BUTTON_VARIANT_CLASSES'); + expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_VARIANT_CLASSES'); + expect(buttonModelSource).toContain('export const COPY_VALUE_BUTTON_SIZE_CLASSES'); + expect(buttonModelSource).toContain('getCopyValueButtonClass'); expect(buttonModelSource).toContain( "secondary: 'border border-border bg-surface text-base-content shadow-sm hover:bg-surface-hover'", ); + expect(buttonModelSource).toContain('primaryFlat:'); + expect(buttonModelSource).toContain( + "'border border-border bg-surface text-muted hover:bg-surface-hover hover:text-base-content'", + ); + expect(buttonModelSource).toContain( + "accent: 'text-blue-700 hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950'", + ); expect(buttonModelSource).toContain('dangerOutline:'); 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( - "settingsAction: 'min-h-10 px-3 py-2 text-sm sm:min-h-9'", - ); + expect(buttonModelSource).toContain("settingsAction: 'min-h-10 px-3 py-2 text-sm sm:min-h-9'"); + expect(buttonModelSource).toContain("chip: 'gap-1 px-1.5 py-0.5 text-[10px]'"); expect(buttonModelSource).toContain("iconMd: 'h-9 w-9 p-0'"); }); @@ -99,6 +110,65 @@ describe('Button', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + it('renders copy-value icon and chip buttons through the shared primitive', () => { + const onCopy = vi.fn(); + + render(() => ( + <> + + + 8443/tcp + + + + )); + + const copyUrlButton = screen.getByRole('button', { name: 'Copy URL' }); + expect(copyUrlButton).toHaveClass('border-border'); + expect(copyUrlButton).toHaveClass('min-h-7'); + copyUrlButton.click(); + expect(onCopy).toHaveBeenCalledWith('https://example.test'); + + const chipButton = screen.getByRole('button', { name: 'Copy 8443/tcp' }); + expect(chipButton).toHaveClass('bg-surface-alt'); + expect(chipButton).toHaveClass('text-[10px]'); + + expect(screen.getByRole('button', { name: 'Copy blank' })).toBeDisabled(); + }); + + it('renders copyable code rows through the shared copy primitive', () => { + const onCopy = vi.fn(); + + render(() => ( + + )); + + expect(copyableCodeRowSource).toContain('CopyValueButton'); + expect(screen.getByText('/etc/pulse/config.yml')).toHaveClass('font-mono'); + + const copyButton = screen.getByRole('button', { name: 'Copy config path' }); + expect(copyButton).toHaveClass('min-h-6'); + copyButton.click(); + expect(onCopy).toHaveBeenCalledWith('/etc/pulse/config.yml'); + }); + it('renders in-app button links through the router', () => { render(() => ( diff --git a/frontend-modern/src/components/shared/Button.tsx b/frontend-modern/src/components/shared/Button.tsx index 3a462258d..0b46ed028 100644 --- a/frontend-modern/src/components/shared/Button.tsx +++ b/frontend-modern/src/components/shared/Button.tsx @@ -1,7 +1,15 @@ import { A } from '@solidjs/router'; +import CheckIcon from 'lucide-solid/icons/check'; import CopyIcon from 'lucide-solid/icons/copy'; import { JSX, Show, mergeProps, splitProps } from 'solid-js'; -import { getButtonClass, type ButtonSize, type ButtonVariant } from './buttonModel'; +import { + getButtonClass, + getCopyValueButtonClass, + type ButtonSize, + type ButtonVariant, + type CopyValueButtonSize, + type CopyValueButtonVariant, +} from './buttonModel'; export interface ButtonProps extends JSX.ButtonHTMLAttributes { variant?: ButtonVariant; @@ -18,11 +26,27 @@ export interface ButtonLinkProps extends JSX.AnchorHTMLAttributes { +export interface CommandCopyButtonProps extends Omit< + ButtonProps, + 'children' | 'isLoading' | 'size' | 'variant' +> { label?: string; } +export interface CopyValueButtonProps extends Omit< + JSX.ButtonHTMLAttributes, + 'children' | 'onClick' | 'value' +> { + value?: string | null; + copied?: boolean; + onCopyValue: (value: string) => void | Promise; + label: string; + variant?: CopyValueButtonVariant; + size?: CopyValueButtonSize; + class?: string; + children?: JSX.Element; +} + export function Button(props: ButtonProps) { const merged = mergeProps( { variant: 'secondary' as ButtonVariant, size: 'md' as ButtonSize, type: 'button' as const }, @@ -97,6 +121,56 @@ export function CommandCopyButton(props: CommandCopyButtonProps) { ); } +export function CopyValueButton(props: CopyValueButtonProps) { + const merged = mergeProps( + { + variant: 'neutral' as CopyValueButtonVariant, + size: 'md' as CopyValueButtonSize, + type: 'button' as const, + }, + props, + ); + const [local, rest] = splitProps(merged, [ + 'value', + 'copied', + 'onCopyValue', + 'label', + 'variant', + 'size', + 'class', + 'children', + 'disabled', + 'title', + 'aria-label', + ]); + const trimmedValue = () => (local.value ?? '').trim(); + + return ( + + ); +} + export function ButtonLink(props: ButtonLinkProps) { const merged = mergeProps( { variant: 'secondary' as ButtonVariant, size: 'md' as ButtonSize }, diff --git a/frontend-modern/src/components/shared/CopyableCodeRow.tsx b/frontend-modern/src/components/shared/CopyableCodeRow.tsx new file mode 100644 index 000000000..14da94cc7 --- /dev/null +++ b/frontend-modern/src/components/shared/CopyableCodeRow.tsx @@ -0,0 +1,37 @@ +import { Component } from 'solid-js'; +import { CopyValueButton, type CopyValueButtonProps } from './Button'; + +export interface CopyableCodeRowProps extends Pick< + CopyValueButtonProps, + 'copied' | 'label' | 'onCopyValue' +> { + value: string; + class?: string; + codeClass?: string; +} + +export const CopyableCodeRow: Component = (props) => ( +
+ + {props.value} + + +
+); + +export default CopyableCodeRow; diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 98e11eaaa..6e738d667 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -8,6 +8,7 @@ import aiChatSource from '@/components/AI/Chat/index.tsx?raw'; import aiModelPickerSource from '@/components/shared/AIModelPicker.tsx?raw'; import buttonSource from '@/components/shared/Button.tsx?raw'; import buttonModelSource from '@/components/shared/buttonModel.ts?raw'; +import copyableCodeRowSource from '@/components/shared/CopyableCodeRow.tsx?raw'; import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw'; import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw'; import columnPickerSource from '@/components/shared/ColumnPicker.tsx?raw'; @@ -101,6 +102,7 @@ import webInterfaceUrlFieldStateSource from '@/components/shared/useWebInterface import webInterfaceNameLinkSource from '@/components/shared/WebInterfaceNameLink.tsx?raw'; import inlineDetailTableRowSource from '@/components/shared/InlineDetailTableRow.tsx?raw'; import sharedTemplateRegistrySource from '../../../scripts/shared-template-registry.json?raw'; +import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw'; import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw'; import incidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw'; import thresholdsTableDockerIgnoredPrefixesSectionSource from '@/components/Alerts/ThresholdsTableDockerIgnoredPrefixesSection.tsx?raw'; @@ -1540,6 +1542,22 @@ describe('shared primitive guardrails', () => { const settingsDialogCloseGuard = registry.patternGuards?.find( (guard) => guard.id === 'button-outline-settings-dialog-close-local-shell', ); + const copyValueRule = registry.rules?.find((rule) => rule.id === 'copy-value-action-shell'); + const copyableCodeRowRule = registry.rules?.find( + (rule) => rule.id === 'copyable-code-row-shell', + ); + const copyValueNeutralGuard = registry.patternGuards?.find( + (guard) => guard.id === 'copy-value-neutral-local-button-shell', + ); + const copyValueMutedGuard = registry.patternGuards?.find( + (guard) => guard.id === 'copy-value-muted-local-button-shell', + ); + const copyValueAccentGuard = registry.patternGuards?.find( + (guard) => guard.id === 'copy-value-accent-local-button-shell', + ); + const copyableCodeRowGuard = registry.patternGuards?.find( + (guard) => guard.id === 'copyable-code-row-local-shell', + ); expect(registeredRule?.canonical?.path).toBe('src/components/shared/Button.tsx'); expect(registeredRule?.canonical?.export).toBe('Button'); @@ -1625,9 +1643,7 @@ describe('shared primitive guardrails', () => { expect(settingsPrimaryActionGuard?.ignoredPaths).toEqual([ 'src/components/shared/Button.test.tsx', ]); - expect(settingsDangerActionGuard?.canonical?.path).toBe( - 'src/components/shared/buttonModel.ts', - ); + expect(settingsDangerActionGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts'); expect(settingsDangerActionGuard?.canonical?.export).toBe('getButtonClass'); expect(settingsDangerActionGuard?.allPatterns).toEqual([ 'rounded-md bg-rose-600 px-3 py-2 text-sm font-medium text-white', @@ -1669,9 +1685,7 @@ 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?.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', @@ -1685,15 +1699,57 @@ describe('shared primitive guardrails', () => { expect(settingsDialogCloseGuard?.ignoredPaths).toEqual([ 'src/components/shared/Button.test.tsx', ]); + expect(copyValueRule?.canonical?.path).toBe('src/components/shared/Button.tsx'); + expect(copyValueRule?.canonical?.export).toBe('CopyValueButton'); + expect(copyValueRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ + 'src/components/Discovery/DiscoveryTab.tsx', + 'src/components/shared/WebInterfaceUrlField.tsx', + ]); + expect(copyableCodeRowRule?.canonical?.path).toBe('src/components/shared/CopyableCodeRow.tsx'); + expect(copyableCodeRowRule?.canonical?.export).toBe('CopyableCodeRow'); + expect(copyableCodeRowRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ + 'src/components/Discovery/DiscoveryTab.tsx', + ]); + expect(copyValueNeutralGuard?.canonical?.path).toBe('src/components/shared/Button.tsx'); + expect(copyValueNeutralGuard?.canonical?.export).toBe('CopyValueButton'); + expect(copyValueNeutralGuard?.allPatterns).toEqual([ + 'inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded border border-border bg-surface px-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content', + 'fallback={}', + ]); + expect(copyValueMutedGuard?.canonical?.path).toBe('src/components/shared/Button.tsx'); + expect(copyValueMutedGuard?.canonical?.export).toBe('CopyValueButton'); + expect(copyValueMutedGuard?.allPatterns).toEqual([ + 'inline-flex min-h-8 min-w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content', + 'fallback={}', + ]); + expect(copyValueAccentGuard?.canonical?.path).toBe('src/components/shared/Button.tsx'); + expect(copyValueAccentGuard?.canonical?.export).toBe('CopyValueButton'); + expect(copyValueAccentGuard?.allPatterns).toEqual([ + 'inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950', + 'fallback={}', + ]); + expect(copyableCodeRowGuard?.canonical?.path).toBe('src/components/shared/CopyableCodeRow.tsx'); + expect(copyableCodeRowGuard?.canonical?.export).toBe('CopyableCodeRow'); + expect(copyableCodeRowGuard?.allPatterns).toEqual([ + 'flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5', + 'break-all font-mono text-xs text-base-content', + ]); expect(buttonSource).toContain('export function Button'); expect(buttonSource).toContain('export function CommandCopyButton'); + expect(buttonSource).toContain('export function CopyValueButton'); expect(buttonSource).toContain('export function ButtonLink'); expect(buttonSource).toContain('getButtonClass'); + expect(buttonSource).toContain('getCopyValueButtonClass'); + expect(copyableCodeRowSource).toContain('CopyValueButton'); expect(buttonModelSource).toContain('BUTTON_VARIANT_CLASSES'); expect(buttonModelSource).toContain('BUTTON_SIZE_CLASSES'); + expect(buttonModelSource).toContain('primaryFlat:'); + expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_VARIANT_CLASSES'); + expect(buttonModelSource).toContain('COPY_VALUE_BUTTON_SIZE_CLASSES'); expect(buttonModelSource).toContain('dangerOutline:'); expect(buttonModelSource).toContain('settingsAction:'); + expect(buttonModelSource).toContain('getCopyValueButtonClass'); expect(chatMessagesSource).toContain('@/components/shared/Button'); expect(chatMessagesSource).not.toContain( 'rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content', @@ -1800,6 +1856,24 @@ describe('shared primitive guardrails', () => { 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(discoveryTabSource).toContain('@/components/shared/Button'); + expect(discoveryTabSource).toContain('@/components/shared/CopyableCodeRow'); + expect(discoveryTabSource).toContain('CopyValueButton'); + expect(discoveryTabSource).toContain('CopyableCodeRow'); + expect(discoveryTabSource).not.toContain('interface CopyValueButtonProps'); + expect(discoveryTabSource).not.toContain('const CopyValueButton'); + expect(discoveryTabSource).not.toContain('const CopyableCodeRow'); + expect(discoveryTabSource).not.toContain( + 'inline-flex min-h-7 min-w-7 shrink-0 items-center justify-center rounded border border-border bg-surface px-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content', + ); + expect(discoveryTabSource).not.toContain( + 'flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5', + ); + expect(webInterfaceUrlFieldSource).toContain('CopyValueButton'); + expect(webInterfaceUrlFieldSource).not.toContain('CopyIcon class="h-3.5 w-3.5"'); + expect(webInterfaceUrlFieldSource).not.toContain( + 'inline-flex min-h-8 min-w-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover: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/WebInterfaceUrlField.tsx b/frontend-modern/src/components/shared/WebInterfaceUrlField.tsx index 81b0f000d..e9410a160 100644 --- a/frontend-modern/src/components/shared/WebInterfaceUrlField.tsx +++ b/frontend-modern/src/components/shared/WebInterfaceUrlField.tsx @@ -1,7 +1,6 @@ import { Component, Show } from 'solid-js'; -import CheckIcon from 'lucide-solid/icons/check'; -import CopyIcon from 'lucide-solid/icons/copy'; import ExternalLinkIcon from 'lucide-solid/icons/external-link'; +import { Button, CopyValueButton } from './Button'; import { DiscoveryProvenanceMarker } from './DiscoveryProvenanceMarker'; import { useWebInterfaceUrlFieldState } from './useWebInterfaceUrlFieldState'; import type { WebInterfaceUrlFieldProps } from './webInterfaceUrlFieldModel'; @@ -36,14 +35,14 @@ export const WebInterfaceUrlField: Component = (props }} disabled={state.urlSaving()} /> - + = (props - + - + @@ -145,28 +138,23 @@ export const WebInterfaceUrlField: Component = (props > - - + diff --git a/frontend-modern/src/components/shared/buttonModel.ts b/frontend-modern/src/components/shared/buttonModel.ts index e06020b31..0b9ecef8f 100644 --- a/frontend-modern/src/components/shared/buttonModel.ts +++ b/frontend-modern/src/components/shared/buttonModel.ts @@ -1,5 +1,6 @@ export type ButtonVariant = | 'primary' + | 'primaryFlat' | 'secondary' | 'danger' | 'dangerOutline' @@ -20,6 +21,7 @@ export const BUTTON_BASE_CLASS = export const BUTTON_VARIANT_CLASSES: Record = { primary: 'border border-transparent bg-blue-600 text-white shadow-sm hover:bg-blue-700', + primaryFlat: 'border border-transparent bg-blue-600 text-white hover:bg-blue-700', secondary: 'border border-border bg-surface text-base-content shadow-sm hover:bg-surface-hover', danger: 'border border-transparent bg-rose-600 text-white shadow-sm hover:bg-rose-700', dangerOutline: @@ -54,3 +56,41 @@ export const getButtonClass = (options: ButtonClassOptions = {}): string => ] .filter(Boolean) .join(' '); + +export type CopyValueButtonVariant = 'neutral' | 'ghost' | 'accent' | 'chip'; +export type CopyValueButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'chip'; + +export const COPY_VALUE_BUTTON_BASE_CLASS = + 'inline-flex shrink-0 items-center justify-center rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'; + +export const COPY_VALUE_BUTTON_VARIANT_CLASSES: Record = { + neutral: + 'border border-border bg-surface text-muted hover:bg-surface-hover hover:text-base-content', + ghost: 'text-muted hover:bg-surface-hover hover:text-base-content', + accent: 'text-blue-700 hover:bg-blue-100 dark:text-blue-200 dark:hover:bg-blue-950', + chip: 'bg-surface-alt text-base-content hover:bg-surface-hover', +}; + +export const COPY_VALUE_BUTTON_SIZE_CLASSES: Record = { + xs: 'min-h-5 min-w-5', + sm: 'min-h-6 min-w-6', + md: 'min-h-7 min-w-7', + lg: 'min-h-8 min-w-8 rounded-md', + chip: 'gap-1 px-1.5 py-0.5 text-[10px]', +}; + +export type CopyValueButtonClassOptions = { + variant?: CopyValueButtonVariant; + size?: CopyValueButtonSize; + class?: string; +}; + +export const getCopyValueButtonClass = (options: CopyValueButtonClassOptions = {}): string => + [ + COPY_VALUE_BUTTON_BASE_CLASS, + COPY_VALUE_BUTTON_VARIANT_CLASSES[options.variant ?? 'neutral'], + COPY_VALUE_BUTTON_SIZE_CLASSES[options.size ?? 'md'], + options.class, + ] + .filter(Boolean) + .join(' ');