Canonicalize copy value controls

This commit is contained in:
rcourtman
2026-06-12 18:37:07 +01:00
parent efdf6acd13
commit e27b981701
9 changed files with 527 additions and 145 deletions
@@ -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
@@ -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={<CopyIcon class=\"h-3.5 w-3.5\" />}"
],
"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={<CopyIcon class=\"h-3.5 w-3.5\" />}"
],
"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={<CopyIcon class=\"h-3.5 w-3.5\" />}"
],
"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"],
@@ -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<void>;
label: string;
class?: string;
}
const CopyValueButton: Component<CopyValueButtonProps> = (props) => {
const trimmedValue = () => (props.value || '').trim();
const copied = () => Boolean(trimmedValue()) && props.copiedValue() === trimmedValue();
return (
<button
type="button"
class={
props.class ||
'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'
}
onClick={() => void props.onCopy(trimmedValue())}
disabled={!trimmedValue()}
title={props.label}
aria-label={props.label}
>
<Show when={copied()} fallback={<CopyIcon class="h-3.5 w-3.5" />}>
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
</Show>
</button>
);
};
interface CopyableCodeRowProps extends CopyValueButtonProps {
value: string;
}
const CopyableCodeRow: Component<CopyableCodeRowProps> = (props) => (
<div class="flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5">
<code class="min-w-0 flex-1 break-all font-mono text-xs text-base-content">{props.value}</code>
<CopyValueButton
value={props.value}
copiedValue={props.copiedValue}
onCopy={props.onCopy}
label={props.label}
class="inline-flex min-h-6 min-w-6 shrink-0 items-center justify-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
/>
</div>
);
export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
const {
canTriggerDiscovery,
@@ -787,10 +739,11 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<span class="text-xs text-muted">Version {d().service_version}</span>
<CopyValueButton
value={d().service_version}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === (d().service_version ?? '').trim()}
onCopyValue={handleCopyDiscoveryValue}
label="Copy service version"
class="inline-flex min-h-5 min-w-5 shrink-0 items-center justify-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
variant="ghost"
size="xs"
/>
</div>
</Show>
@@ -867,10 +820,11 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</a>
<CopyValueButton
value={d().suggested_url}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === (d().suggested_url ?? '').trim()}
onCopyValue={handleCopyDiscoveryValue}
label="Copy suggested URL"
class={getDiscoverySuggestedURLActionClass()}
variant="accent"
size="md"
/>
</div>
<p class={`mt-1.5 text-[11px] ${getDiscoverySuggestedURLTextClass()}`}>
@@ -898,8 +852,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
>
<CopyableCodeRow
value={cliCommand()!}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === cliCommand()!.trim()}
onCopyValue={handleCopyDiscoveryValue}
label="Copy CLI command"
/>
<p class="mt-1.5 text-[11px] text-muted">
@@ -930,8 +884,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
{(path) => (
<CopyableCodeRow
value={path}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === path.trim()}
onCopyValue={handleCopyDiscoveryValue}
label="Copy config path"
/>
)}
@@ -950,8 +904,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
{(path) => (
<CopyableCodeRow
value={path}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === path.trim()}
onCopyValue={handleCopyDiscoveryValue}
label="Copy data path"
/>
)}
@@ -970,8 +924,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
{(path) => (
<CopyableCodeRow
value={path}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === path.trim()}
onCopyValue={handleCopyDiscoveryValue}
label="Copy log path"
/>
)}
@@ -997,8 +951,8 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<div class="space-y-0.5">
<CopyableCodeRow
value={mount.source}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === mount.source.trim()}
onCopyValue={handleCopyDiscoveryValue}
label="Copy host path"
/>
<div class="pl-1 text-[10px] text-muted">
@@ -1024,14 +978,13 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<div class="flex flex-wrap gap-1">
<For each={d().ports}>
{(port) => (
<button
type="button"
class="inline-flex items-center gap-1 rounded bg-surface-alt px-1.5 py-0.5 text-[10px] text-base-content transition-colors hover:bg-surface-hover"
onClick={() =>
void handleCopyDiscoveryValue(`${port.port}/${port.protocol}`)
}
title="Copy port"
aria-label={`Copy ${port.port}/${port.protocol}`}
<CopyValueButton
value={`${port.port}/${port.protocol}`}
copied={copiedDiscoveryValue() === `${port.port}/${port.protocol}`}
onCopyValue={handleCopyDiscoveryValue}
label={`Copy ${port.port}/${port.protocol}`}
variant="chip"
size="chip"
>
<span>
{port.port}/{port.protocol}
@@ -1039,13 +992,7 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<Show when={port.process}>
<span class="text-muted">({port.process})</span>
</Show>
<Show
when={copiedDiscoveryValue() === `${port.port}/${port.protocol}`}
fallback={<CopyIcon class="h-3 w-3 text-muted" />}
>
<CheckIcon class="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
</Show>
</button>
</CopyValueButton>
)}
</For>
</div>
@@ -1070,10 +1017,11 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
</span>
<CopyValueButton
value={fact.value}
copiedValue={copiedDiscoveryValue}
onCopy={handleCopyDiscoveryValue}
copied={copiedDiscoveryValue() === fact.value.trim()}
onCopyValue={handleCopyDiscoveryValue}
label={`Copy ${fact.key}`}
class="inline-flex min-h-5 min-w-5 shrink-0 items-center justify-center rounded text-muted transition-colors hover:bg-surface-hover hover:text-base-content"
variant="ghost"
size="xs"
/>
</div>
</div>
@@ -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(() => (
<>
<CopyValueButton
value=" https://example.test "
copied={false}
onCopyValue={onCopy}
label="Copy URL"
/>
<CopyValueButton
value="8443/tcp"
copied
onCopyValue={onCopy}
label="Copy 8443/tcp"
variant="chip"
size="chip"
>
<span>8443/tcp</span>
</CopyValueButton>
<CopyValueButton value=" " onCopyValue={onCopy} label="Copy blank" />
</>
));
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(() => (
<CopyableCodeRow
value="/etc/pulse/config.yml"
copied={false}
onCopyValue={onCopy}
label="Copy config path"
/>
));
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(() => (
<Router>
@@ -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<HTMLButtonElement> {
variant?: ButtonVariant;
@@ -18,11 +26,27 @@ export interface ButtonLinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElem
hardNavigation?: boolean;
}
export interface CommandCopyButtonProps
extends Omit<ButtonProps, 'children' | 'isLoading' | 'size' | 'variant'> {
export interface CommandCopyButtonProps extends Omit<
ButtonProps,
'children' | 'isLoading' | 'size' | 'variant'
> {
label?: string;
}
export interface CopyValueButtonProps extends Omit<
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
'children' | 'onClick' | 'value'
> {
value?: string | null;
copied?: boolean;
onCopyValue: (value: string) => void | Promise<void>;
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 (
<button
{...rest}
type="button"
class={getCopyValueButtonClass({
variant: local.variant,
size: local.size,
class: local.class,
})}
disabled={local.disabled || !trimmedValue()}
onClick={() => {
const value = trimmedValue();
if (!value) return;
void local.onCopyValue(value);
}}
title={local.title ?? local.label}
aria-label={local['aria-label'] ?? local.label}
>
{local.children}
<Show when={local.copied} fallback={<CopyIcon class="h-3.5 w-3.5" aria-hidden="true" />}>
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden="true" />
</Show>
</button>
);
}
export function ButtonLink(props: ButtonLinkProps) {
const merged = mergeProps(
{ variant: 'secondary' as ButtonVariant, size: 'md' as ButtonSize },
@@ -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<CopyableCodeRowProps> = (props) => (
<div
class={['flex items-start gap-2 rounded bg-surface-alt px-2 py-1.5', props.class]
.filter(Boolean)
.join(' ')}
>
<code
class={['min-w-0 flex-1 break-all font-mono text-xs text-base-content', props.codeClass]
.filter(Boolean)
.join(' ')}
>
{props.value}
</code>
<CopyValueButton
value={props.value}
copied={props.copied}
onCopyValue={props.onCopyValue}
label={props.label}
variant="ghost"
size="sm"
/>
</div>
);
export default CopyableCodeRow;
@@ -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={<CopyIcon class="h-3.5 w-3.5" />}',
]);
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={<CopyIcon class="h-3.5 w-3.5" />}',
]);
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={<CopyIcon class="h-3.5 w-3.5" />}',
]);
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',
@@ -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<WebInterfaceUrlFieldProps> = (props
}}
disabled={state.urlSaving()}
/>
<button
type="button"
class="px-2.5 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
<Button
variant="primaryFlat"
size="sm"
disabled={state.urlSaving() || state.urlValue().trim() === state.normalizedCurrentUrl()}
onClick={() => void state.handleSaveUrl()}
>
Save
</button>
</Button>
<Show when={state.normalizedCurrentUrl()}>
<a
href={state.normalizedCurrentUrl()}
@@ -57,31 +56,25 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
</a>
</Show>
<Show when={state.normalizedCurrentUrl()}>
<button
type="button"
class="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"
onClick={() => void state.handleCopyUrl(state.normalizedCurrentUrl())}
title="Copy URL"
aria-label="Copy URL"
>
<Show
when={state.copiedUrlValue() === state.normalizedCurrentUrl()}
fallback={<CopyIcon class="h-3.5 w-3.5" />}
>
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
</Show>
</button>
<CopyValueButton
value={state.normalizedCurrentUrl()}
copied={state.copiedUrlValue() === state.normalizedCurrentUrl()}
onCopyValue={state.handleCopyUrl}
label="Copy URL"
variant="ghost"
size="lg"
/>
</Show>
<Show when={state.normalizedCurrentUrl()}>
<button
type="button"
class="px-2.5 py-1.5 text-xs font-medium rounded-md text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900 disabled:opacity-50 transition-colors"
<Button
variant="dangerOutline"
size="sm"
disabled={state.urlSaving()}
onClick={() => void state.handleDeleteUrl()}
title="Remove URL"
>
Remove
</button>
</Button>
</Show>
</div>
@@ -145,28 +138,23 @@ export const WebInterfaceUrlField: Component<WebInterfaceUrlFieldProps> = (props
>
<ExternalLinkIcon class="h-3.5 w-3.5" />
</a>
<button
type="button"
class="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"
onClick={() => void state.handleCopyUrl(state.normalizedSuggestedUrl())}
title="Copy suggested URL"
aria-label="Copy suggested URL"
>
<Show
when={state.copiedUrlValue() === state.normalizedSuggestedUrl()}
fallback={<CopyIcon class="h-3.5 w-3.5" />}
>
<CheckIcon class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
</Show>
</button>
<button
type="button"
class="px-2 py-1 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors flex-shrink-0"
<CopyValueButton
value={state.normalizedSuggestedUrl()}
copied={state.copiedUrlValue() === state.normalizedSuggestedUrl()}
onCopyValue={state.handleCopyUrl}
label="Copy suggested URL"
variant="accent"
size="md"
/>
<Button
variant="primaryFlat"
size="xs"
class="flex-shrink-0"
onClick={() => state.setUrlValue(state.normalizedSuggestedUrl())}
disabled={state.urlSaving()}
>
{state.normalizedCurrentUrl() ? 'Use instead' : 'Use this'}
</button>
</Button>
</div>
</div>
</Show>
@@ -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<ButtonVariant, string> = {
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<CopyValueButtonVariant, string> = {
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<CopyValueButtonSize, string> = {
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(' ');