Move three forms onto shared controls and draw the select arrow (#2546)

This clears the shared template audit (`node
scripts/shared-template-audit.mjs`) for the forms that still built their
own controls. Three findings remain, in BackupTransferDialogs.tsx and
AlertDeadManDestinationSection.tsx. Those two files are part of an open
`bg-base` change and will follow after it lands.

**Moved onto shared controls:**
- **Alerts › Thresholds › CPU averaging:** the ten per-platform selects
now use `FormSelect`, with each profile name as a visible label.
- **Discovery availability proposal:** the Interval and Observation
location fields use `FormSelect`, and the check-name field next to them
uses the shared field classes, so the three match.
- **Availability check › Request body:** the field uses `FormTextarea`.
Its size note is now the field's accessible description. With
`sm:min-h-24` it also gets the 96px height its class asked for on
desktop; on main it rendered at 54px.

**Select arrow.** `FormSelect`'s default chrome set `appearance-none`
and drew no arrow of its own, so every default select in Settings read
as a text field. That includes the existing Preset, Target type and
Probe selects in the availability dialog. A new `form-select-arrow`
utility draws a caret in the padding the class already reserved, using
the muted text colour. In forced-colors mode it hands the native arrow
back. Filter-bar selects use their own class and are unchanged.

**Registry fixes:**
- `AlertEscalationSection` is removed from the `form-select-shell`
required consumers. It has used checkboxes since e0baecc418.
- The inline detail row rule now excludes test files. Its only finding
was `ProxmoxBackupsTable.test.tsx`.

Verification:
- Checked live on a mock stack, before and after, in light and dark mode
at 1440px and 390px.
- The Discovery card was rendered through route interception.
- Forced-colors mode was emulated.
- 11,393 tests pass, and the receipt is included.
This commit is contained in:
rcourtman
2026-10-06 00:20:46 +01:00
committed by GitHub
16 changed files with 177 additions and 85 deletions
@@ -2928,7 +2928,8 @@ hours colour the dashboard and never alert. A group switched off as a whole
shows its Disabled badge and no defaults line. The CPU averaging line counts
every configured profile override, including ones only the API sets. CPU averaging is tuning, not the page's
answer. It is one collapsed disclosure stating the effective window and the
number of platform overrides, with the same per-profile selects inside. Docker
number of platform overrides, with the same per-profile selects inside, each
a compact shared `FormSelect` with its profile name as a visible label. Docker
and Machines list their resource groups before Docker-only rules and the
SMART card. Threshold values, inheritance, metric evaluation windows and
persistence are unchanged.
@@ -1822,6 +1822,10 @@ payload shape change when the portal presents compact client rows.
arbitrary remote content. Contract tests must pin redaction, unchanged-
secret preservation, bounded evaluation, and the reachable-but-incorrect
application case.
The settings client edits the request body in the shared `FormTextarea`,
whose size note (`Up to 8 KiB`) is the field's accessible description;
removing a stored body stays an explicit action beside it, and leaving the
field blank keeps the stored body as before.
Availability target protocol vocabulary is canonicalized at the API/config
boundary: clients may submit `ping` as a user-facing alias for the ICMP
check, but saved targets, API reads, probe status, connections rows, and
@@ -2464,6 +2464,12 @@ not a replacement status card, CTA band, or page-local nested card.
apply controlled `value` props after options are mounted so settings panels
such as Discovery show the persisted option instead of falling back
to the first option while the collapsed summary shows a different value.
Its default select chrome (`formSelect` / `formSelectCompact` in
`Form.ts`) sets `appearance-none` to match the text inputs, so it also
carries the `form-select-arrow` utility, which draws the dropdown caret in
the reserved right padding (and hands the native arrow back in forced-colors
mode); without it every default `FormSelect` read as a text field. Callers
that pass their own `selectBaseClass` keep their own arrow.
The Assistant runtime controls in
`frontend-modern/src/components/Settings/AIRuntimeControlsSection.tsx` — e.g. the
service context scan `Toggle` — are settings-shell chrome bound to the canonical
@@ -2907,7 +2907,9 @@ Discovery-led availability proposals present their evidence and canonical
attachment boundary as short, complete sentences in both the inline card and
machine-wide review. The colocated component test pins that operator-facing
copy so the shared copy-style audit cannot regress it back to semicolon-joined
instructions.
instructions. The card's editable check name, interval and observation
location use the shared form label and control chrome (`FormSelect` for the
two choices), so they match the availability settings form they hand off to.
### Provider workflows hydrate only their active route inventory
@@ -0,0 +1,48 @@
{
"version": 1,
"base_sha": "979466013876ce54a67494b0c4cda9b404ea45e8",
"verified_at": "2026-10-05T22:01:46Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx",
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx",
"frontend-modern/src/components/shared/Form.ts",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/components/Discovery/AvailabilityProposalCard.tsx": "bfa50b13f521b9a91b57495eec3a827a71a413a0eed0832be0d58444e8ca3ee7",
"frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx": "af7a1d322045c33747600466a580ae6148f752c8d847404a6f5b04fa49fd53e7",
"frontend-modern/src/components/shared/Form.ts": "e47d06ebbbba68ac0d19769acac83a0ca930b1e2d5a7258c7c1fcf43e82ce44a",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx": "8142c12c2e6930a505e1f063aa8a396bfa45c6c55a8ea7c7d1085198a9f3915f",
"frontend-modern/src/index.css": "b8ae1a40f0915ff034e51a16a94e0c2159ac726ef0afdfdd8fb321f5d5f7dc5e"
},
"routes": [
"/alerts/thresholds",
"/settings/monitoring/availability",
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 1100
},
{
"width": 390,
"height": 1100
}
],
"states": [
"Alerts > Thresholds > CPU averaging opened: all 10 per-platform selects render through FormSelect with their visible label tied by for/id, values intact (5-minute average, Inherit), 32px desktop / 44px phone, and a dropdown caret; main showed native selects",
"Settings > Availability checks > Add service/device check with an HTTP POST probe: Request body (optional) is a FormTextarea with its label tied and the 8 KiB help linked by aria-describedby, 96px tall on desktop and phone (main: 54px on desktop because formControl's sm:min-h-9 overrode min-h-24; no help link)",
"Existing default FormSelects in that dialog (Preset, Target type, Probe) now show a caret; on main they had appearance-none and no arrow, reading as text fields",
"Proxmox guest drawer Discovery tab with discovery_enabled and an availability proposal served by route interception: Check name input and the Interval / Observation location FormSelects share the shared form label and control chrome at matched heights, with carets",
"Dark mode at 1440: caret drawn in the muted text colour and readable; forced-colors emulation: select falls back to appearance auto with no background image",
"Shared template audit: only the three findings in BackupTransferDialogs.tsx and AlertDeadManDestinationSection.tsx remain (held by another session's open bg-base change)",
"Re-checked after rebasing onto 9794660138 (#2535, shared toggle): CPU averaging selects unchanged, labelled, with the caret"
],
"interactions": [
"Opened the CPU averaging disclosure, the availability check dialog (HTTP, POST) and a guest Discovery tab, on main (reverse-applied patch) and on the change",
"Emulated forced-colors: active"
]
}
@@ -1709,7 +1709,6 @@
{ "path": "src/components/shared/FilterToolbar.tsx" },
{ "path": "src/features/alerts/AlertAppriseDestinationsSection.tsx" },
{ "path": "src/features/alerts/AlertDeliveryRoutingSection.tsx" },
{ "path": "src/features/alerts/AlertEscalationSection.tsx" },
{ "path": "src/features/alerts/AlertQuietHoursSection.tsx" },
{ "path": "src/features/patrol/PatrolAttentionWorkbench.tsx" }
],
@@ -6664,12 +6663,11 @@
"src/features/vmware"
],
"extensions": [".tsx"],
"pathExcludes": [".test.", "__tests__"],
"triggerPatterns": ["data-inline", "TableRow"],
"requiredPatterns": ["InlineDetailTableRow"],
"allowedPaths": [],
"ignoredPaths": [
"src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx"
],
"ignoredPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
@@ -304,6 +304,18 @@ describe('App architecture', () => {
);
});
it('draws the shared select arrow that appearance-none removes', () => {
// FormSelect's default chrome sets appearance-none to match text inputs;
// without this utility every default select read as a text field.
expect(appStylesSource).toMatch(
/@utility form-select-arrow \{\s*background-image:\s*linear-gradient\(45deg, transparent 50%, var\(--color-text-muted\) 50%\)/,
);
// Forced-colors mode drops background images, so the native arrow returns.
expect(appStylesSource).toMatch(
/@media \(forced-colors: active\) \{\s*appearance: auto !important;\s*background-image: none;/,
);
});
it('keeps compact phone settings spacing scoped to the settings content shell', () => {
expect(appStylesSource).toContain('@media (max-width: 39.999rem)');
expect(appStylesSource).toContain(
@@ -22,6 +22,8 @@ import {
} from '@/api/discovery';
import { ActionIconButton, Button } from '@/components/shared/Button';
import { Dialog } from '@/components/shared/Dialog';
import { formControl, formField, formLabel } from '@/components/shared/Form';
import { FormSelect } from '@/components/shared/FormSelect';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import type { DiscoverySummary, ResourceDiscovery, ResourceType } from '@/types/discovery';
import {
@@ -298,47 +300,35 @@ export const AvailabilityProposalCard: Component<AvailabilityProposalCardProps>
</p>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<label class="block sm:col-span-1">
<span class="text-[11px] font-medium text-base-content">
Check name · you control
</span>
<label class={formField}>
<span class={formLabel}>Check name · you control</span>
<input
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
class={formControl}
value={name()}
onInput={(event) => setName(event.currentTarget.value)}
/>
</label>
<label class="block">
<span class="text-[11px] font-medium text-base-content">
Interval · you control
</span>
<select
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
value={intervalSeconds()}
onChange={(event) => setIntervalSeconds(Number(event.currentTarget.value))}
>
<option value={30}>Every 30 seconds</option>
<option value={60}>Every minute</option>
<option value={300}>Every 5 minutes</option>
</select>
</label>
<label class="block">
<span class="text-[11px] font-medium text-base-content">
Observation location · you control
</span>
<select
class="mt-1 w-full rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
value={probeAgentId()}
onChange={(event) => setProbeAgentId(event.currentTarget.value)}
>
<option value="">This Pulse server</option>
<For each={props.connectedAgents ?? []}>
{(agent) => (
<option value={agent.agent_id}>{agent.hostname || agent.agent_id}</option>
)}
</For>
</select>
</label>
<FormSelect
label="Interval · you control"
value={intervalSeconds()}
onChange={(event) => setIntervalSeconds(Number(event.currentTarget.value))}
>
<option value={30}>Every 30 seconds</option>
<option value={60}>Every minute</option>
<option value={300}>Every 5 minutes</option>
</FormSelect>
<FormSelect
label="Observation location · you control"
value={probeAgentId()}
onChange={(event) => setProbeAgentId(event.currentTarget.value)}
>
<option value="">This Pulse server</option>
<For each={props.connectedAgents ?? []}>
{(agent) => (
<option value={agent.agent_id}>{agent.hostname || agent.agent_id}</option>
)}
</For>
</FormSelect>
</div>
<Show when={duplicate()}>
@@ -10,6 +10,7 @@ import {
formLabel,
} from '@/components/shared/Form';
import { FormSelect } from '@/components/shared/FormSelect';
import { FormTextarea } from '@/components/shared/FormTextarea';
import {
AvailabilityTargetsAPI,
type AvailabilityProbeProtocol,
@@ -776,10 +777,11 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
</div>
<Show when={form().httpMethod === 'POST'}>
<label class={formField}>
<span class={formLabel}>Request body (optional)</span>
<textarea
class={`${formControl} min-h-24 font-mono text-xs`}
<div class={formField}>
<FormTextarea
label="Request body (optional)"
textareaBaseClass={`${formControl} min-h-24 sm:min-h-24 font-mono text-xs`}
help="Up to 8 KiB. Kept out of history and test output."
value={form().httpBody}
onInput={(event) =>
updateForm({
@@ -794,11 +796,8 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
: '{"operation":"health"}'
}
/>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class={formHelpText}>
Up to 8 KiB. Kept out of history and test output.
</span>
<Show when={form().httpBodyConfigured && !form().httpBodyTouched}>
<Show when={form().httpBodyConfigured && !form().httpBodyTouched}>
<div>
<button
type="button"
class="text-xs font-medium text-error hover:underline"
@@ -812,9 +811,9 @@ export const AvailabilityTargetSlot: Component<AvailabilityTargetSlotProps> = (p
>
Remove stored body
</button>
</Show>
</div>
</label>
</div>
</Show>
</div>
</Show>
<div class="grid gap-4 sm:grid-cols-2">
@@ -338,6 +338,10 @@ describe('AvailabilityTargetSlot', () => {
'placeholder',
'Stored securely — leave blank to keep it',
);
// The request body composes FormTextarea, which links its size note.
expect(screen.getByLabelText(/^Request body \(optional\)/)).toHaveAccessibleDescription(
'Up to 8 KiB. Kept out of history and test output.',
);
expect(screen.getByLabelText('Header value')).toHaveAttribute(
'placeholder',
'Stored securely — leave blank to keep it',
@@ -25,9 +25,9 @@ export const formCheckbox = baseCheckbox;
export const formControlDense = join(baseControl, 'py-1.5 px-2');
export const formControlMono = join(baseControl, 'font-mono');
export const formSelect = join(baseControl, 'pr-8 appearance-none');
export const formSelect = join(baseControl, 'pr-8 appearance-none form-select-arrow');
export const formTextarea = join(baseControl, 'min-h-[120px] resize-vertical');
export const formSelectCompact = join(compactControl, 'pr-8 appearance-none');
export const formSelectCompact = join(compactControl, 'pr-8 appearance-none form-select-arrow');
export const formTextareaCompact = join(compactControl, 'min-h-16 resize-y');
export const formLabelMuted = join(baseLabel, 'text-muted font-normal');
@@ -20,6 +20,8 @@ describe('FormSelect', () => {
expect(select).toBe(screen.getByTestId('delivery-mode'));
expect(select).toHaveValue('cli');
expect(select).toHaveClass('min-h-11', 'sm:min-h-9');
// appearance-none drops the native arrow, so the shared class draws one.
expect(select).toHaveClass('appearance-none', 'form-select-arrow');
});
it('preserves explicit ids and compact styling hooks', () => {
@@ -719,7 +719,6 @@ describe('shared primitive guardrails', () => {
'src/components/shared/FilterToolbar.tsx',
'src/features/alerts/AlertAppriseDestinationsSection.tsx',
'src/features/alerts/AlertDeliveryRoutingSection.tsx',
'src/features/alerts/AlertEscalationSection.tsx',
'src/features/alerts/AlertQuietHoursSection.tsx',
'src/features/patrol/PatrolAttentionWorkbench.tsx',
]);
@@ -7730,6 +7729,7 @@ describe('shared primitive guardrails', () => {
requiredPatterns?: string[];
allowedPaths?: string[];
ignoredPaths?: string[];
pathExcludes?: string[];
}>;
};
const registeredRule = registry.rules?.find(
@@ -7774,9 +7774,9 @@ describe('shared primitive guardrails', () => {
expect(guard?.allowedPaths ?? []).toHaveLength(0);
}
expect(inlineGuard?.triggerPatterns).toEqual(['data-inline', 'TableRow']);
expect(inlineGuard?.ignoredPaths).toEqual([
'src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx',
]);
// Test files assert on rendered rows; they are not row surfaces.
expect(inlineGuard?.pathExcludes).toEqual(['.test.', '__tests__']);
expect(inlineGuard?.ignoredPaths).toEqual([]);
expect(namedGuard?.triggerPatterns).toEqual(['detail-row', 'TableRow']);
expect(namedGuard?.ignoredPaths ?? []).toHaveLength(0);
@@ -379,6 +379,12 @@ describe('ThresholdsTab', () => {
'5-minute average, 1 platform override',
);
expect(virtualMachineWindow.options[0].textContent).toBe('Inherit (15 minutes)');
// The window controls compose the shared FormSelect: a visible label tied
// to the select and the shared dropdown arrow.
expect(document.querySelector(`label[for="${workloadWindow.id}"]`)?.textContent).toBe(
'All workloads',
);
expect(workloadWindow).toHaveClass('appearance-none', 'form-select-arrow');
fireEvent.input(workloadWindow, { target: { value: '60' } });
@@ -1,6 +1,7 @@
import { createSignal, For, Show } from 'solid-js';
import ChevronRight from 'lucide-solid/icons/chevron-right';
import { ThresholdsTable } from '@/components/Alerts/ThresholdsTable';
import { FormSelect } from '@/components/shared/FormSelect';
import {
AlertIntentPolicyPanel,
type AlertIntentPolicySelectionTarget,
@@ -96,31 +97,28 @@ export function ThresholdsTab(props: ThresholdsTabProps) {
const evaluationProfileControl = (profile: (typeof evaluationProfiles)[number]) => {
const configured = () => props.metricEvaluationWindows?.()[profile.key]?.cpu;
return (
<label class="block rounded-md border border-border p-3">
<span class="block text-xs font-medium text-base-content">{profile.label}</span>
<select
aria-label={`${profile.label} CPU averaging`}
class="mt-2 w-full rounded-md border border-border bg-surface px-2 py-1.5 text-sm text-base-content"
value={
profile.key === 'all'
? String(configured() ?? 300)
: configured() === undefined
? 'inherit'
: String(configured())
}
onInput={(event) => updateCPUWindow(profile.key, event.currentTarget.value)}
>
<Show when={profile.key !== 'all'}>
<option value="inherit">
Inherit ({evaluationLabel(inheritedCPUWindow(profile))})
</option>
</Show>
<option value="0">Current value</option>
<option value="60">1-minute average</option>
<option value="300">5-minute average</option>
<option value="900">15-minute average</option>
</select>
</label>
<FormSelect
label={profile.label}
aria-label={`${profile.label} CPU averaging`}
fieldClass="rounded-md border border-border p-3"
density="compact"
value={
profile.key === 'all'
? String(configured() ?? 300)
: configured() === undefined
? 'inherit'
: String(configured())
}
onInput={(event) => updateCPUWindow(profile.key, event.currentTarget.value)}
>
<Show when={profile.key !== 'all'}>
<option value="inherit">Inherit ({evaluationLabel(inheritedCPUWindow(profile))})</option>
</Show>
<option value="0">Current value</option>
<option value="60">1-minute average</option>
<option value="300">5-minute average</option>
<option value="900">15-minute average</option>
</FormSelect>
);
};
+22
View File
@@ -88,6 +88,28 @@
padding-bottom: max(3.5rem, env(safe-area-inset-bottom, 3.5rem));
}
/* The shared select classes drop the native arrow (appearance-none) so the
control matches the text inputs, which left every FormSelect looking like
a text field. Draw a small caret in the reserved right padding instead, in
the muted text colour so it follows the theme. */
@utility form-select-arrow {
background-image:
linear-gradient(45deg, transparent 50%, var(--color-text-muted) 50%),
linear-gradient(135deg, var(--color-text-muted) 50%, transparent 50%);
background-position:
calc(100% - 15px) calc(50% + 1px),
calc(100% - 10px) calc(50% + 1px);
background-size: 5px 5px;
background-repeat: no-repeat;
/* Forced-colors mode drops background images, so hand the arrow back to
the browser there. */
@media (forced-colors: active) {
appearance: auto !important;
background-image: none;
}
}
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;