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 {