From 531a34f5cb9793b7773de95218c3dfec47751c2d Mon Sep 17 00:00:00 2001
From: rcourtman <8825017+rcourtman@users.noreply.github.com>
Date: Mon, 5 Oct 2026 21:48:58 +0100
Subject: [PATCH] Keep a disabled toggle's track visible and true to its state
A disabled toggle switched its track to bg-base, which names no theme
colour (the base alias was removed because it collided with text-base),
so the track rendered transparent. Every disabled toggle showed only a
faint knob: a plan-locked setting such as Patrol's Container update risk
was nearly invisible, and for a read-only session a setting that was on
looked the same as one that was off.
A disabled toggle now keeps its on or off track colour and dims, and the
knob relies on the track's opacity instead of dimming again.
---
.../subsystems/frontend-primitives.md | 4 +++
.../4aa9308843ebb8ab.json | 35 +++++++++++++++++++
.../SharedPrimitives.guardrails.test.ts | 4 +++
.../src/components/shared/Toggle.test.tsx | 17 +++++++++
.../src/components/shared/toggleModel.ts | 15 +++++---
5 files changed, 70 insertions(+), 5 deletions(-)
create mode 100644 frontend-modern/browser-verification/4aa9308843ebb8ab.json
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index e077067776..38a433deaf 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -5070,6 +5070,10 @@ 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.
+A disabled toggle (a read-only session, a plan lock, a save in flight) keeps
+its on or off track colour and dims, so it still shows whether the setting is
+on; it must not swap to a separate disabled background, which hid the track
+and made a locked-on setting read as off.
Binary on/off controls are registry-backed too. Product surfaces must compose
`Toggle` or `TogglePrimitive` for shared track/knob styling, disabled behavior,
label/description wiring, and synthetic checked events instead of recreating
diff --git a/frontend-modern/browser-verification/4aa9308843ebb8ab.json b/frontend-modern/browser-verification/4aa9308843ebb8ab.json
new file mode 100644
index 0000000000..f1929f9cb0
--- /dev/null
+++ b/frontend-modern/browser-verification/4aa9308843ebb8ab.json
@@ -0,0 +1,35 @@
+{
+ "version": 1,
+ "base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a",
+ "verified_at": "2026-10-05T20:47:57Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/shared/toggleModel.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/shared/toggleModel.ts": "141b8981ae6c92eb4a3f3c8d5a82ce9b8a470f675551ec5b93d2638a5330a5d6"
+ },
+ "routes": [
+ "/settings/pulse-intelligence/patrol"
+ ],
+ "viewports": [
+ {
+ "width": 1440,
+ "height": 1000
+ },
+ {
+ "width": 390,
+ "height": 1000
+ }
+ ],
+ "states": [
+ "Settings > Patrol with the Container update risk toggle disabled by its plan lock and off (the /api/settings/ai response set to what a free-tier backend returns), light and dark",
+ "Before (main): the disabled toggle's track computed to rgba(0,0,0,0), leaving only a faint half-opacity knob on the card",
+ "After: the track keeps its off colour (rgb(226,232,240) light, rgb(51,65,85) dark) at 50% opacity and reads as a dimmed off switch; enabled toggles beside it are unchanged",
+ "With the dev stack's unmasked response (on), the same disabled toggle reads as a dimmed on switch rather than an empty track",
+ "Phone at 390: same rendering"
+ ],
+ "interactions": [
+ "Compared main (reverse-applied patch) and the change with the settings response intercepted, at 1440 light and dark and 390"
+ ]
+}
diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
index 1769154c13..a6210b35fb 100644
--- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
+++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts
@@ -2117,6 +2117,10 @@ describe('shared primitive guardrails', () => {
expect(toggleModelSource).toContain('getToggleTrackClass');
expect(toggleModelSource).toContain('getToggleKnobClass');
expect(toggleModelSource).toContain('ToggleChangeEvent');
+ // A disabled toggle keeps its on/off track and dims; bg-base names no
+ // theme colour, so a disabled track built on it rendered transparent.
+ expect(toggleModelSource).not.toMatch(/TOGGLE_DISABLED_CLASS = '[^']*bg-base/);
+ expect(toggleModelSource).toContain('checked ? TOGGLE_CHECKED_CLASS : TOGGLE_UNCHECKED_CLASS');
expect(dockerRuntimeSettingsCardSource).toContain('TogglePrimitive');
expect(dockerRuntimeSettingsCardSource).toContain('ariaLabelledBy');
diff --git a/frontend-modern/src/components/shared/Toggle.test.tsx b/frontend-modern/src/components/shared/Toggle.test.tsx
index 4ec53b49ba..7dec6b85cb 100644
--- a/frontend-modern/src/components/shared/Toggle.test.tsx
+++ b/frontend-modern/src/components/shared/Toggle.test.tsx
@@ -32,6 +32,23 @@ describe('Toggle', () => {
expect(toggleModelSource).toContain('ToggleChangeEvent');
});
+ it('keeps a disabled toggle on its on or off colour, dimmed', () => {
+ render(() => (
+ <>
+
+
+ >
+ ));
+
+ const lockedOn = screen.getByRole('button', { name: 'Locked on' });
+ const lockedOff = screen.getByRole('button', { name: 'Locked off' });
+ expect(lockedOn).toHaveClass('bg-blue-500', 'opacity-50', 'cursor-not-allowed');
+ expect(lockedOff).toHaveClass('bg-border', 'opacity-50', 'cursor-not-allowed');
+ // bg-base names no theme colour, so it rendered no track at all.
+ expect(lockedOn.className).not.toContain('bg-base');
+ expect(lockedOff.className).not.toContain('bg-base');
+ });
+
it('emits the synthetic next checked state and respects preventDefault', () => {
const onToggle = vi.fn();
const onChange = vi.fn((event) => event.preventDefault());
diff --git a/frontend-modern/src/components/shared/toggleModel.ts b/frontend-modern/src/components/shared/toggleModel.ts
index 3f2c495c68..3907269951 100644
--- a/frontend-modern/src/components/shared/toggleModel.ts
+++ b/frontend-modern/src/components/shared/toggleModel.ts
@@ -11,7 +11,11 @@ export interface ToggleChangeEvent {
const TOGGLE_CHECKED_CLASS = 'bg-blue-500';
const TOGGLE_UNCHECKED_CLASS = 'bg-border';
-const TOGGLE_DISABLED_CLASS = 'bg-base cursor-not-allowed opacity-50';
+// A disabled switch (read-only session, plan lock, save in flight) keeps the
+// track colour of its state and dims, so it still says whether the setting is
+// on. The old bg-base track named no theme colour and rendered transparent:
+// a disabled switch lost its track and an enabled-but-locked one read as off.
+const TOGGLE_DISABLED_CLASS = 'cursor-not-allowed opacity-50';
const TOGGLE_KNOB_BASE_CLASS = 'bg-surface border border-border-subtle';
const TOGGLE_CONTAINER_CLASS = 'flex items-center gap-3';
const TOGGLE_LABEL_CLASS = 'flex flex-col text-sm text-base-content';
@@ -50,15 +54,16 @@ export function getToggleTrackClass(
): string {
const config = toggleSizeConfig[size];
return `relative inline-flex ${config.track} shrink-0 items-center rounded-full p-0.5 transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] after:absolute after:-inset-y-2.5 after:inset-x-0 after:content-[''] focus:outline-hidden focus:ring-0 ${
- disabled ? TOGGLE_DISABLED_CLASS : checked ? TOGGLE_CHECKED_CLASS : TOGGLE_UNCHECKED_CLASS
- } ${className ?? ''}`.trim();
+ checked ? TOGGLE_CHECKED_CLASS : TOGGLE_UNCHECKED_CLASS
+ } ${disabled ? TOGGLE_DISABLED_CLASS : ''} ${className ?? ''}`.trim();
}
-export function getToggleKnobClass(size: ToggleSize, checked: boolean, disabled: boolean): string {
+// The knob sits inside the track, so a disabled track's opacity already dims it.
+export function getToggleKnobClass(size: ToggleSize, checked: boolean, _disabled: boolean): string {
const config = toggleSizeConfig[size];
return `inline-block ${config.knob} rounded-full transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] ${TOGGLE_KNOB_BASE_CLASS} ${
checked ? config.translateOn : 'translate-x-0'
- } ${disabled ? 'opacity-60' : ''}`.trim();
+ }`.trim();
}
export function getToggleContainerClass(containerClass?: string): string {