Use the shared link and button primitives in two remaining spots

The shared template audit still flagged hand-built controls in two
places: the backup export and import warnings opened the migration
guide through raw new-tab anchors, and the external watchdog section
copied the outline button shell for Refresh and Show/Hide.

The guide links now compose ExternalTextLink. Its new inlineInherit
variant keeps the warning's own colour and underline, so a link inside
a tinted callout sentence does not switch to link blue. The watchdog
buttons compose Button (outline, xs and settingsAction), which keeps
their desktop look and gives them a 44px touch target on phones.
This commit is contained in:
rcourtman
2026-10-06 00:08:07 +01:00
parent b4ea3761e8
commit eaaea2214e
10 changed files with 85 additions and 18 deletions
@@ -2943,6 +2943,9 @@ ping URL must never return to the browser after save; the configured state uses
an explicit replacement placeholder and removal action. The watchdog remains
active independently of alert activation, snooze, quiet hours, and notification
delivery pause because those policies must not disable observation of Pulse.
Its Refresh and Show/Hide actions are the shared outline `Button` (xs and
`settingsAction` sizes), not a copied border shell, so they match the other
destination controls and keep a 44px touch target on phones.
Watchdog transport and monitoring progress remain notifications- and
monitoring-owned respectively. Alerts owns the system-alert projection:
@@ -6455,6 +6455,9 @@ must not hand-code raw `<a target="_blank">` anchors for documentation links.
The `settings-external-text-link-shell` and
`settings-external-text-link-local-anchor` registry entries enforce that split,
and the Button registry owns the `info` variant for blue documentation CTAs.
A documentation link inside a tinted callout sentence (the backup export and
import warnings) uses the `inlineInherit` variant, which keeps the callout's
text colour and underline instead of switching to link blue.
Platform inline notices that sit inside platform pages but are not settings
callouts must route through the shared `InlineNotice` primitive. Platform owners
@@ -0,0 +1,38 @@
{
"version": 1,
"base_sha": "b4ea3761e83e8b6cc2e50c7afdd0e6f5367116b6",
"verified_at": "2026-10-05T23:06:38Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
"frontend-modern/src/components/shared/ExternalTextLink.tsx",
"frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "966ef9b08d0cc3b162e8a9a0978379cc4fd834d46090630428d176681bc8cf42",
"frontend-modern/src/components/shared/ExternalTextLink.tsx": "91a539a03b2846767f2bfd650bae40d336750cbf8abb8b377842617fd46d24be",
"frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx": "73c23af492449c5d3eb09308162fb9169aaeffe70f067b226caa8296181f3beb"
},
"routes": [
"/settings/system-recovery",
"/alerts/destinations"
],
"viewports": [
{
"width": 1440,
"height": 1100
},
{
"width": 390,
"height": 1100
}
],
"states": [
"Settings > Recovery > Create Backup and Restore Backup dialogs: the migration guide link inside the amber and yellow warnings is an ExternalTextLink (inlineInherit) that keeps the callout colour (rgb(180,83,9) / rgb(161,98,7) light, rgb(252,211,77) / rgb(253,224,71) dark), stays inline and underlined, and opens in a new tab with noopener; pixel-identical to main on desktop",
"Alerts > Destinations > External watchdog: Refresh and Show/Hide are shared outline Buttons; on desktop they match main (26px and 38px); on a 390px phone they become 44px touch targets (main: 26px and 38px)",
"Shared template audit: no findings remain in BackupTransferDialogs.tsx or AlertDeadManDestinationSection.tsx"
],
"interactions": [
"Opened both backup dialogs and the destinations page on main (reverse-applied patch) and on the change, light and dark at 1440 and at 390"
]
}
@@ -1599,6 +1599,7 @@
{ "path": "src/components/Settings/AISettingsDialogs.tsx" },
{ "path": "src/components/Settings/APITokenManager.tsx" },
{ "path": "src/components/Settings/AgentIntegrationsPanel.tsx" },
{ "path": "src/components/Settings/BackupTransferDialogs.tsx" },
{ "path": "src/components/Settings/GeneralSettingsPanel.tsx" },
{ "path": "src/components/Settings/SecurityOverviewPanel.tsx" },
{ "path": "src/components/Settings/SelfHostedCommercialRecoverySection.tsx" }
@@ -5,6 +5,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
import { controlClass, formField, formHelpText, labelClass } from '@/components/shared/Form';
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
import { getAPITokenManagementLocationMessage } from '@/utils/apiTokenPresentation';
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
import { getShippedDocUrl } from '@/utils/docsLinks';
interface BackupTransferDialogsProps {
@@ -145,14 +146,12 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
and API-token records, but not history, TrueNAS/vSphere connections or agent
enrolment state. Local login credentials and sessions are not included. Check
the{' '}
<a
<ExternalTextLink
href={getShippedDocUrl('MIGRATION.md')}
target="_blank"
rel="noopener noreferrer"
class="underline"
variant="inlineInherit"
>
migration guide
</a>{' '}
</ExternalTextLink>{' '}
before relying on this for recovery. Remember your{' '}
{props.useCustomPassphrase() || !props.securityStatus()?.hasAuthentication
? 'passphrase'
@@ -296,14 +295,12 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
<strong>Warning:</strong> Import replaces the included settings and API-token
records, not the whole installation. Back up the destination first. History and
excluded settings are not restored. Check the{' '}
<a
<ExternalTextLink
href={getShippedDocUrl('MIGRATION.md')}
target="_blank"
rel="noopener noreferrer"
class="underline"
variant="inlineInherit"
>
migration guide
</a>{' '}
</ExternalTextLink>{' '}
before retiring the old server.
</p>
<p>
@@ -29,6 +29,20 @@ describe('ExternalTextLink', () => {
expect(link.className).toContain('sm:min-h-9');
});
it("keeps a callout's text colour for links inside a tinted sentence", () => {
render(() => (
<ExternalTextLink href="https://docs.example.test/migration" variant="inlineInherit">
migration guide
</ExternalTextLink>
));
const link = screen.getByRole('link', { name: 'migration guide' });
expect(link).toHaveAttribute('target', '_blank');
expect(link.className).toContain('underline');
expect(link.className).not.toContain('text-blue');
expect(link.className).not.toContain('min-h-');
});
it('centralizes the preserve-opener exception', () => {
expect(getExternalTextLinkRel()).toBe('noopener noreferrer');
expect(getExternalTextLinkRel(true)).toBeUndefined();
@@ -7,6 +7,7 @@ export type ExternalTextLinkVariant =
| 'compact'
| 'compactAction'
| 'compactInherit'
| 'inlineInherit'
| 'inlineAction';
interface ExternalTextLinkProps extends Omit<
@@ -33,6 +34,8 @@ export const EXTERNAL_TEXT_LINK_VARIANT_CLASSES: Record<ExternalTextLinkVariant,
compactAction:
'inline-flex min-h-10 items-center rounded-sm px-1 text-xs font-medium text-blue-700 hover:underline dark:text-blue-300 sm:min-h-9',
compactInherit: 'inline-flex min-h-10 items-center rounded-sm px-1 underline sm:min-h-9',
// A link inside a tinted callout sentence keeps the callout's text colour.
inlineInherit: 'underline',
inlineAction:
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-blue-600 hover:underline dark:text-blue-300',
};
@@ -1891,10 +1891,13 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/AISettingsDialogs.tsx',
'src/components/Settings/APITokenManager.tsx',
'src/components/Settings/AgentIntegrationsPanel.tsx',
'src/components/Settings/BackupTransferDialogs.tsx',
'src/components/Settings/GeneralSettingsPanel.tsx',
'src/components/Settings/SecurityOverviewPanel.tsx',
'src/components/Settings/SelfHostedCommercialRecoverySection.tsx',
]);
// A link inside a tinted callout sentence keeps the callout's colour.
expect(externalTextLinkSource).toContain("inlineInherit: 'underline'");
expect(registeredRule?.forbiddenPatterns).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -1,6 +1,7 @@
import { createSignal, createUniqueId, onMount, Show, type Accessor } from 'solid-js';
import { AlertsAPI } from '@/api/alerts';
import { Button } from '@/components/shared/Button';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import type { DeadManStatus } from '@/types/alerts';
import { formatRelativeTime } from '@/utils/format';
@@ -83,14 +84,14 @@ export function AlertDeadManDestinationSection(props: AlertDeadManDestinationSec
<span class={`rounded-full px-2.5 py-1 text-xs font-semibold ${presentation().class}`}>
{unavailable() ? 'Status unavailable' : presentation().label}
</span>
<button
type="button"
class="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content hover:bg-surface-hover disabled:opacity-50"
<Button
variant="outline"
size="xs"
disabled={loading()}
onClick={() => void loadStatus()}
>
{loading() ? 'Refreshing…' : 'Refresh'}
</button>
</Button>
</div>
}
class="min-w-0"
@@ -133,14 +134,14 @@ export function AlertDeadManDestinationSection(props: AlertDeadManDestinationSec
</button>
}
>
<button
type="button"
class="rounded-md border border-border px-3 py-2 text-sm font-medium text-base-content hover:bg-surface-hover"
<Button
variant="outline"
size="settingsAction"
aria-pressed={showUrl()}
onClick={() => setShowUrl((value) => !value)}
>
{showUrl() ? 'Hide' : 'Show'}
</button>
</Button>
</Show>
</div>
<p id={`${inputId}-help`} class="mt-2 text-xs leading-5 text-muted">
@@ -80,6 +80,10 @@ describe('AlertDeadManDestinationSection', () => {
expect(input).toHaveValue('https://watchdog.example.test/ping/new-token');
expect(container.textContent).not.toContain('***REDACTED***');
// The section's actions are the shared outline Buttons, which give a
// phone a 44px touch target.
expect(screen.getByRole('button', { name: 'Show' })).toHaveClass('min-h-11', 'sm:min-h-9');
expect(screen.getByRole('button', { name: /^Refresh/ })).toHaveClass('min-h-11', 'sm:min-h-0');
fireEvent.click(screen.getByRole('button', { name: 'Show' }));
expect(input).toHaveAttribute('type', 'text');
fireEvent.click(screen.getByRole('button', { name: 'Hide' }));