= (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(' ');