diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 2a9743cff..3646d9919 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -263,6 +263,14 @@ plus the synthetic toggle change-event runtime, and
track/knob/container class policy, and the canonical toggle event type.
Future toggle work should extend those owners instead of pushing synthetic
event behavior or size/class policy back into the shell.
+The shared status badge now follows that same owner split.
+`frontend-modern/src/components/shared/StatusBadge.tsx` stays the render shell,
+`frontend-modern/src/components/shared/useStatusBadgeState.ts` owns disabled
+gating and click runtime, and
+`frontend-modern/src/components/shared/statusBadgeModel.ts` owns size padding,
+label/title fallback policy, and status-badge class selection. Future status
+badge work should extend those owners instead of pushing label/title policy or
+disabled click handling back into the shell.
The shared dialog now follows that same owner split.
`frontend-modern/src/components/shared/Dialog.tsx` stays the render shell,
`frontend-modern/src/components/shared/useDialogState.ts` owns focus trap,
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index cd965ffd3..9f6f2a5fa 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -39,6 +39,8 @@ import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw';
import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw';
+import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw';
+import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw';
import toggleSource from '@/components/shared/Toggle.tsx?raw';
import toggleModelSource from '@/components/shared/toggleModel.ts?raw';
import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw';
@@ -76,6 +78,7 @@ import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import scrollToTopButtonStateSource from '@/components/shared/useScrollToTopButtonState.ts?raw';
+import statusBadgeStateSource from '@/components/shared/useStatusBadgeState.ts?raw';
import toggleStateSource from '@/components/shared/useToggleState.ts?raw';
import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw';
import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
@@ -305,6 +308,27 @@ describe('shared primitive guardrails', () => {
expect(toggleModelSource).toContain('ToggleChangeEvent');
});
+ it('keeps status badge on shell, runtime, and model owners', () => {
+ expect(statusBadgeSource).toContain('useStatusBadgeState');
+ expect(statusBadgeSource).toContain('getStatusBadgeClass');
+ expect(statusBadgeSource).toContain('getStatusBadgeLabel');
+ expect(statusBadgeSource).toContain('getStatusBadgeTitle');
+ expect(statusBadgeSource).not.toContain('cursor-not-allowed');
+ expect(statusBadgeSource).not.toContain('props.onToggle?.()');
+ expect(statusBadgeSource).not.toContain('labelEnabled ??');
+
+ expect(statusBadgeStateSource).toContain('export function useStatusBadgeState');
+ expect(statusBadgeStateSource).toContain('Boolean(props.disabled)');
+ expect(statusBadgeStateSource).toContain('props.onToggle?.()');
+ expect(statusBadgeStateSource).toContain('if (isDisabled())');
+
+ expect(statusBadgeModelSource).toContain('STATUS_BADGE_PADDING_BY_SIZE');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeClass');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeLabel');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeTitle');
+ expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'");
+ });
+
it('routes settings info callouts through CalloutCard', () => {
expect(calloutCardSource).toContain(
"type CalloutTone = 'danger' | 'info' | 'success' | 'warning'",
diff --git a/frontend-modern/src/components/shared/StatusBadge.test.tsx b/frontend-modern/src/components/shared/StatusBadge.test.tsx
new file mode 100644
index 000000000..12302ad6a
--- /dev/null
+++ b/frontend-modern/src/components/shared/StatusBadge.test.tsx
@@ -0,0 +1,62 @@
+import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { StatusBadge } from './StatusBadge';
+import statusBadgeSource from './StatusBadge.tsx?raw';
+import statusBadgeModelSource from './statusBadgeModel.ts?raw';
+import statusBadgeStateSource from './useStatusBadgeState.ts?raw';
+
+describe('StatusBadge', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('keeps status badge on shell, runtime, and model owners', () => {
+ expect(statusBadgeSource).toContain('useStatusBadgeState');
+ expect(statusBadgeSource).toContain('getStatusBadgeClass');
+ expect(statusBadgeSource).toContain('getStatusBadgeLabel');
+ expect(statusBadgeSource).toContain('getStatusBadgeTitle');
+ expect(statusBadgeSource).not.toContain('cursor-not-allowed');
+ expect(statusBadgeSource).not.toContain('props.onToggle?.()');
+ expect(statusBadgeSource).not.toContain('labelEnabled ??');
+
+ expect(statusBadgeStateSource).toContain('export function useStatusBadgeState');
+ expect(statusBadgeStateSource).toContain('Boolean(props.disabled)');
+ expect(statusBadgeStateSource).toContain('props.onToggle?.()');
+ expect(statusBadgeStateSource).toContain('if (isDisabled())');
+
+ expect(statusBadgeModelSource).toContain('STATUS_BADGE_PADDING_BY_SIZE');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeClass');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeLabel');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeTitle');
+ expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'");
+ });
+
+ it('renders label/title policy and blocks toggles when disabled', () => {
+ const onToggle = vi.fn();
+
+ render(() => (
+
+ ));
+
+ const button = screen.getByRole('button', { name: 'Disabled' });
+ expect(button).toHaveAttribute('title', 'Locked');
+ fireEvent.click(button);
+ expect(onToggle).not.toHaveBeenCalled();
+ });
+
+ it('allows toggles when enabled and not disabled', () => {
+ const onToggle = vi.fn();
+
+ render(() => );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Enabled' }));
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend-modern/src/components/shared/StatusBadge.tsx b/frontend-modern/src/components/shared/StatusBadge.tsx
index be58cbc64..2bb26b69d 100644
--- a/frontend-modern/src/components/shared/StatusBadge.tsx
+++ b/frontend-modern/src/components/shared/StatusBadge.tsx
@@ -1,62 +1,27 @@
import { JSX } from 'solid-js';
+import {
+ getStatusBadgeClass,
+ getStatusBadgeLabel,
+ getStatusBadgeTitle,
+ resolveStatusBadgeSize,
+ type StatusBadgeProps,
+} from '@/components/shared/statusBadgeModel';
+import { useStatusBadgeState } from '@/components/shared/useStatusBadgeState';
-interface StatusBadgeProps {
- isEnabled: boolean;
- disabled?: boolean;
- size?: 'sm' | 'md';
- onToggle?: () => void;
- labelEnabled?: string;
- labelDisabled?: string;
- titleEnabled?: string;
- titleDisabled?: string;
- titleWhenDisabled?: string;
-}
-
-/**
- * StatusBadge - A shared component for enabled/disabled status badges
- * Used throughout the app for consistent styling of status indicators
- */
export function StatusBadge(props: StatusBadgeProps): JSX.Element {
- const {
- isEnabled,
- disabled = false,
- size = 'sm',
- onToggle,
- labelEnabled = 'Enabled',
- labelDisabled = 'Disabled',
- titleEnabled,
- titleDisabled,
- titleWhenDisabled,
- } = props;
-
- const basePadding = size === 'md' ? 'px-2.5 py-1' : 'px-2 py-0.5';
- const baseClasses = `inline-flex items-center justify-center ${basePadding} text-xs font-medium rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-blue-400`;
- const stateClasses = isEnabled
- ? 'bg-blue-50 text-blue-700 hover:bg-blue-100 dark:bg-blue-500 dark:text-blue-300 dark:hover:bg-blue-500'
- : 'text-muted hover:bg-surface-hover';
- const disabledClasses = disabled
- ? 'opacity-60 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent'
- : '';
-
- const title = disabled
- ? (titleWhenDisabled ?? titleDisabled ?? titleEnabled ?? '')
- : isEnabled
- ? (titleEnabled ?? '')
- : (titleDisabled ?? '');
+ const state = useStatusBadgeState(props);
+ const size = resolveStatusBadgeSize(props.size);
return (
);
}
diff --git a/frontend-modern/src/components/shared/statusBadgeModel.ts b/frontend-modern/src/components/shared/statusBadgeModel.ts
new file mode 100644
index 000000000..ec3366d9c
--- /dev/null
+++ b/frontend-modern/src/components/shared/statusBadgeModel.ts
@@ -0,0 +1,53 @@
+export type StatusBadgeSize = 'sm' | 'md';
+
+export interface StatusBadgeProps {
+ isEnabled: boolean;
+ disabled?: boolean;
+ size?: StatusBadgeSize;
+ onToggle?: () => void;
+ labelEnabled?: string;
+ labelDisabled?: string;
+ titleEnabled?: string;
+ titleDisabled?: string;
+ titleWhenDisabled?: string;
+}
+
+const STATUS_BADGE_BASE_CLASS =
+ 'inline-flex items-center justify-center text-xs font-medium rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-blue-400';
+const STATUS_BADGE_PADDING_BY_SIZE: Record = {
+ sm: 'px-2 py-0.5',
+ md: 'px-2.5 py-1',
+};
+const STATUS_BADGE_ENABLED_CLASS =
+ 'bg-blue-50 text-blue-700 hover:bg-blue-100 dark:bg-blue-500 dark:text-blue-300 dark:hover:bg-blue-500';
+const STATUS_BADGE_DISABLED_STATE_CLASS = 'text-muted hover:bg-surface-hover';
+const STATUS_BADGE_INTERACTION_DISABLED_CLASS =
+ 'opacity-60 cursor-not-allowed hover:bg-transparent dark:hover:bg-transparent';
+
+export function resolveStatusBadgeSize(size: StatusBadgeSize | undefined): StatusBadgeSize {
+ return size ?? 'sm';
+}
+
+export function getStatusBadgeClass(
+ size: StatusBadgeSize,
+ isEnabled: boolean,
+ disabled: boolean,
+): string {
+ return [
+ STATUS_BADGE_BASE_CLASS,
+ STATUS_BADGE_PADDING_BY_SIZE[size],
+ isEnabled ? STATUS_BADGE_ENABLED_CLASS : STATUS_BADGE_DISABLED_STATE_CLASS,
+ disabled ? STATUS_BADGE_INTERACTION_DISABLED_CLASS : '',
+ ].join(' ').trim();
+}
+
+export function getStatusBadgeLabel(props: StatusBadgeProps): string {
+ return props.isEnabled ? (props.labelEnabled ?? 'Enabled') : (props.labelDisabled ?? 'Disabled');
+}
+
+export function getStatusBadgeTitle(props: StatusBadgeProps, disabled: boolean): string {
+ if (disabled) {
+ return props.titleWhenDisabled ?? props.titleDisabled ?? props.titleEnabled ?? '';
+ }
+ return props.isEnabled ? (props.titleEnabled ?? '') : (props.titleDisabled ?? '');
+}
diff --git a/frontend-modern/src/components/shared/useStatusBadgeState.ts b/frontend-modern/src/components/shared/useStatusBadgeState.ts
new file mode 100644
index 000000000..141b39185
--- /dev/null
+++ b/frontend-modern/src/components/shared/useStatusBadgeState.ts
@@ -0,0 +1,15 @@
+import type { StatusBadgeProps } from '@/components/shared/statusBadgeModel';
+
+export function useStatusBadgeState(props: StatusBadgeProps) {
+ const isDisabled = () => Boolean(props.disabled);
+
+ return {
+ handleClick: () => {
+ if (isDisabled()) {
+ return;
+ }
+ props.onToggle?.();
+ },
+ isDisabled,
+ };
+}
diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
index 13a3f4be8..e51243d15 100644
--- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
+++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts
@@ -44,6 +44,8 @@ import searchInputSource from '@/components/shared/SearchInput.tsx?raw';
import searchInputModelSource from '@/components/shared/searchInputModel.ts?raw';
import scrollToTopButtonSource from '@/components/shared/ScrollToTopButton.tsx?raw';
import scrollToTopButtonModelSource from '@/components/shared/scrollToTopButtonModel.ts?raw';
+import statusBadgeSource from '@/components/shared/StatusBadge.tsx?raw';
+import statusBadgeModelSource from '@/components/shared/statusBadgeModel.ts?raw';
import toggleSource from '@/components/shared/Toggle.tsx?raw';
import toggleModelSource from '@/components/shared/toggleModel.ts?raw';
import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw';
@@ -76,6 +78,7 @@ import whatsNewModalStateSource from '@/components/shared/useWhatsNewModalState.
import searchFieldStateSource from '@/components/shared/useSearchFieldState.ts?raw';
import searchInputStateSource from '@/components/shared/useSearchInputState.ts?raw';
import scrollToTopButtonStateSource from '@/components/shared/useScrollToTopButtonState.ts?raw';
+import statusBadgeStateSource from '@/components/shared/useStatusBadgeState.ts?raw';
import toggleStateSource from '@/components/shared/useToggleState.ts?raw';
import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopoverState.ts?raw';
import tooltipStateSource from '@/components/shared/useTooltipState.ts?raw';
@@ -2830,6 +2833,21 @@ describe('frontend resource type boundaries', () => {
expect(toggleModelSource).toContain('getToggleTrackClass');
expect(toggleModelSource).toContain('getToggleKnobClass');
expect(toggleModelSource).toContain('ToggleChangeEvent');
+ expect(statusBadgeSource).toContain('useStatusBadgeState');
+ expect(statusBadgeSource).toContain('getStatusBadgeClass');
+ expect(statusBadgeSource).toContain('getStatusBadgeLabel');
+ expect(statusBadgeSource).toContain('getStatusBadgeTitle');
+ expect(statusBadgeSource).not.toContain('cursor-not-allowed');
+ expect(statusBadgeSource).not.toContain('props.onToggle?.()');
+ expect(statusBadgeSource).not.toContain('labelEnabled ??');
+ expect(statusBadgeStateSource).toContain('Boolean(props.disabled)');
+ expect(statusBadgeStateSource).toContain('props.onToggle?.()');
+ expect(statusBadgeStateSource).toContain('if (isDisabled())');
+ expect(statusBadgeModelSource).toContain('STATUS_BADGE_PADDING_BY_SIZE');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeClass');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeLabel');
+ expect(statusBadgeModelSource).toContain('getStatusBadgeTitle');
+ expect(statusBadgeModelSource).toContain("labelEnabled ?? 'Enabled'");
expect(monitoredSystemLimitWarningBannerSource).toContain(
'useMonitoredSystemLimitWarningBannerState',
);