Converge update modal chrome on shared primitives

This commit is contained in:
rcourtman
2026-06-13 19:40:04 +01:00
parent 9418dbb330
commit 14eda65f2e
6 changed files with 337 additions and 281 deletions
@@ -523,6 +523,10 @@ not a replacement status card, CTA band, or page-local nested card.
Shared error-boundary fallback actions are also command buttons: reset,
reload, and retry controls must compose `Button` so emergency UI does not
become a separate local button vocabulary.
Update confirmation and progress modal actions are part of the same command
boundary: cancel, start, retry, close, history, reload-now, and close-icon
controls must compose `Button` or `ActionIconButton` instead of carrying
modal-local blue, neutral, or icon-button class strings.
Compact icon-only row, inline, and floating action controls belong to
`ActionIconButton`. Feature surfaces may own the icon choice, click handler,
label text, and layout slot, but icon-button size, tone, focus ring,
@@ -1372,6 +1376,10 @@ not a replacement status card, CTA band, or page-local nested card.
notices are part of the same settings callout boundary: the editor and
credential slots own the source-specific lifecycle or API meaning, while
`CalloutCard` owns the warning/success/danger shell and compact density.
Update confirmation and progress modal notices share that same primitive
boundary. The update flow owns version, prerequisite, root-access, restart,
and error copy; `CalloutCard` owns the info/warning/danger shell, spacing,
dark-mode tone, and icon layout.
8. Keep hosted settings-shell framing imports safe for bundle initialization.
Self-hosted billing titles, descriptions, and referral copy used by
`settingsHeaderMeta.ts`, `settingsNavCatalog.ts`, and adjacent settings
@@ -1786,6 +1794,9 @@ primitive internals such as `Button`, `PulseDataGrid`, and
`HistoryChartOverlay`, as well as Login, Settings, Patrol, and AI finding
surfaces; local `animate-spin` spinner shells in those consumers are governed
by the shared-template registry rather than page-local discretion.
Update progress status indicators are included in that loading boundary:
progress-stage loading must compose `LoadingSpinner` rather than local spinner
SVGs.
`DiscoveryLoadingFallback` owns the discovery-tab Suspense fallback row for
resource, workload, and Docker host drawers: centered row layout, status
semantics, discovery loading copy, and canonical `LoadingSpinner` composition
@@ -3398,6 +3409,10 @@ Shared error-boundary fallbacks use the same boundary: the fallback owns error
copy and reset/reload handlers, while `CalloutCard` owns danger tone, spacing,
dark-mode styling, and alert layout instead of inline red panels or raw SVG
alert glyphs.
Update confirmation and progress modals use the same shared boundary: the
modal flow owns update state and copy, while `CalloutCard`, `Button`,
`ActionIconButton`, `LoadingSpinner`, and lucide icons own the colored notice,
command, icon-only close, and status indicator chrome.
Settings loading placeholders must route through the shared
`SettingsLoadingSkeleton` primitive instead of local `animate-pulse` blocks.
@@ -415,6 +415,48 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "update-modal-action-callout-shell",
"category": "callout",
"summary": "Update confirmation and progress modals must compose CalloutCard, Button, ActionIconButton, LoadingSpinner, and library icons for shared status, warning, restart, and command chrome instead of recreating local colored panels, raw SVG glyphs, or modal-local button classes.",
"canonical": {
"path": "src/components/shared/CalloutCard.tsx",
"export": "CalloutCard"
},
"requiredConsumers": [
{ "path": "src/components/UpdateConfirmationModal.tsx" },
{ "path": "src/components/UpdateProgressModal.tsx" }
],
"forbiddenPatterns": [
{
"path": "src/components/UpdateConfirmationModal.tsx",
"patterns": [
"<svg",
"bg-blue-50 dark:bg-blue-900 border border-blue-200",
"bg-yellow-50 dark:bg-yellow-900 border border-yellow-200",
"rounded-md p-4 border",
"px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
]
},
{
"path": "src/components/UpdateProgressModal.tsx",
"patterns": [
"<svg",
"bg-red-50 dark:bg-red-900 border border-red-200",
"bg-blue-50 dark:bg-blue-900 border border-blue-200",
"bg-yellow-50 dark:bg-yellow-900 border border-yellow-200",
"mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded"
]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/__tests__/UpdateConfirmationModal.test.tsx",
"src/components/shared/CalloutCard.test.tsx",
"src/components/shared/Button.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-inline-notice-shell",
"category": "callout",
@@ -495,6 +537,7 @@
{ "path": "src/components/Settings/UpdatesSettingsPanel.tsx" },
{ "path": "src/components/Settings/UserAssignmentsDialog.tsx" },
{ "path": "src/components/Settings/UserAssignmentsPanel.tsx" },
{ "path": "src/components/UpdateProgressModal.tsx" },
{ "path": "src/components/shared/Button.tsx" },
{ "path": "src/components/shared/DiscoveryLoadingFallback.tsx" },
{ "path": "src/components/shared/HistoryChartOverlay.tsx" },
@@ -733,6 +776,13 @@
{
"path": "src/components/Settings/SecurityOverviewPanel.tsx",
"patterns": ["animate-pulse", "bg-slate-300"]
},
{
"path": "src/components/UpdateProgressModal.tsx",
"patterns": [
"<svg class=\"w-12 h-12 text-blue-500 animate-spin\"",
"class=\"animate-spin h-4 w-4\""
]
}
],
"proof": [
@@ -779,6 +829,8 @@
{ "path": "src/components/Settings/InfrastructureWorkspace.tsx" },
{ "path": "src/components/Settings/ReportingPanel.tsx" },
{ "path": "src/components/Settings/ResourcePicker.tsx" },
{ "path": "src/components/UpdateConfirmationModal.tsx" },
{ "path": "src/components/UpdateProgressModal.tsx" },
{ "path": "src/components/Workloads/GuestDrawer.tsx" },
{ "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" },
{ "path": "src/features/standalone/StandalonePageSurface.tsx" }
@@ -834,6 +886,20 @@
"px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700",
"text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
]
},
{
"path": "src/components/UpdateConfirmationModal.tsx",
"patterns": [
"px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover rounded-md transition-colors",
"px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
]
},
{
"path": "src/components/UpdateProgressModal.tsx",
"patterns": [
"mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded",
"px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
]
}
],
"proof": [
@@ -1,5 +1,13 @@
import { createEffect, createSignal, Show, For } from 'solid-js';
import type { UpdatePlan } from '@/api/updates';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import ClockIcon from 'lucide-solid/icons/clock';
import LockIcon from 'lucide-solid/icons/lock';
import XIcon from 'lucide-solid/icons/x';
import { ActionIconButton, Button } from '@/components/shared/Button';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { Dialog } from '@/components/shared/Dialog';
interface UpdateConfirmationModalProps {
@@ -34,6 +42,13 @@ export function UpdateConfirmationModal(props: UpdateConfirmationModalProps) {
props.onConfirm();
}
};
const warningTone = () => (props.isMajorUpgrade ? 'warning' : 'info');
const warningTitle = () =>
props.isMajorUpgrade && props.isPrerelease
? 'Major Version Pre-Release'
: props.isMajorUpgrade
? 'Major Version Upgrade'
: 'Pre-Release Build';
return (
<Dialog
@@ -48,121 +63,47 @@ export function UpdateConfirmationModal(props: UpdateConfirmationModalProps) {
<div class="px-6 py-4 border-b border-border">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-base-content">Confirm Update</h2>
<button
<ActionIconButton
onClick={handleClose}
aria-label="Close confirmation"
label="Close confirmation"
title="Close"
class=" hover:text-base-content"
tone="muted"
size="md"
disabled={props.isApplying}
type="button"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<XIcon class="h-5 w-5" aria-hidden="true" />
</ActionIconButton>
</div>
</div>
{/* Body */}
<div class="px-6 py-4 space-y-4">
{/* Version Jump */}
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<div class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Version Update
</div>
<div class="flex items-center gap-3 text-blue-800 dark:text-blue-200">
<CalloutCard tone="info" scale="compact" padding="md" title="Version Update">
<div class="flex items-center gap-3 text-sm text-blue-800 dark:text-blue-200">
<span class="font-mono text-sm">{props.currentVersion}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
<ArrowRightIcon class="h-4 w-4" aria-hidden="true" />
<span class="font-mono text-sm font-semibold">{props.latestVersion}</span>
</div>
</div>
</CalloutCard>
{/* Major version / pre-release warning */}
<Show when={props.warning}>
<div
class={`rounded-md p-4 border ${
props.isMajorUpgrade && props.isPrerelease
? 'bg-orange-50 dark:bg-orange-950 border-orange-300 dark:border-orange-700'
: props.isMajorUpgrade
? 'bg-amber-50 dark:bg-amber-950 border-amber-300 dark:border-amber-700'
: 'bg-blue-50 dark:bg-blue-950 border-blue-300 dark:border-blue-700'
}`}
>
<div class="flex items-start gap-3">
<svg
class={`w-5 h-5 flex-shrink-0 mt-0.5 ${
props.isMajorUpgrade && props.isPrerelease
? 'text-orange-500 dark:text-orange-400'
: props.isMajorUpgrade
? 'text-amber-500 dark:text-amber-400'
: 'text-blue-500 dark:text-blue-400'
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="text-sm">
<p
class={`font-medium ${
props.isMajorUpgrade && props.isPrerelease
? 'text-orange-800 dark:text-orange-200'
: props.isMajorUpgrade
? 'text-amber-800 dark:text-amber-200'
: 'text-blue-800 dark:text-blue-200'
}`}
>
{props.isMajorUpgrade && props.isPrerelease
? 'Major Version Pre-Release'
: props.isMajorUpgrade
? 'Major Version Upgrade'
: 'Pre-Release Build'}
</p>
<p
class={`mt-1 ${
props.isMajorUpgrade && props.isPrerelease
? 'text-orange-700 dark:text-orange-300'
: props.isMajorUpgrade
? 'text-amber-700 dark:text-amber-300'
: 'text-blue-700 dark:text-blue-300'
}`}
>
{props.warning}
</p>
</div>
</div>
</div>
<CalloutCard
tone={warningTone()}
scale="compact"
padding="md"
icon={<AlertTriangleIcon class="h-5 w-5" aria-hidden="true" />}
title={warningTitle()}
description={<span class="text-sm">{props.warning}</span>}
/>
</Show>
{/* Estimated Time */}
<Show when={props.plan.estimatedTime}>
<div class="flex items-center gap-2 text-sm text-muted">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<ClockIcon class="h-4 w-4" aria-hidden="true" />
<span>Estimated time: {props.plan.estimatedTime}</span>
</div>
</Show>
@@ -175,19 +116,10 @@ export function UpdateConfirmationModal(props: UpdateConfirmationModalProps) {
<For each={props.plan.prerequisites}>
{(prerequisite) => (
<li class="flex items-start gap-2 text-sm text-base-content">
<svg
class="w-4 h-4 mt-0.5 flex-shrink-0 text-orange-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<AlertTriangleIcon
class="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500"
aria-hidden="true"
/>
<span>{prerequisite}</span>
</li>
)}
@@ -198,42 +130,24 @@ export function UpdateConfirmationModal(props: UpdateConfirmationModalProps) {
{/* Root Required Warning */}
<Show when={props.plan.requiresRoot}>
<div class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<div class="flex items-start gap-2">
<svg
class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<div class="font-medium">Root access required</div>
<div class="text-yellow-700 dark:text-yellow-300 mt-1">
This update requires elevated privileges to modify system files.
</div>
</div>
</div>
</div>
<CalloutCard
tone="warning"
scale="compact"
padding="md"
icon={<LockIcon class="h-5 w-5" aria-hidden="true" />}
title="Root access required"
description={
<span class="text-sm">
This update requires elevated privileges to modify system files.
</span>
}
/>
</Show>
{/* Rollback Support */}
<Show when={props.plan.rollbackSupport}>
<div class="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<CheckCircleIcon class="h-4 w-4" aria-hidden="true" />
<span>Automatic backup will be created</span>
</div>
</Show>
@@ -258,39 +172,25 @@ export function UpdateConfirmationModal(props: UpdateConfirmationModalProps) {
{/* Footer */}
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
<button
<Button
onClick={handleClose}
disabled={props.isApplying}
class="px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
variant="ghost"
size="md"
type="button"
>
Cancel
</button>
<button
</Button>
<Button
onClick={handleConfirm}
disabled={!acknowledged() || props.isApplying}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
isLoading={props.isApplying}
variant="primary"
size="md"
type="button"
>
<Show when={props.isApplying}>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</Show>
<span>{props.isApplying ? 'Starting...' : 'Start Update'}</span>
</button>
</Button>
</div>
</div>
</Dialog>
@@ -1,6 +1,12 @@
import { createSignal, Show, onCleanup, createEffect } from 'solid-js';
import { UpdatesAPI, type UpdateStatus } from '@/api/updates';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
import InfoIcon from 'lucide-solid/icons/info';
import { Dialog } from '@/components/shared/Dialog';
import { ActionIconButton, Button } from '@/components/shared/Button';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import { ProgressBar } from '@/components/shared/ProgressBar';
import { apiFetch } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
@@ -310,48 +316,14 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
if (!currentStatus) return null;
if (hasError()) {
return (
<svg class="w-12 h-12 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
return <AlertTriangleIcon class="h-12 w-12 text-red-500" aria-hidden="true" />;
}
if (isComplete() && !hasError()) {
return (
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
return <CheckCircleIcon class="h-12 w-12 text-emerald-500" aria-hidden="true" />;
}
return (
<svg class="w-12 h-12 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
);
return <LoadingSpinner size="lg" tone="info" label="Update in progress" />;
};
const getStatusText = () => {
@@ -391,23 +363,24 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
<div class="px-6 py-4 border-b border-border">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-base-content">Updating Pulse</h2>
<button
<ActionIconButton
onClick={handleClose}
class="text-muted hover:text-base-content"
type="button"
aria-label={
label={
isComplete()
? 'Close update progress'
: 'Hide update progress. The update continues server-side.'
}
tone="muted"
size="md"
type="button"
title={
isComplete()
? 'Close update progress'
: 'Hide update progress. GlobalUpdateProgressWatcher keeps tracking the server-side update.'
}
>
<XIcon class="w-5 h-5" />
</button>
<XIcon class="h-5 w-5" aria-hidden="true" />
</ActionIconButton>
</div>
</div>
@@ -443,78 +416,65 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
{/* Error Message */}
<Show when={hasError() && status()?.error}>
<div class="mt-6 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-md p-4">
<div class="text-sm text-red-800 dark:text-red-200">
<div class="font-medium mb-1">Error Details:</div>
<div class="text-red-700 dark:text-red-300">{status()!.error}</div>
</div>
</div>
<CalloutCard
tone="danger"
scale="compact"
padding="md"
class="mt-6"
icon={<AlertTriangleIcon class="h-5 w-5" aria-hidden="true" />}
title="Error Details"
description={<span class="text-sm">{status()!.error}</span>}
/>
</Show>
{/* Warning / Info */}
<Show when={!isComplete()}>
<Show when={isRestarting()}>
<div class="mt-6 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<div class="flex items-start gap-2">
<svg
class="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<CalloutCard
tone="info"
scale="compact"
padding="md"
class="mt-6"
icon={<InfoIcon class="h-5 w-5" aria-hidden="true" />}
description={
<Show
when={wsDisconnected()}
fallback={
<span class="text-sm">Pulse is restarting with the new version...</span>
}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="flex-1">
<div class="text-sm text-blue-800 dark:text-blue-200">
<Show
when={wsDisconnected()}
fallback={<span>Pulse is restarting with the new version...</span>}
>
<span>
Waiting for Pulse to complete restart. This page will reload
automatically.
</span>
</Show>
</div>
<Show when={wsDisconnected() && healthCheckAttempts() > 5}>
<button
onClick={() => window.location.reload()}
class="mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded transition-colors"
type="button"
>
Reload Now
</button>
</Show>
</div>
</div>
</div>
<span class="text-sm">
Waiting for Pulse to complete restart. This page will reload automatically.
</span>
</Show>
}
>
<Show when={wsDisconnected() && healthCheckAttempts() > 5}>
<Button
onClick={() => window.location.reload()}
variant="primary"
size="sm"
class="mt-2"
type="button"
>
Reload Now
</Button>
</Show>
</CalloutCard>
</Show>
<Show when={!isRestarting()}>
<div class="mt-6 bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<div class="flex items-start gap-2">
<svg
class="w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<CalloutCard
tone="warning"
scale="compact"
padding="md"
class="mt-6"
icon={<AlertTriangleIcon class="h-5 w-5" aria-hidden="true" />}
description={
<span class="text-sm">
Please do not close this window or refresh the page during the update.
</div>
</div>
</div>
</span>
}
/>
</Show>
</Show>
</div>
@@ -523,30 +483,23 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
<Show when={isComplete()}>
<div class="px-6 py-4 bg-surface-alt border-t border-border flex items-center justify-end gap-3">
<Show when={!hasError()}>
<button
onClick={props.onViewHistory}
class="px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover rounded-md transition-colors"
type="button"
>
<Button onClick={props.onViewHistory} variant="ghost" size="md" type="button">
View History
</button>
</Button>
</Show>
<Show when={hasError()}>
<button
<Button
onClick={() => window.location.reload()}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
variant="primary"
size="md"
type="button"
>
Retry
</button>
</Button>
</Show>
<button
onClick={handleClose}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
type="button"
>
<Button onClick={handleClose} variant="primary" size="md" type="button">
Close
</button>
</Button>
</div>
</Show>
</div>
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { UpdateConfirmationModal } from '@/components/UpdateConfirmationModal';
import updateConfirmationModalSource from '@/components/UpdateConfirmationModal.tsx?raw';
describe('UpdateConfirmationModal', () => {
afterEach(() => {
@@ -44,4 +45,21 @@ describe('UpdateConfirmationModal', () => {
expect((reopenedCheckbox as HTMLInputElement).checked).toBe(false);
expect(await screen.findByRole('button', { name: 'Start Update' })).toBeDisabled();
});
it('keeps update confirmation chrome on shared primitives', () => {
expect(updateConfirmationModalSource).toContain('CalloutCard');
expect(updateConfirmationModalSource).toContain('Button');
expect(updateConfirmationModalSource).toContain('ActionIconButton');
expect(updateConfirmationModalSource).toContain('lucide-solid/icons/arrow-right');
expect(updateConfirmationModalSource).not.toContain('<svg');
expect(updateConfirmationModalSource).not.toContain(
'bg-blue-50 dark:bg-blue-900 border border-blue-200',
);
expect(updateConfirmationModalSource).not.toContain(
'bg-yellow-50 dark:bg-yellow-900 border border-yellow-200',
);
expect(updateConfirmationModalSource).not.toContain(
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
);
});
});
@@ -116,6 +116,8 @@ import sharedTemplateRegistrySource from '../../../scripts/shared-template-regis
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw';
import errorBoundarySource from '@/components/ErrorBoundary.tsx?raw';
import updateConfirmationModalSource from '@/components/UpdateConfirmationModal.tsx?raw';
import updateProgressModalSource from '@/components/UpdateProgressModal.tsx?raw';
import incidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw';
import alertDetailPresentationSource from '@/utils/alertDetailPresentation.ts?raw';
import alertSeverityPresentationSource from '@/utils/alertSeverityPresentation.ts?raw';
@@ -2520,6 +2522,7 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/UpdatesSettingsPanel.tsx',
'src/components/Settings/UserAssignmentsDialog.tsx',
'src/components/Settings/UserAssignmentsPanel.tsx',
'src/components/UpdateProgressModal.tsx',
'src/components/shared/Button.tsx',
'src/components/shared/DiscoveryLoadingFallback.tsx',
'src/components/shared/HistoryChartOverlay.tsx',
@@ -2616,6 +2619,7 @@ describe('shared primitive guardrails', () => {
discoveryLoadingFallbackSource,
historyChartOverlaySource,
pulseDataGridSource,
updateProgressModalSource,
]) {
expect(source).toContain('LoadingSpinner');
expect(source).not.toContain('animate-spin');
@@ -2904,6 +2908,8 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/InfrastructureWorkspace.tsx',
'src/components/Settings/ReportingPanel.tsx',
'src/components/Settings/ResourcePicker.tsx',
'src/components/UpdateConfirmationModal.tsx',
'src/components/UpdateProgressModal.tsx',
'src/components/Workloads/GuestDrawer.tsx',
'src/features/patrol/PatrolIntelligenceWorkspace.tsx',
'src/features/standalone/StandalonePageSurface.tsx',
@@ -2961,6 +2967,20 @@ describe('shared primitive guardrails', () => {
'text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700',
]),
}),
expect.objectContaining({
path: 'src/components/UpdateConfirmationModal.tsx',
patterns: expect.arrayContaining([
'px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover rounded-md transition-colors',
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
]),
}),
expect.objectContaining({
path: 'src/components/UpdateProgressModal.tsx',
patterns: expect.arrayContaining([
'mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded',
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
]),
}),
]),
);
expect(errorBoundarySource).toContain('Button');
@@ -2973,6 +2993,22 @@ describe('shared primitive guardrails', () => {
expect(errorBoundarySource).not.toContain(
'text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700',
);
expect(updateConfirmationModalSource).toContain('Button');
expect(updateConfirmationModalSource).toContain('ActionIconButton');
expect(updateConfirmationModalSource).not.toContain(
'px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover rounded-md transition-colors',
);
expect(updateConfirmationModalSource).not.toContain(
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
);
expect(updateProgressModalSource).toContain('Button');
expect(updateProgressModalSource).toContain('ActionIconButton');
expect(updateProgressModalSource).not.toContain(
'mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded',
);
expect(updateProgressModalSource).not.toContain(
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
);
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts');
expect(registeredGuard?.canonical?.export).toBe('getButtonClass');
expect(registeredGuard?.allPatterns).toEqual([
@@ -3735,6 +3771,74 @@ describe('shared primitive guardrails', () => {
);
});
it('routes update modal callouts, actions, and status indicators through shared primitives', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{
id: string;
canonical?: { path?: string; export?: string };
requiredConsumers?: Array<{ path?: string }>;
forbiddenPatterns?: Array<{ path?: string; patterns?: string[] }>;
}>;
};
const registeredRule = registry.rules?.find(
(rule) => rule.id === 'update-modal-action-callout-shell',
);
expect(registeredRule?.canonical?.path).toBe('src/components/shared/CalloutCard.tsx');
expect(registeredRule?.canonical?.export).toBe('CalloutCard');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/UpdateConfirmationModal.tsx',
'src/components/UpdateProgressModal.tsx',
]);
expect(registeredRule?.forbiddenPatterns).toEqual([
{
path: 'src/components/UpdateConfirmationModal.tsx',
patterns: [
'<svg',
'bg-blue-50 dark:bg-blue-900 border border-blue-200',
'bg-yellow-50 dark:bg-yellow-900 border border-yellow-200',
'rounded-md p-4 border',
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
],
},
{
path: 'src/components/UpdateProgressModal.tsx',
patterns: [
'<svg',
'bg-red-50 dark:bg-red-900 border border-red-200',
'bg-blue-50 dark:bg-blue-900 border border-blue-200',
'bg-yellow-50 dark:bg-yellow-900 border border-yellow-200',
'mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded',
],
},
]);
expect(updateConfirmationModalSource).toContain('CalloutCard');
expect(updateConfirmationModalSource).toContain('Button');
expect(updateConfirmationModalSource).toContain('ActionIconButton');
expect(updateConfirmationModalSource).toContain('lucide-solid/icons/arrow-right');
expect(updateProgressModalSource).toContain('CalloutCard');
expect(updateProgressModalSource).toContain('Button');
expect(updateProgressModalSource).toContain('ActionIconButton');
expect(updateProgressModalSource).toContain('LoadingSpinner');
for (const source of [updateConfirmationModalSource, updateProgressModalSource]) {
expect(source).not.toContain('<svg');
expect(source).not.toContain('bg-blue-50 dark:bg-blue-900 border border-blue-200');
expect(source).not.toContain('bg-yellow-50 dark:bg-yellow-900 border border-yellow-200');
expect(source).not.toContain(
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
);
}
expect(updateConfirmationModalSource).not.toContain('rounded-md p-4 border');
expect(updateProgressModalSource).not.toContain(
'bg-red-50 dark:bg-red-900 border border-red-200',
);
expect(updateProgressModalSource).not.toContain(
'mt-2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded',
);
});
it('routes platform inline notices through InlineNotice', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{