Keep a disabled toggle's track visible and true to its state (#2535)

A disabled toggle set its track to `bg-base`, which isn't a theme
colour. The `base` colour alias was removed long ago because it collided
with the `text-base` font-size utility, so the track rendered
transparent. As a result, every disabled toggle showed only a faint
knob:

- Plan-locked settings such as Settings › Patrol › **Container update
risk** were nearly invisible.
- In read-only sessions, 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 to 50%.
The knob relies on the track's opacity instead of dimming a second time.

Verification:
- Measured live on Settings › Patrol, in light and dark mode at 1440px
and at 390px. With the settings response set to what a free-tier backend
returns, the track background was transparent on main; it is now the off
colour (slate-200 in light mode, slate-700 in dark) at 50%.
- With an "on" value, the same disabled toggle reads as a dimmed on
switch.
- Added a toggle test and a shared-primitive guardrail. The receipt is
included.

`bg-base` is used about 80 more times elsewhere and is a no-op in each
of those places too. That is a separate sweep.
This commit is contained in:
rcourtman
2026-10-05 22:55:00 +01:00
committed by GitHub
5 changed files with 70 additions and 5 deletions
@@ -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
@@ -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"
]
}
@@ -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');
@@ -32,6 +32,23 @@ describe('Toggle', () => {
expect(toggleModelSource).toContain('ToggleChangeEvent');
});
it('keeps a disabled toggle on its on or off colour, dimmed', () => {
render(() => (
<>
<Toggle checked disabled ariaLabel="Locked on" />
<Toggle checked={false} disabled ariaLabel="Locked off" />
</>
));
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());
@@ -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 {